ForcePilot/backend/package/yuxi/channel/extensions/slack/gateway.py
Kris bfc7755137 feat(channel): 添加 Slack 渠道扩展
新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- monitor: 渠道状态监控
- status: 会话状态管理
- actions: 交互动作处理
- interactive: 交互式消息
- commands: 斜杠指令
- threading: 线程管理
- mentions: @提及
- constants: 常量定义
- types: 类型定义
2026-05-21 11:43:23 +08:00

910 lines
34 KiB
Python

import asyncio
import hashlib
import hmac
import logging
import re
from yuxi.channel.extensions.slack.types import SlackMode, SlackBotIdentity, SlackInboundEvent
from yuxi.channel.extensions.slack.constants import SLACK_APP_TOKEN_PATTERN
from yuxi.channel.extensions.slack.errors import SlackError, SlackErrorCode, SlackAuthError
from yuxi.channel.extensions.slack.config import SlackConfigAdapter
from yuxi.channel.context import ChannelContext
logger = logging.getLogger(__name__)
class SlackGateway:
delivery_mode = "direct"
def __init__(self):
self._accounts: dict[str, dict] = {}
async def start(self, ctx: ChannelContext) -> object:
adapter = SlackConfigAdapter()
config = getattr(ctx, "raw_config", None) or {}
account = await adapter.resolve_account(ctx.account_id, config)
if not account.get("bot_token"):
raise SlackError(SlackErrorCode.CONFIG_ERROR, "bot_token is required")
mode = account.get("mode", SlackMode.SOCKET)
if mode == SlackMode.SOCKET:
if not account.get("app_token"):
raise SlackError(SlackErrorCode.CONFIG_ERROR, "app_token is required for socket mode")
elif mode == SlackMode.HTTP:
if not account.get("signing_secret"):
raise SlackError(SlackErrorCode.CONFIG_ERROR, "signing_secret is required for HTTP mode")
self._accounts[ctx.account_id] = account
bot_identity = await self._auth_test(account)
ctx._slack_bot_identity = bot_identity
if mode == SlackMode.SOCKET:
runtime = await self._start_socket_mode(ctx, account)
else:
runtime = await self._start_http_mode(ctx, account)
return {
"account": account,
"identity": bot_identity,
"mode": mode,
"runtime": runtime,
}
async def stop(self, ctx: ChannelContext) -> None:
account = self._accounts.pop(ctx.account_id, None)
if account is None:
return
mode = account.get("mode", SlackMode.SOCKET)
if mode == SlackMode.SOCKET:
handler = getattr(ctx, "_slack_handler", None)
if handler is not None:
try:
await handler.disconnect_async()
except Exception:
logger.warning("Error disconnecting socket mode handler", exc_info=True)
try:
await handler.close_async()
except Exception:
logger.warning("Error closing socket mode handler", exc_info=True)
else:
app = getattr(ctx, "_slack_app", None)
if app is not None:
logger.info("Slack HTTP mode app reference cleared for account %s", ctx.account_id)
for attr in ("_slack_handler", "_slack_app", "_slack_bot_identity"):
try:
delattr(ctx, attr)
except AttributeError:
pass
logger.info("Slack gateway stopped for account %s", ctx.account_id)
def _register_event_handlers(self, app, ctx: ChannelContext) -> None:
@app.event("message")
async def on_message(body: dict, say):
event = body.get("event", {})
subtype = event.get("subtype", "")
if subtype == "message_changed":
await self._on_message_changed(body, say, ctx)
elif subtype == "message_deleted":
await self._on_message_deleted(body, say, ctx)
else:
await self._on_message(body, say, ctx)
@app.event("app_mention")
async def on_app_mention(body: dict, say):
await self._on_app_mention(body, say, ctx)
@app.event("reaction_added")
async def on_reaction_added(body: dict, say):
await self._on_reaction_added(body, say, ctx)
@app.event("reaction_removed")
async def on_reaction_removed(body: dict, say):
await self._on_reaction_removed(body, say, ctx)
@app.event("pin_added")
async def on_pin_added(body: dict, say):
await self._on_pin_event("pin_added", body, say, ctx)
@app.event("pin_removed")
async def on_pin_removed(body: dict, say):
await self._on_pin_event("pin_removed", body, say, ctx)
@app.event("member_joined_channel")
async def on_member_joined(body: dict, say):
await self._on_member_event("member_joined_channel", body, say, ctx)
@app.event("member_left_channel")
async def on_member_left(body: dict, say):
await self._on_member_event("member_left_channel", body, say, ctx)
@app.event("channel_created")
async def on_channel_created(body: dict, say):
await self._on_channel_lifecycle("channel_created", body, say, ctx)
@app.event("channel_archive")
async def on_channel_archive(body: dict, say):
await self._on_channel_lifecycle("channel_archive", body, say, ctx)
@app.event("channel_unarchive")
async def on_channel_unarchive(body: dict, say):
await self._on_channel_lifecycle("channel_unarchive", body, say, ctx)
@app.event("channel_rename")
async def on_channel_rename(body: dict, say):
await self._on_channel_lifecycle("channel_rename", body, say, ctx)
@app.event("app_home_opened")
async def on_app_home_opened(body: dict, say):
await self._on_app_home_opened(body, say, ctx)
@app.event("app_uninstalled")
async def on_app_uninstalled(body: dict, say):
await self._on_app_uninstalled(body, say, ctx)
@app.event("link_shared")
async def on_link_shared(body: dict, say):
await self._on_link_shared(body, say, ctx)
@app.event("file_created")
async def on_file_created(body: dict, say):
await self._on_file_event("file_created", body, say, ctx)
@app.event("file_deleted")
async def on_file_deleted(body: dict, say):
await self._on_file_event("file_deleted", body, say, ctx)
@app.event("file_shared")
async def on_file_shared(body: dict, say):
await self._on_file_event("file_shared", body, say, ctx)
@app.event("user_change")
async def on_user_change(body: dict, say):
await self._on_user_change(body, say, ctx)
@app.event("team_join")
async def on_team_join(body: dict, say):
await self._on_team_member_event("team_join", body, say, ctx)
@app.event("dnd_updated")
async def on_dnd_updated(body: dict, say):
await self._on_dnd_updated(body, say, ctx)
@app.event("message")
async def on_message_replied(body: dict, say):
event = body.get("event", {})
if event.get("subtype") == "message_replied":
await self._on_message_replied(body, say, ctx)
@app.action(re.compile(".*"))
async def on_block_action(ack, body: dict, say):
await ack()
await self._on_block_action(body, say, ctx)
@app.shortcut(re.compile(".*"))
async def on_shortcut(ack, body: dict, say):
await ack()
await self._on_shortcut(body, say, ctx)
@app.command("/help")
async def on_help_command(ack, body: dict, say):
await ack()
await self._on_slash_command("help", body, say, ctx)
@app.command("/reset")
async def on_reset_command(ack, body: dict, say):
await ack()
await self._on_slash_command("reset", body, say, ctx)
@app.view(re.compile(".*"))
async def on_view_submission(ack, body: dict, say):
await ack()
await self._on_view_submission(body, say, ctx)
@app.options(re.compile(".*"))
async def on_external_select_options(ack, body: dict):
await ack()
await self._on_external_select_options(body, ctx)
async def _on_view_submission(self, body: dict, say, ctx: ChannelContext) -> None:
view = body.get("view", {})
callback_id = view.get("callback_id", "")
state = view.get("state", {}).get("values", {})
user_id = body.get("user", {}).get("id", "")
logger.info("Slack view_submission: callback_id=%s user=%s", callback_id, user_id)
inbound = SlackInboundEvent(
event_type="view_submission",
event_ts=str(asyncio.get_event_loop().time()),
channel=user_id,
user=user_id,
text=callback_id,
ts=view.get("id", ""),
raw={"body": body, "state": state},
team_id=body.get("team", {}).get("id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack view_submission event, user=%s", user_id)
async def _on_external_select_options(self, body: dict, ctx: ChannelContext) -> None:
action_id = body.get("action_id", "")
query = body.get("value", "")
user_id = body.get("user", {}).get("id", "")
logger.info("Slack external_select_options: action_id=%s query=%s user=%s", action_id, query, user_id)
async def _start_socket_mode(self, ctx: ChannelContext, account: dict) -> dict:
try:
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import SocketModeHandler
except ImportError as e:
raise SlackError(
SlackErrorCode.CONFIG_ERROR,
"slack_bolt package is required for socket mode. Install with: pip install slack-bolt",
) from e
bot_token = account["bot_token"]
app_token = account["app_token"]
self._validate_app_token(app_token)
app = AsyncApp(token=bot_token)
self._register_event_handlers(app, ctx)
handler = SocketModeHandler(app, app_token=app_token)
await handler.connect_async()
auth_info = await app.client.auth_test()
token_api_app_id = auth_info.get("app_id", "")
app_token_match = re.match(r"^xapp-\d+-([A-Za-z0-9]+)-", app_token)
app_token_app_id = app_token_match.group(1) if app_token_match else ""
if token_api_app_id and app_token_app_id and token_api_app_id != app_token_app_id:
logger.warning(
"api_app_id mismatch: bot_token app_id=%s, app_token app_id=%s",
token_api_app_id,
app_token_app_id,
)
ctx._slack_handler = handler
ctx._slack_app = app
while not ctx.cancel_event.is_set():
await asyncio.sleep(1)
return {"handler": handler, "app": app}
async def _start_http_mode(self, ctx: ChannelContext, account: dict) -> dict:
try:
from slack_bolt.async_app import AsyncApp
except ImportError as e:
raise SlackError(
SlackErrorCode.CONFIG_ERROR,
"slack_bolt package is required for HTTP mode. Install with: pip install slack-bolt",
) from e
bot_token = account["bot_token"]
signing_secret = account["signing_secret"]
app = AsyncApp(token=bot_token, signing_secret=signing_secret)
self._register_event_handlers(app, ctx)
ctx._slack_app = app
ctx._slack_signing_secret = signing_secret
logger.info("Slack HTTP mode registered for account %s", ctx.account_id)
return {
"app": app,
"route_registered": True,
"events_path": "/api/channel/slack/events",
}
async def process_event(self, ctx: ChannelContext, body: dict, headers: dict) -> dict:
signing_secret = getattr(ctx, "_slack_signing_secret", "")
if signing_secret:
raw_body = headers.get("_raw_body", "")
signature = headers.get("X-Slack-Signature", "")
timestamp = headers.get("X-Slack-Request-Timestamp", "")
if not self._verify_slack_signature(signing_secret, timestamp, raw_body, signature):
return {"status": 403, "body": "Invalid signature"}
event_type = body.get("type", "")
if event_type == "url_verification":
return {"status": 200, "body": body.get("challenge", "")}
app = getattr(ctx, "_slack_app", None)
if app is not None:
asyncio.create_task(app.process_event(body))
else:
logger.warning("No Slack app available for event processing")
return {"status": 200, "body": "ok"}
@staticmethod
def _verify_slack_signature(
signing_secret: str,
timestamp: str,
raw_body: str,
signature: str,
) -> bool:
if not signing_secret or not timestamp or not raw_body or not signature:
return False
version = "v0"
base = f"{version}:{timestamp}:{raw_body}"
computed = f"{version}={
hmac.new(
signing_secret.encode('utf-8'),
base.encode('utf-8'),
hashlib.sha256,
).hexdigest()
}"
return hmac.compare_digest(computed, signature)
async def _on_message(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
channel_type = event.get("channel_type", "")
subtype = event.get("subtype", "")
if subtype == "message_changed":
return
if subtype and subtype != "bot_message":
return
bot_id = event.get("bot_id", "")
if bot_id:
return
inbound = SlackInboundEvent(
event_type="message",
event_ts=event.get("event_ts", ""),
channel=event.get("channel", ""),
channel_type=channel_type,
user=event.get("user", ""),
text=event.get("text", ""),
ts=event.get("ts", ""),
thread_ts=event.get("thread_ts", ""),
bot_id=bot_id,
subtype=subtype,
files=event.get("files", []),
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack message event, channel=%s", inbound.channel)
async def _on_app_mention(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
channel_type = event.get("channel_type", "")
if channel_type == "im":
return
inbound = SlackInboundEvent(
event_type="app_mention",
event_ts=event.get("event_ts", ""),
channel=event.get("channel", ""),
channel_type=channel_type,
user=event.get("user", ""),
text=event.get("text", ""),
ts=event.get("ts", ""),
thread_ts=event.get("thread_ts", ""),
bot_id=event.get("bot_id", ""),
subtype=event.get("subtype", ""),
files=event.get("files", []),
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack app_mention event, channel=%s", inbound.channel)
async def _on_message_changed(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
if event.get("subtype") != "message_changed":
return
message = event.get("message", {})
channel_type = event.get("channel_type", "")
if channel_type != "im":
return
bot_id = message.get("bot_id", "")
bot_identity: SlackBotIdentity = getattr(ctx, "_slack_bot_identity", None)
if not bot_identity or not bot_id or bot_id != bot_identity.bot_id:
return
previous_message = event.get("previous_message", {})
edited_by = message.get("edited", {}).get("user", "")
text = message.get("text", "")
user = message.get("user", "")
if not text and previous_message:
text = previous_message.get("text", "")
user = edited_by or user
if not text or not user:
return
inbound = SlackInboundEvent(
event_type="message_changed",
event_ts=event.get("event_ts", ""),
channel=event.get("channel", ""),
channel_type=channel_type,
user=user,
text=text,
ts=message.get("ts", ""),
thread_ts=message.get("thread_ts", ""),
bot_id="",
subtype="message_changed",
files=[],
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack message_changed event, channel=%s", inbound.channel)
async def _on_reaction_added(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
reaction = event.get("reaction", "")
item = event.get("item", {})
logger.info(
"Slack reaction_added: reaction=%s channel=%s ts=%s user=%s",
reaction,
item.get("channel", ""),
item.get("ts", ""),
event.get("user", ""),
)
inbound = SlackInboundEvent(
event_type="reaction_added",
event_ts=event.get("event_ts", ""),
channel=item.get("channel", ""),
channel_type=item.get("type", ""),
user=event.get("user", ""),
text=reaction,
ts=item.get("ts", ""),
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack reaction_added event, channel=%s", inbound.channel)
async def _on_message_deleted(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
deleted_ts = event.get("deleted_ts", "")
channel = event.get("channel", "")
logger.info(
"Slack message_deleted: channel=%s deleted_ts=%s user=%s",
channel,
deleted_ts,
event.get("user", ""),
)
inbound = SlackInboundEvent(
event_type="message_deleted",
event_ts=event.get("event_ts", ""),
channel=channel,
user=event.get("user", ""),
ts=deleted_ts,
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack message_deleted event, channel=%s", inbound.channel)
async def _on_block_action(self, body: dict, say, ctx: ChannelContext) -> None:
action = body.get("actions", [{}])[0]
action_id = action.get("action_id", "")
value = action.get("value", "")
user_id = body.get("user", {}).get("id", "")
channel_id = body.get("channel", {}).get("id", "")
logger.info(
"Slack block_action: action_id=%s value=%s user=%s channel=%s",
action_id,
value,
user_id,
channel_id,
)
inbound = SlackInboundEvent(
event_type="block_action",
event_ts=str(asyncio.get_event_loop().time()),
channel=channel_id,
channel_type=body.get("channel", {}).get("type", ""),
user=user_id,
text=value,
ts=body.get("message", {}).get("ts", ""),
raw=body,
team_id=body.get("team", {}).get("id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack block_action event, channel=%s", inbound.channel)
async def _on_slash_command(self, command: str, body: dict, say, ctx: ChannelContext) -> None:
user_id = body.get("user_id", "")
channel_id = body.get("channel_id", "")
text = body.get("text", "")
logger.info(
"Slack slash_command: command=%s user=%s channel=%s",
command,
user_id,
channel_id,
)
inbound = SlackInboundEvent(
event_type="slash_command",
event_ts=str(body.get("ts", asyncio.get_event_loop().time())),
channel=channel_id,
channel_type=body.get("channel_name", ""),
user=user_id,
text=f"/{command} {text}".strip(),
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack slash_command event, channel=%s", inbound.channel)
async def _on_reaction_removed(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
reaction = event.get("reaction", "")
item = event.get("item", {})
logger.info("Slack reaction_removed: reaction=%s channel=%s", reaction, item.get("channel", ""))
inbound = SlackInboundEvent(
event_type="reaction_removed",
event_ts=event.get("event_ts", ""),
channel=item.get("channel", ""),
channel_type=item.get("type", ""),
user=event.get("user", ""),
text=reaction,
ts=item.get("ts", ""),
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack reaction_removed event, channel=%s", inbound.channel)
async def _on_pin_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
item = event.get("item", {})
channel = item.get("channel", "")
logger.info("Slack %s: channel=%s", event_type, channel)
inbound = SlackInboundEvent(
event_type=event_type,
event_ts=event.get("event_ts", ""),
channel=channel,
user=event.get("user", ""),
text="",
ts=item.get("message", {}).get("ts", "") if item else "",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack %s event, channel=%s", event_type, inbound.channel)
async def _on_member_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
channel = event.get("channel", "")
user = event.get("user", "")
logger.info("Slack %s: user=%s channel=%s", event_type, user, channel)
inbound = SlackInboundEvent(
event_type=event_type,
event_ts=event.get("event_ts", ""),
channel=channel,
user=user,
text="",
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack %s event, channel=%s", event_type, inbound.channel)
async def _on_channel_lifecycle(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
channel = event.get("channel", {})
channel_id = channel.get("id", "") if isinstance(channel, dict) else event.get("channel", "")
logger.info("Slack %s: channel=%s", event_type, channel_id)
inbound = SlackInboundEvent(
event_type=event_type,
event_ts=event.get("event_ts", ""),
channel=channel_id,
user=event.get("user", ""),
text=channel.get("name", "") if isinstance(channel, dict) else "",
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack %s event, channel=%s", event_type, inbound.channel)
async def _on_app_home_opened(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
user = event.get("user", "")
logger.info("Slack app_home_opened: user=%s", user)
inbound = SlackInboundEvent(
event_type="app_home_opened",
event_ts=event.get("event_ts", ""),
channel=user,
user=user,
text="",
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack app_home_opened event, user=%s", user)
async def _on_app_uninstalled(self, body: dict, say, ctx: ChannelContext) -> None:
team_id = body.get("team_id", "")
logger.info("Slack app_uninstalled: team=%s", team_id)
inbound = SlackInboundEvent(
event_type="app_uninstalled",
event_ts=str(asyncio.get_event_loop().time()),
channel=team_id,
user="",
text="",
ts="",
raw=body,
team_id=team_id,
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack app_uninstalled event, team=%s", team_id)
async def _on_link_shared(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
channel = event.get("channel", "")
links = event.get("links", [])
logger.info("Slack link_shared: channel=%s links=%d", channel, len(links))
inbound = SlackInboundEvent(
event_type="link_shared",
event_ts=event.get("event_ts", ""),
channel=channel,
user=event.get("user", ""),
text=",".join([link.get("url", "") for link in links]),
ts=event.get("message_ts", ""),
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack link_shared event, channel=%s", channel)
async def _on_file_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
file_id = event.get("file_id", "")
logger.info("Slack %s: file_id=%s", event_type, file_id)
inbound = SlackInboundEvent(
event_type=event_type,
event_ts=event.get("event_ts", ""),
channel="",
user=event.get("user_id", ""),
text=file_id,
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack %s event, file_id=%s", event_type, file_id)
async def _on_user_change(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
user = event.get("user", {})
user_id = user.get("id", "") if isinstance(user, dict) else event.get("user", "")
logger.info("Slack user_change: user=%s", user_id)
inbound = SlackInboundEvent(
event_type="user_change",
event_ts=event.get("event_ts", ""),
channel="",
user=user_id,
text="",
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack user_change event, user=%s", user_id)
async def _on_team_member_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
user = event.get("user", {})
user_id = user.get("id", "") if isinstance(user, dict) else ""
logger.info("Slack %s: user=%s", event_type, user_id)
inbound = SlackInboundEvent(
event_type=event_type,
event_ts=event.get("event_ts", ""),
channel="",
user=user_id,
text="",
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack %s event, user=%s", event_type, user_id)
async def _on_dnd_updated(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
user = event.get("user", "")
dnd_status = event.get("dnd_status", {})
logger.info("Slack dnd_updated: user=%s snooze=%s", user, dnd_status.get("snooze_enabled", False))
inbound = SlackInboundEvent(
event_type="dnd_updated",
event_ts=event.get("event_ts", ""),
channel="",
user=user,
text=str(dnd_status.get("snooze_enabled", False)),
ts="",
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack dnd_updated event, user=%s", user)
async def _on_message_replied(self, body: dict, say, ctx: ChannelContext) -> None:
event = body.get("event", {})
message = event.get("message", {})
channel = event.get("channel", "")
thread_ts = message.get("thread_ts", "")
logger.info("Slack message_replied: channel=%s thread=%s", channel, thread_ts)
inbound = SlackInboundEvent(
event_type="message_replied",
event_ts=event.get("event_ts", ""),
channel=channel,
user=message.get("user", ""),
text=message.get("text", ""),
ts=thread_ts,
thread_ts=thread_ts,
raw=body,
team_id=body.get("team_id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack message_replied event, channel=%s", channel)
async def _on_shortcut(self, body: dict, say, ctx: ChannelContext) -> None:
callback_id = body.get("callback_id", "")
trigger_id = body.get("trigger_id", "")
user_id = body.get("user", {}).get("id", "")
logger.info("Slack shortcut: callback_id=%s user=%s", callback_id, user_id)
inbound = SlackInboundEvent(
event_type="shortcut",
event_ts=str(asyncio.get_event_loop().time()),
channel=body.get("channel", {}).get("id", ""),
user=user_id,
text=callback_id,
ts=trigger_id,
raw=body,
team_id=body.get("team", {}).get("id", ""),
api_app_id=body.get("api_app_id", ""),
)
if ctx.queue is not None:
await ctx.queue.put(inbound)
else:
logger.warning("No queue available for Slack shortcut event, user=%s", user_id)
async def _auth_test(self, account: dict) -> SlackBotIdentity:
bot_token = account["bot_token"]
try:
from slack_sdk.web.async_client import AsyncWebClient
except ImportError as e:
raise SlackError(
SlackErrorCode.CONFIG_ERROR,
"slack_sdk package is required. Install with: pip install slack-sdk",
) from e
client = AsyncWebClient(token=bot_token)
try:
auth_info = await client.auth_test()
except Exception as e:
raise SlackAuthError(f"auth.test failed: {e}") from e
if not auth_info.get("ok"):
error_msg = auth_info.get("error", "unknown")
raise SlackAuthError(f"auth.test returned error: {error_msg}")
return SlackBotIdentity(
bot_user_id=auth_info.get("user_id", ""),
bot_id=auth_info.get("bot_id", ""),
team_id=auth_info.get("team_id", ""),
team_name=auth_info.get("team", ""),
api_app_id=auth_info.get("app_id", ""),
workspace_url=auth_info.get("url", ""),
user_id=auth_info.get("user_id", ""),
)
@staticmethod
def _validate_app_token(app_token: str) -> bool:
if not app_token:
raise SlackError(SlackErrorCode.CONFIG_ERROR, "app_token is empty")
if not re.match(SLACK_APP_TOKEN_PATTERN, app_token):
raise SlackError(
SlackErrorCode.CONFIG_ERROR,
f"app_token does not match expected pattern: {SLACK_APP_TOKEN_PATTERN}",
)
return True
def resolve_gateway_auth_bypass_paths(self, config: dict) -> list[str]:
return ["/api/channel/slack/events"]