2026-02-13 19:06:28 -07:00
|
|
|
"""
|
|
|
|
|
Slack Socket Mode adapter for ajarbot.
|
|
|
|
|
|
|
|
|
|
Uses Socket Mode for easy firewall-free integration without webhooks.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
from slack_bolt.adapter.socket_mode.async_handler import (
|
|
|
|
|
AsyncSocketModeHandler,
|
|
|
|
|
)
|
|
|
|
|
from slack_bolt.async_app import AsyncApp
|
|
|
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
|
|
|
|
|
|
from adapters.base import (
|
|
|
|
|
AdapterCapabilities,
|
|
|
|
|
AdapterConfig,
|
|
|
|
|
BaseAdapter,
|
|
|
|
|
InboundMessage,
|
|
|
|
|
MessageType,
|
|
|
|
|
OutboundMessage,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SlackAdapter(BaseAdapter):
|
|
|
|
|
"""
|
|
|
|
|
Slack adapter using Socket Mode.
|
|
|
|
|
|
|
|
|
|
Socket Mode allows receiving events over WebSocket without exposing
|
|
|
|
|
a public HTTP endpoint - perfect for development and simple deployments.
|
|
|
|
|
|
|
|
|
|
Configuration required:
|
|
|
|
|
- bot_token: Bot User OAuth Token (xoxb-...)
|
|
|
|
|
- app_token: App-Level Token (xapp-...)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: AdapterConfig) -> None:
|
|
|
|
|
super().__init__(config)
|
|
|
|
|
self.app: Optional[AsyncApp] = None
|
|
|
|
|
self.handler: Optional[AsyncSocketModeHandler] = None
|
2026-02-13 23:38:44 -07:00
|
|
|
self._username_cache: Dict[str, str] = {} # user_id -> username
|
2026-02-13 19:06:28 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def platform_name(self) -> str:
|
|
|
|
|
return "slack"
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def capabilities(self) -> AdapterCapabilities:
|
|
|
|
|
return AdapterCapabilities(
|
|
|
|
|
supports_threads=True,
|
|
|
|
|
supports_reactions=True,
|
|
|
|
|
supports_media=True,
|
|
|
|
|
supports_files=True,
|
|
|
|
|
supports_markdown=True,
|
|
|
|
|
max_message_length=4000,
|
|
|
|
|
chunking_strategy="word",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def validate_config(self) -> bool:
|
2026-03-01 14:34:24 -07:00
|
|
|
"""Validate Slack configuration.
|
|
|
|
|
|
|
|
|
|
Required scopes for bot token:
|
|
|
|
|
- files:read (for downloading file attachments)
|
|
|
|
|
- files:write (for uploading files - future feature)
|
|
|
|
|
"""
|
2026-02-13 19:06:28 -07:00
|
|
|
if not self.config.credentials:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
bot_token = self.config.credentials.get("bot_token", "")
|
|
|
|
|
app_token = self.config.credentials.get("app_token", "")
|
|
|
|
|
|
2026-03-01 14:34:24 -07:00
|
|
|
valid = (
|
2026-02-13 19:06:28 -07:00
|
|
|
bool(bot_token and app_token)
|
|
|
|
|
and bot_token.startswith("xoxb-")
|
|
|
|
|
and app_token.startswith("xapp-")
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-01 14:34:24 -07:00
|
|
|
if valid:
|
|
|
|
|
print("[Slack] ✓ Config valid. Ensure bot has 'files:read' and 'files:write' scopes at api.slack.com")
|
|
|
|
|
|
|
|
|
|
return valid
|
|
|
|
|
|
2026-02-13 19:06:28 -07:00
|
|
|
async def start(self) -> None:
|
|
|
|
|
"""Start the Slack Socket Mode connection."""
|
|
|
|
|
if not self.validate_config():
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"Invalid Slack configuration. "
|
|
|
|
|
"Need bot_token (xoxb-...) and app_token (xapp-...)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
bot_token = self.config.credentials["bot_token"]
|
|
|
|
|
app_token = self.config.credentials["app_token"]
|
|
|
|
|
|
|
|
|
|
self.app = AsyncApp(token=bot_token)
|
|
|
|
|
self._register_handlers()
|
|
|
|
|
|
|
|
|
|
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
|
|
|
|
|
|
|
|
|
print("[Slack] Starting Socket Mode connection...")
|
2026-02-22 21:19:28 -07:00
|
|
|
# Connect to Slack (non-blocking)
|
|
|
|
|
await self.handler.connect_async()
|
2026-02-13 19:06:28 -07:00
|
|
|
|
|
|
|
|
self.is_running = True
|
|
|
|
|
print("[Slack] Connected and listening for messages")
|
|
|
|
|
|
|
|
|
|
async def stop(self) -> None:
|
|
|
|
|
"""Stop the Slack Socket Mode connection."""
|
|
|
|
|
if self.handler:
|
|
|
|
|
print("[Slack] Stopping Socket Mode connection...")
|
2026-02-22 21:19:28 -07:00
|
|
|
await self.handler.disconnect_async()
|
2026-02-13 19:06:28 -07:00
|
|
|
self.is_running = False
|
|
|
|
|
print("[Slack] Disconnected")
|
|
|
|
|
|
|
|
|
|
def _register_handlers(self) -> None:
|
|
|
|
|
"""Register Slack event handlers."""
|
|
|
|
|
|
|
|
|
|
@self.app.event("message")
|
|
|
|
|
async def handle_message_events(event, say):
|
|
|
|
|
"""Handle incoming messages."""
|
|
|
|
|
if event.get("subtype") in ["bot_message", "message_changed"]:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
user_id = event.get("user")
|
|
|
|
|
text = event.get("text", "")
|
|
|
|
|
channel = event.get("channel")
|
|
|
|
|
thread_ts = event.get("thread_ts")
|
|
|
|
|
ts = event.get("ts")
|
2026-03-01 14:34:24 -07:00
|
|
|
files = event.get("files", [])
|
|
|
|
|
|
|
|
|
|
# DEBUG: Log full event structure
|
|
|
|
|
print(f"[Slack DEBUG] Event subtype: {event.get('subtype')}")
|
|
|
|
|
print(f"[Slack DEBUG] Event has text: {bool(text)}, text length: {len(text)}")
|
|
|
|
|
print(f"[Slack DEBUG] Event has files: {bool(files)}, file count: {len(files)}")
|
|
|
|
|
|
|
|
|
|
# DEBUG: Log file detection
|
|
|
|
|
if files:
|
|
|
|
|
print(f"[Slack DEBUG] Detected {len(files)} file(s) in message")
|
|
|
|
|
for f in files:
|
|
|
|
|
print(f"[Slack DEBUG] File: {f.get('name')} ({f.get('mimetype')}, ID: {f.get('id')})")
|
2026-02-13 19:06:28 -07:00
|
|
|
|
|
|
|
|
username = await self._get_username(user_id)
|
|
|
|
|
|
2026-03-01 14:34:24 -07:00
|
|
|
# Determine message type
|
|
|
|
|
message_type = MessageType.FILE if files else MessageType.TEXT
|
|
|
|
|
|
|
|
|
|
# Download files
|
|
|
|
|
downloaded_files = []
|
|
|
|
|
for file_info in files:
|
|
|
|
|
print(f"[Slack DEBUG] Downloading: {file_info.get('name')} (ID: {file_info.get('id')})")
|
|
|
|
|
result = await self._download_slack_file(file_info)
|
|
|
|
|
if result["success"]:
|
|
|
|
|
print(f"[Slack DEBUG] Downloaded to: {result['file_path']}")
|
|
|
|
|
downloaded_files.append(result)
|
|
|
|
|
else:
|
|
|
|
|
print(f"[Slack] Failed to download file {file_info.get('name')}: {result['error']}")
|
|
|
|
|
|
|
|
|
|
# If files but no text, add placeholder
|
|
|
|
|
if files and not text:
|
|
|
|
|
file_names = ", ".join(f["filename"] for f in downloaded_files)
|
|
|
|
|
text = f"[Uploaded {len(downloaded_files)} file(s): {file_names}]"
|
|
|
|
|
|
2026-02-13 19:06:28 -07:00
|
|
|
inbound_msg = InboundMessage(
|
|
|
|
|
platform="slack",
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
username=username,
|
|
|
|
|
text=text,
|
|
|
|
|
channel_id=channel,
|
|
|
|
|
thread_id=thread_ts,
|
|
|
|
|
reply_to_id=None,
|
2026-03-01 14:34:24 -07:00
|
|
|
message_type=message_type,
|
2026-02-13 19:06:28 -07:00
|
|
|
metadata={
|
|
|
|
|
"ts": ts,
|
|
|
|
|
"team": event.get("team"),
|
|
|
|
|
"channel_type": event.get("channel_type"),
|
2026-03-01 14:34:24 -07:00
|
|
|
"files": downloaded_files,
|
2026-02-13 19:06:28 -07:00
|
|
|
},
|
|
|
|
|
raw=event,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._dispatch_message(inbound_msg)
|
|
|
|
|
|
|
|
|
|
@self.app.event("app_mention")
|
|
|
|
|
async def handle_app_mentions(event, say):
|
|
|
|
|
"""Handle @mentions of the bot."""
|
|
|
|
|
user_id = event.get("user")
|
|
|
|
|
text = self._strip_mention(event.get("text", ""))
|
|
|
|
|
channel = event.get("channel")
|
|
|
|
|
thread_ts = event.get("thread_ts")
|
|
|
|
|
ts = event.get("ts")
|
2026-03-01 14:34:24 -07:00
|
|
|
files = event.get("files", [])
|
|
|
|
|
|
|
|
|
|
# DEBUG: Log file detection
|
|
|
|
|
if files:
|
|
|
|
|
print(f"[Slack DEBUG @mention] Detected {len(files)} file(s)")
|
|
|
|
|
for f in files:
|
|
|
|
|
print(f"[Slack DEBUG @mention] File: {f.get('name')} ({f.get('mimetype')})")
|
2026-02-13 19:06:28 -07:00
|
|
|
|
|
|
|
|
username = await self._get_username(user_id)
|
|
|
|
|
|
2026-03-01 14:34:24 -07:00
|
|
|
# Determine message type
|
|
|
|
|
message_type = MessageType.FILE if files else MessageType.TEXT
|
|
|
|
|
|
|
|
|
|
# Download files
|
|
|
|
|
downloaded_files = []
|
|
|
|
|
for file_info in files:
|
|
|
|
|
print(f"[Slack DEBUG @mention] Downloading: {file_info.get('name')} (ID: {file_info.get('id')})")
|
|
|
|
|
result = await self._download_slack_file(file_info)
|
|
|
|
|
if result["success"]:
|
|
|
|
|
print(f"[Slack DEBUG @mention] Downloaded to: {result['file_path']}")
|
|
|
|
|
downloaded_files.append(result)
|
|
|
|
|
else:
|
|
|
|
|
print(f"[Slack @mention] Failed to download file {file_info.get('name')}: {result['error']}")
|
|
|
|
|
|
|
|
|
|
# If files but no text (after stripping mention), add placeholder
|
|
|
|
|
if files and not text:
|
|
|
|
|
file_names = ", ".join(f["filename"] for f in downloaded_files)
|
|
|
|
|
text = f"[Uploaded {len(downloaded_files)} file(s): {file_names}]"
|
|
|
|
|
|
2026-02-13 19:06:28 -07:00
|
|
|
inbound_msg = InboundMessage(
|
|
|
|
|
platform="slack",
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
username=username,
|
|
|
|
|
text=text,
|
|
|
|
|
channel_id=channel,
|
|
|
|
|
thread_id=thread_ts,
|
|
|
|
|
reply_to_id=None,
|
2026-03-01 14:34:24 -07:00
|
|
|
message_type=message_type,
|
2026-02-13 19:06:28 -07:00
|
|
|
metadata={
|
|
|
|
|
"ts": ts,
|
|
|
|
|
"mentioned": True,
|
|
|
|
|
"team": event.get("team"),
|
2026-03-01 14:34:24 -07:00
|
|
|
"files": downloaded_files,
|
2026-02-13 19:06:28 -07:00
|
|
|
},
|
|
|
|
|
raw=event,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._dispatch_message(inbound_msg)
|
|
|
|
|
|
2026-03-01 14:34:24 -07:00
|
|
|
async def _download_slack_file(
|
|
|
|
|
self,
|
|
|
|
|
file_info: Dict[str, Any],
|
|
|
|
|
output_dir: str = "downloads/slack"
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Download a file from Slack using url_private_download.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_info: File object from Slack event (contains url_private_download, name, etc.)
|
|
|
|
|
output_dir: Directory to save files (default: "downloads/slack")
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with success, file_path, filename, mimetype, size, or error
|
|
|
|
|
"""
|
|
|
|
|
import aiohttp
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
url = file_info.get("url_private_download")
|
|
|
|
|
token = self.config.credentials["bot_token"]
|
|
|
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
async with session.get(url, headers=headers) as response:
|
|
|
|
|
if response.status == 403:
|
|
|
|
|
return {
|
|
|
|
|
"success": False,
|
|
|
|
|
"error": "Permission denied. Add 'files:read' scope to bot at api.slack.com → OAuth & Permissions → Bot Token Scopes"
|
|
|
|
|
}
|
|
|
|
|
elif response.status == 404:
|
|
|
|
|
return {"success": False, "error": "File not found or expired"}
|
|
|
|
|
elif response.status != 200:
|
|
|
|
|
return {"success": False, "error": f"HTTP {response.status}"}
|
|
|
|
|
|
|
|
|
|
content_type = response.headers.get("Content-Type", "")
|
|
|
|
|
file_data = await response.read()
|
|
|
|
|
|
|
|
|
|
# Detect HTML login page (auth failure)
|
|
|
|
|
if content_type.startswith("text/html") or file_data.startswith(b"<!DOCTYPE") or file_data.startswith(b"<html"):
|
|
|
|
|
print(f"[Slack] Auth failure: Got HTML instead of file (likely missing 'files:read' scope)")
|
|
|
|
|
return {
|
|
|
|
|
"success": False,
|
|
|
|
|
"error": "Authentication failed. Bot needs 'files:read' scope. Add it at api.slack.com → OAuth & Permissions → Scopes → Add files:read → Reinstall to Workspace"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Save to disk
|
|
|
|
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
safe_name = Path(file_info["name"]).name # Prevent path traversal
|
|
|
|
|
file_path = Path(output_dir) / safe_name
|
|
|
|
|
|
|
|
|
|
# Handle duplicates with timestamp
|
|
|
|
|
if file_path.exists():
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
stem, suffix = safe_name.rsplit(".", 1) if "." in safe_name else (safe_name, "")
|
|
|
|
|
safe_name = f"{stem}_{timestamp}.{suffix}" if suffix else f"{stem}_{timestamp}"
|
|
|
|
|
file_path = Path(output_dir) / safe_name
|
|
|
|
|
|
|
|
|
|
file_path.write_bytes(file_data)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"file_path": str(file_path.absolute()),
|
|
|
|
|
"filename": safe_name,
|
|
|
|
|
"mimetype": file_info.get("mimetype", ""),
|
|
|
|
|
"size": len(file_data)
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
2026-02-13 19:06:28 -07:00
|
|
|
async def send_message(
|
|
|
|
|
self, message: OutboundMessage
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Send a message to Slack."""
|
|
|
|
|
if not self.app:
|
|
|
|
|
return {"success": False, "error": "Adapter not started"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
chunks = self.chunk_text(message.text)
|
|
|
|
|
results: List[Dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
|
|
|
thread_ts = (
|
|
|
|
|
message.thread_id
|
|
|
|
|
if i == 0
|
|
|
|
|
else results[0].get("ts")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await self.app.client.chat_postMessage(
|
|
|
|
|
channel=message.channel_id,
|
|
|
|
|
text=chunk,
|
|
|
|
|
thread_ts=thread_ts,
|
|
|
|
|
mrkdwn=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
results.append({
|
|
|
|
|
"ts": result["ts"],
|
|
|
|
|
"channel": result["channel"],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message_id": results[0]["ts"],
|
|
|
|
|
"chunks_sent": len(chunks),
|
|
|
|
|
"results": results,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except SlackApiError as e:
|
|
|
|
|
error_msg = e.response["error"]
|
|
|
|
|
print(f"[Slack] Error sending message: {error_msg}")
|
|
|
|
|
return {"success": False, "error": error_msg}
|
|
|
|
|
|
|
|
|
|
async def send_reaction(
|
|
|
|
|
self, channel_id: str, message_id: str, emoji: str
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Add a reaction to a message."""
|
|
|
|
|
if not self.app:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
await self.app.client.reactions_add(
|
|
|
|
|
channel=channel_id,
|
|
|
|
|
timestamp=message_id,
|
|
|
|
|
name=emoji.strip(":"),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
except SlackApiError as e:
|
|
|
|
|
print(
|
|
|
|
|
f"[Slack] Error adding reaction: {e.response['error']}"
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def send_typing_indicator(self, channel_id: str) -> None:
|
|
|
|
|
"""Slack doesn't have a typing indicator API."""
|
|
|
|
|
|
|
|
|
|
async def health_check(self) -> Dict[str, Any]:
|
|
|
|
|
"""Perform health check."""
|
|
|
|
|
base_health = await super().health_check()
|
|
|
|
|
|
|
|
|
|
if not self.app:
|
|
|
|
|
return {**base_health, "details": "App not initialized"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = await self.app.client.auth_test()
|
|
|
|
|
return {
|
|
|
|
|
**base_health,
|
|
|
|
|
"bot_id": response.get("bot_id"),
|
|
|
|
|
"team": response.get("team"),
|
|
|
|
|
"user": response.get("user"),
|
|
|
|
|
"connected": True,
|
|
|
|
|
}
|
|
|
|
|
except SlackApiError as e:
|
|
|
|
|
return {
|
|
|
|
|
**base_health,
|
|
|
|
|
"healthy": False,
|
|
|
|
|
"error": str(e.response.get("error")),
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 14:34:24 -07:00
|
|
|
async def send_file(
|
|
|
|
|
self,
|
|
|
|
|
channel_id: str,
|
|
|
|
|
file_path: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
thread_id: Optional[str] = None
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Upload a file to Slack channel."""
|
|
|
|
|
if not self.app:
|
|
|
|
|
return {"success": False, "error": "Adapter not started"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
path = Path(file_path)
|
|
|
|
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return {"success": False, "error": f"File not found: {file_path}"}
|
|
|
|
|
|
|
|
|
|
result = await self.app.client.files_upload_v2(
|
|
|
|
|
channel=channel_id,
|
|
|
|
|
file=str(path.absolute()),
|
|
|
|
|
title=path.name,
|
|
|
|
|
initial_comment=caption or "",
|
|
|
|
|
thread_ts=thread_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message_id": result["file"]["id"],
|
|
|
|
|
"file_path": file_path,
|
|
|
|
|
}
|
|
|
|
|
except SlackApiError as e:
|
|
|
|
|
error_msg = e.response["error"]
|
|
|
|
|
print(f"[Slack] Error uploading file: {error_msg}")
|
|
|
|
|
return {"success": False, "error": error_msg}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[Slack] Unexpected error uploading file: {e}")
|
|
|
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
2026-02-13 19:06:28 -07:00
|
|
|
async def _get_username(self, user_id: str) -> str:
|
2026-02-13 23:38:44 -07:00
|
|
|
"""Get username from user ID, with caching to avoid excessive API calls.
|
|
|
|
|
|
|
|
|
|
Sanitizes the returned username to contain only alphanumeric,
|
|
|
|
|
hyphens, and underscores (matching memory_system validation rules).
|
|
|
|
|
"""
|
|
|
|
|
# Check cache first
|
|
|
|
|
if user_id in self._username_cache:
|
|
|
|
|
return self._username_cache[user_id]
|
|
|
|
|
|
2026-02-13 19:06:28 -07:00
|
|
|
if not self.app:
|
|
|
|
|
return user_id
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = await self.app.client.users_info(user=user_id)
|
|
|
|
|
user = result["user"]
|
|
|
|
|
profile = user.get("profile", {})
|
2026-02-13 23:38:44 -07:00
|
|
|
raw_username = (
|
2026-02-13 19:06:28 -07:00
|
|
|
profile.get("display_name")
|
|
|
|
|
or profile.get("real_name")
|
|
|
|
|
or user.get("name")
|
|
|
|
|
or user_id
|
|
|
|
|
)
|
2026-02-13 23:38:44 -07:00
|
|
|
# Sanitize: replace spaces/special chars with underscores
|
|
|
|
|
sanitized = "".join(
|
|
|
|
|
c if c.isalnum() or c in "-_" else "_" for c in raw_username
|
|
|
|
|
)
|
|
|
|
|
self._username_cache[user_id] = sanitized
|
|
|
|
|
return sanitized
|
2026-02-13 19:06:28 -07:00
|
|
|
except SlackApiError:
|
2026-02-13 23:38:44 -07:00
|
|
|
self._username_cache[user_id] = user_id
|
2026-02-13 19:06:28 -07:00
|
|
|
return user_id
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _strip_mention(text: str) -> str:
|
|
|
|
|
"""Remove bot mention from text (e.g., '<@U12345> hello' -> 'hello')."""
|
|
|
|
|
return re.sub(r"<@[A-Z0-9]+>", "", text).strip()
|