新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - monitor: 渠道状态监控 - status: 会话状态管理 - actions: 交互动作处理 - interactive: 交互式消息 - commands: 斜杠指令 - threading: 线程管理 - mentions: @提及 - constants: 常量定义 - types: 类型定义
910 lines
34 KiB
Python
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"]
|