Use files_upload_v2 API to upload media attachments in Slack messages. This enables the message tool's media parameter to work correctly when sending images or other files through the Slack channel. Requires files:write OAuth scope.
253 lines
9.0 KiB
Python
253 lines
9.0 KiB
Python
"""Slack channel implementation using Socket Mode."""
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
from slack_sdk.socket_mode.websockets import SocketModeClient
|
|
from slack_sdk.socket_mode.request import SocketModeRequest
|
|
from slack_sdk.socket_mode.response import SocketModeResponse
|
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
|
|
from slackify_markdown import slackify_markdown
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.channels.base import BaseChannel
|
|
from nanobot.config.schema import SlackConfig
|
|
|
|
|
|
class SlackChannel(BaseChannel):
|
|
"""Slack channel using Socket Mode."""
|
|
|
|
name = "slack"
|
|
|
|
def __init__(self, config: SlackConfig, bus: MessageBus):
|
|
super().__init__(config, bus)
|
|
self.config: SlackConfig = config
|
|
self._web_client: AsyncWebClient | None = None
|
|
self._socket_client: SocketModeClient | None = None
|
|
self._bot_user_id: str | None = None
|
|
|
|
async def start(self) -> None:
|
|
"""Start the Slack Socket Mode client."""
|
|
if not self.config.bot_token or not self.config.app_token:
|
|
logger.error("Slack bot/app token not configured")
|
|
return
|
|
if self.config.mode != "socket":
|
|
logger.error(f"Unsupported Slack mode: {self.config.mode}")
|
|
return
|
|
|
|
self._running = True
|
|
|
|
self._web_client = AsyncWebClient(token=self.config.bot_token)
|
|
self._socket_client = SocketModeClient(
|
|
app_token=self.config.app_token,
|
|
web_client=self._web_client,
|
|
)
|
|
|
|
self._socket_client.socket_mode_request_listeners.append(self._on_socket_request)
|
|
|
|
# Resolve bot user ID for mention handling
|
|
try:
|
|
auth = await self._web_client.auth_test()
|
|
self._bot_user_id = auth.get("user_id")
|
|
logger.info(f"Slack bot connected as {self._bot_user_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Slack auth_test failed: {e}")
|
|
|
|
logger.info("Starting Slack Socket Mode client...")
|
|
await self._socket_client.connect()
|
|
|
|
while self._running:
|
|
await asyncio.sleep(1)
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the Slack client."""
|
|
self._running = False
|
|
if self._socket_client:
|
|
try:
|
|
await self._socket_client.close()
|
|
except Exception as e:
|
|
logger.warning(f"Slack socket close failed: {e}")
|
|
self._socket_client = None
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
|
"""Send a message through Slack."""
|
|
if not self._web_client:
|
|
logger.warning("Slack client not running")
|
|
return
|
|
try:
|
|
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
|
|
thread_ts = slack_meta.get("thread_ts")
|
|
channel_type = slack_meta.get("channel_type")
|
|
# Only reply in thread for channel/group messages; DMs don't use threads
|
|
use_thread = thread_ts and channel_type != "im"
|
|
thread_ts_param = thread_ts if use_thread else None
|
|
|
|
# Send text message if content is present
|
|
if msg.content:
|
|
await self._web_client.chat_postMessage(
|
|
channel=msg.chat_id,
|
|
text=self._to_mrkdwn(msg.content),
|
|
thread_ts=thread_ts_param,
|
|
)
|
|
|
|
# Upload media files if present
|
|
for media_path in msg.media or []:
|
|
try:
|
|
await self._web_client.files_upload_v2(
|
|
channel=msg.chat_id,
|
|
file=media_path,
|
|
thread_ts=thread_ts_param,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to upload file {media_path}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error sending Slack message: {e}")
|
|
|
|
async def _on_socket_request(
|
|
self,
|
|
client: SocketModeClient,
|
|
req: SocketModeRequest,
|
|
) -> None:
|
|
"""Handle incoming Socket Mode requests."""
|
|
if req.type != "events_api":
|
|
return
|
|
|
|
# Acknowledge right away
|
|
await client.send_socket_mode_response(
|
|
SocketModeResponse(envelope_id=req.envelope_id)
|
|
)
|
|
|
|
payload = req.payload or {}
|
|
event = payload.get("event") or {}
|
|
event_type = event.get("type")
|
|
|
|
# Handle app mentions or plain messages
|
|
if event_type not in ("message", "app_mention"):
|
|
return
|
|
|
|
sender_id = event.get("user")
|
|
chat_id = event.get("channel")
|
|
|
|
# Ignore bot/system messages (any subtype = not a normal user message)
|
|
if event.get("subtype"):
|
|
return
|
|
if self._bot_user_id and sender_id == self._bot_user_id:
|
|
return
|
|
|
|
# Avoid double-processing: Slack sends both `message` and `app_mention`
|
|
# for mentions in channels. Prefer `app_mention`.
|
|
text = event.get("text") or ""
|
|
if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text:
|
|
return
|
|
|
|
# Debug: log basic event shape
|
|
logger.debug(
|
|
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
|
event_type,
|
|
event.get("subtype"),
|
|
sender_id,
|
|
chat_id,
|
|
event.get("channel_type"),
|
|
text[:80],
|
|
)
|
|
if not sender_id or not chat_id:
|
|
return
|
|
|
|
channel_type = event.get("channel_type") or ""
|
|
|
|
if not self._is_allowed(sender_id, chat_id, channel_type):
|
|
return
|
|
|
|
if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id):
|
|
return
|
|
|
|
text = self._strip_bot_mention(text)
|
|
|
|
thread_ts = event.get("thread_ts")
|
|
if self.config.reply_in_thread and not thread_ts:
|
|
thread_ts = event.get("ts")
|
|
# Add :eyes: reaction to the triggering message (best-effort)
|
|
try:
|
|
if self._web_client and event.get("ts"):
|
|
await self._web_client.reactions_add(
|
|
channel=chat_id,
|
|
name=self.config.react_emoji,
|
|
timestamp=event.get("ts"),
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Slack reactions_add failed: {e}")
|
|
|
|
await self._handle_message(
|
|
sender_id=sender_id,
|
|
chat_id=chat_id,
|
|
content=text,
|
|
metadata={
|
|
"slack": {
|
|
"event": event,
|
|
"thread_ts": thread_ts,
|
|
"channel_type": channel_type,
|
|
}
|
|
},
|
|
)
|
|
|
|
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
|
|
if channel_type == "im":
|
|
if not self.config.dm.enabled:
|
|
return False
|
|
if self.config.dm.policy == "allowlist":
|
|
return sender_id in self.config.dm.allow_from
|
|
return True
|
|
|
|
# Group / channel messages
|
|
if self.config.group_policy == "allowlist":
|
|
return chat_id in self.config.group_allow_from
|
|
return True
|
|
|
|
def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool:
|
|
if self.config.group_policy == "open":
|
|
return True
|
|
if self.config.group_policy == "mention":
|
|
if event_type == "app_mention":
|
|
return True
|
|
return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text
|
|
if self.config.group_policy == "allowlist":
|
|
return chat_id in self.config.group_allow_from
|
|
return False
|
|
|
|
def _strip_bot_mention(self, text: str) -> str:
|
|
if not text or not self._bot_user_id:
|
|
return text
|
|
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
|
|
|
|
_TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*")
|
|
|
|
@classmethod
|
|
def _to_mrkdwn(cls, text: str) -> str:
|
|
"""Convert Markdown to Slack mrkdwn, including tables."""
|
|
if not text:
|
|
return ""
|
|
text = cls._TABLE_RE.sub(cls._convert_table, text)
|
|
return slackify_markdown(text)
|
|
|
|
@staticmethod
|
|
def _convert_table(match: re.Match) -> str:
|
|
"""Convert a Markdown table to a Slack-readable list."""
|
|
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
|
|
if len(lines) < 2:
|
|
return match.group(0)
|
|
headers = [h.strip() for h in lines[0].strip("|").split("|")]
|
|
start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1
|
|
rows: list[str] = []
|
|
for line in lines[start:]:
|
|
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
cells = (cells + [""] * len(headers))[: len(headers)]
|
|
parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]]
|
|
if parts:
|
|
rows.append(" · ".join(parts))
|
|
return "\n".join(rows)
|
|
|