Coverage for agentos/channels/adapters/slack.py: 0%
83 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 21:26 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 21:26 +0800
1"""
2Slack Channel Adapter — Slack Events API (Bolt for Python).
4OAuth 2.0 flow → Bolt App → Socket Mode / HTTP Webhook → ChannelMessage.
5"""
7from __future__ import annotations
9import hashlib
10import hmac
11import json
12import time
14from agentos.channels.base import BaseChannelAdapter, ChannelConfig, ReplyResult
15from agentos.channels.message import ChannelMessage, ChannelType, MessageType
18class SlackAdapter(BaseChannelAdapter):
19 """Slack Events API adapter.
21 Config fields:
22 bot_token: Slack Bot User OAuth Token (xoxb-...)
23 signing_secret: Slack Signing Secret for request verification
24 app_token: Socket Mode app-level token (xapp-...) — optional
25 socket_mode: bool — use Socket Mode instead of HTTP webhooks
26 """
28 CHANNEL_TYPE = ChannelType.SLACK
30 def __init__(self, config: ChannelConfig):
31 super().__init__(config)
32 self._bot_token = config.extra.get("bot_token", "")
33 self._signing_secret = config.extra.get("signing_secret", "")
34 self._app_token = config.extra.get("app_token", "")
35 self._socket_mode = config.extra.get("socket_mode", False)
37 # ── Webhook verification ──
39 def verify_signature(self, body: bytes, headers: dict) -> bool:
40 """Verify Slack request signature (HMAC-SHA256)."""
41 timestamp = headers.get("x-slack-request-timestamp", "")
42 slack_sig = headers.get("x-slack-signature", "")
44 if abs(time.time() - int(timestamp)) > 300:
45 return False
47 sig_basestring = f"v0:{timestamp}:{body.decode()}"
48 computed = (
49 "v0="
50 + hmac.new(
51 self._signing_secret.encode(),
52 sig_basestring.encode(),
53 hashlib.sha256,
54 ).hexdigest()
55 )
57 return hmac.compare_digest(computed, slack_sig)
59 # ── Message parsing ──
61 async def parse_incoming(self, payload: dict) -> ChannelMessage | None:
62 """Parse a Slack event payload into ChannelMessage."""
63 event_type = payload.get("type", "")
65 # URL verification challenge
66 if event_type == "url_verification":
67 return ChannelMessage(
68 channel_type=ChannelType.SLACK,
69 channel_id=self.config.channel_id,
70 user_id="system",
71 content=payload.get("challenge", ""),
72 message_type=MessageType.SYSTEM,
73 raw=payload,
74 reply_token=payload.get("challenge"),
75 )
77 # Event callback
78 if event_type == "event_callback":
79 event = payload.get("event", {})
80 return self._parse_event(event)
82 return None
84 def _parse_event(self, event: dict) -> ChannelMessage | None:
85 """Parse a Slack event (message, app_mention, etc.)."""
86 event_type = event.get("type", "")
87 user = event.get("user", "")
88 channel = event.get("channel", "")
89 text = event.get("text", "")
90 ts = event.get("ts", "")
92 # Strip bot mention prefix
93 if event_type == "app_mention" and text:
94 text = self._strip_mention(text)
96 if not text:
97 return None
99 msg_type = MessageType.TEXT
100 return ChannelMessage(
101 channel_type=ChannelType.SLACK,
102 channel_id=channel,
103 user_id=user,
104 content=text,
105 message_type=msg_type,
106 raw=event,
107 reply_token=ts,
108 )
110 def _strip_mention(self, text: str) -> str:
111 """Remove <@BOT_ID> prefix from message text."""
112 import re
114 return re.sub(r"^<@U[A-Z0-9]+>\s*", "", text).strip()
116 # ── Reply ──
118 async def reply(self, channel_id: str, content: str, **kwargs) -> ReplyResult:
119 """Send a message to Slack channel via chat.postMessage."""
120 url = "https://slack.com/api/chat.postMessage"
121 headers = {
122 "Authorization": f"Bearer {self._bot_token}",
123 "Content-Type": "application/json",
124 }
125 body = {
126 "channel": channel_id,
127 "text": content,
128 }
130 thread_ts = kwargs.get("thread_ts") or kwargs.get("reply_token")
131 if thread_ts:
132 body["thread_ts"] = thread_ts
134 try:
135 import aiohttp
137 async with aiohttp.ClientSession() as session:
138 async with session.post(url, headers=headers, json=body) as resp:
139 data = await resp.json()
140 if data.get("ok"):
141 return ReplyResult(success=True, message_id=data.get("ts", ""))
142 return ReplyResult(success=False, error=data.get("error", "unknown"))
143 except ImportError:
144 import urllib.request
146 req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
147 with urllib.request.urlopen(req) as resp:
148 data = json.loads(resp.read())
149 return ReplyResult(success=data.get("ok", False), message_id=data.get("ts", ""))
151 async def reply_blocks(self, channel_id: str, blocks: list[dict], **kwargs) -> ReplyResult:
152 """Send Slack Block Kit message."""
153 url = "https://slack.com/api/chat.postMessage"
154 headers = {
155 "Authorization": f"Bearer {self._bot_token}",
156 "Content-Type": "application/json",
157 }
158 body = {"channel": channel_id, "blocks": blocks}
160 try:
161 import aiohttp
163 async with aiohttp.ClientSession() as session:
164 async with session.post(url, headers=headers, json=body) as resp:
165 data = await resp.json()
166 return ReplyResult(success=data.get("ok", False), message_id=data.get("ts", ""))
167 except ImportError:
168 import urllib.request
170 req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
171 with urllib.request.urlopen(req) as resp:
172 data = json.loads(resp.read())
173 return ReplyResult(success=data.get("ok", False), message_id=data.get("ts", ""))