Coverage for src/lexigram/notification/backends/slack/slack_notifier.py: 94%

108 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 02:32 +0800

1"""Slack notification backend. 

2 

3Supports two delivery modes: 

4 

5- **Webhook** — posts a message to a Slack Incoming Webhook URL via 

6 ``httpx``. No dependency on the Slack SDK. 

7- **Bot API** — uses the ``slack-sdk`` ``AsyncWebClient`` to send rich 

8 messages, attach blocks, target specific channels, and reply in threads. 

9 Requires the ``lexigram-notification[slack]`` extra. 

10""" 

11 

12from __future__ import annotations 

13 

14from typing import Any 

15import uuid 

16 

17from lexigram.contracts.core import HealthCheckResult, HealthStatus 

18from lexigram.contracts.mailer import MessageDeliveryReceipt 

19from lexigram.contracts.notification.errors import NotificationError 

20from lexigram.logging import get_logger 

21from lexigram.result import Err, Ok, Result 

22 

23logger = get_logger(__name__) 

24 

25_DEFAULT_TIMEOUT = 30 

26 

27_MRKDWN_ESCAPES = str.maketrans({"<": "&lt;", ">": "&gt;", "&": "&amp;"}) 

28 

29 

30def _escape_mrkdwn(text: str) -> str: 

31 """Escape Slack mrkdwn-special characters in user-supplied text. 

32 

33 Slack interprets ``<url|label>`` link markup and ``&`` entities in the 

34 ``text`` field. Apply before sending untrusted content so it renders as 

35 literal text instead of a spoofable link. 

36 

37 Args: 

38 text: Message text. 

39 

40 Returns: 

41 Text with ``<``, ``>``, and ``&`` escaped. 

42 """ 

43 return text.translate(_MRKDWN_ESCAPES) 

44 

45 

46class SlackNotificationError(NotificationError): 

47 """Slack notification delivery failure.""" 

48 

49 _code = "LEX_ERR_NOTIF_014" 

50 

51 def __init__( 

52 self, 

53 message: str = "Slack delivery error", 

54 *, 

55 slack_error: str | None = None, 

56 ) -> None: 

57 super().__init__(message, channel="chat", backend="slack") 

58 self.slack_error = slack_error 

59 

60 

61class SlackMessage: 

62 """A message to be delivered via Slack. 

63 

64 Args: 

65 text: Plaintext fallback message body. 

66 channel: Target channel or user ID (Bot API only). Overrides 

67 the ``default_channel`` set on the backend. 

68 blocks: Optional Block Kit blocks for rich formatting. 

69 thread_ts: Optional parent message timestamp for threaded replies. 

70 """ 

71 

72 __slots__ = ("blocks", "channel", "text", "thread_ts") 

73 

74 def __init__( 

75 self, 

76 text: str, 

77 *, 

78 channel: str | None = None, 

79 blocks: list[dict[str, Any]] | None = None, 

80 thread_ts: str | None = None, 

81 ) -> None: 

82 self.text = text 

83 self.channel = channel 

84 self.blocks = blocks 

85 self.thread_ts = thread_ts 

86 

87 

88class SlackNotifier: 

89 """Slack notification backend. 

90 

91 Supports two delivery modes: 

92 

93 - **Incoming Webhook** (``mode="webhook"``): Posts a message to a 

94 pre-configured webhook URL. Simple and credential-free; does not 

95 require ``slack-sdk``. Uses ``httpx``. 

96 - **Bot API** (``mode="bot"``): Uses the ``slack-sdk`` 

97 ``AsyncWebClient`` to send to any channel or user, with support 

98 for Block Kit, threading, and other rich features. Requires the 

99 ``lexigram-notification[slack]`` extra. 

100 

101 Args: 

102 webhook_url: Slack Incoming Webhook URL (webhook mode). 

103 bot_token: Slack Bot OAuth token beginning with ``xoxb-`` 

104 (bot mode). 

105 default_channel: Default channel / user to message when 

106 :class:`SlackMessage` does not specify one (bot mode). 

107 timeout: HTTP request timeout in seconds. 

108 """ 

109 

110 def __init__( 

111 self, 

112 *, 

113 webhook_url: str | None = None, 

114 bot_token: str | None = None, 

115 default_channel: str | None = None, 

116 timeout: int = _DEFAULT_TIMEOUT, 

117 ) -> None: 

118 if not webhook_url and not bot_token: 

119 raise ValueError( 

120 "SlackNotifier requires either webhook_url (webhook mode) " 

121 "or bot_token (bot mode)." 

122 ) 

123 self._webhook_url = webhook_url 

124 self._bot_token = bot_token 

125 self._default_channel = default_channel 

126 self._timeout = timeout 

127 

128 @property 

129 def _mode(self) -> str: 

130 """Active delivery mode.""" 

131 return "webhook" if self._webhook_url else "bot" 

132 

133 # ────────────────────────────────────────────────────────────── 

134 # Public interface 

135 # ────────────────────────────────────────────────────────────── 

136 

137 async def send( 

138 self, message: SlackMessage 

139 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

140 """Send a Slack message. 

141 

142 Dispatches to the webhook or Bot API implementation depending on 

143 which credentials were supplied at construction time. 

144 

145 Args: 

146 message: :class:`SlackMessage` to deliver. 

147 

148 Returns: 

149 ``Ok(MessageDeliveryReceipt)`` on success. 

150 ``Err(SlackNotificationError)`` on delivery failure. 

151 """ 

152 if self._mode == "webhook": 

153 return await self._send_webhook(message) 

154 return await self._send_bot(message) 

155 

156 async def send_text( 

157 self, 

158 text: str, 

159 *, 

160 channel: str | None = None, 

161 thread_ts: str | None = None, 

162 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

163 """Convenience wrapper that sends a plain-text Slack message. 

164 

165 Args: 

166 text: Message body. 

167 channel: Target channel (bot mode only). 

168 thread_ts: Thread parent timestamp for threaded replies. 

169 

170 Returns: 

171 ``Ok(MessageDeliveryReceipt)`` on success. 

172 """ 

173 return await self.send(SlackMessage(text, channel=channel, thread_ts=thread_ts)) 

174 

175 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

176 """Check Slack API reachability. 

177 

178 - Webhook mode: performs a GET to ``https://slack.com``. 

179 - Bot API mode: calls ``auth.test`` with the bot token. 

180 

181 Args: 

182 timeout: Max seconds to wait. 

183 

184 Returns: 

185 :class:`~lexigram.contracts.core.HealthCheckResult`. 

186 """ 

187 if self._mode == "webhook": 

188 return await self._health_webhook(timeout) 

189 return await self._health_bot(timeout) 

190 

191 # ────────────────────────────────────────────────────────────── 

192 # Webhook mode 

193 # ────────────────────────────────────────────────────────────── 

194 

195 async def _send_webhook( 

196 self, message: SlackMessage 

197 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

198 """Post to a Slack Incoming Webhook URL via httpx.""" 

199 try: 

200 import httpx 

201 except ImportError as exc: 

202 raise ImportError( 

203 "httpx is required for SlackNotifier webhook mode. " 

204 "Install with: pip install lexigram-notification[slack]" 

205 ) from exc 

206 

207 payload: dict[str, Any] = {"text": _escape_mrkdwn(message.text)} 

208 if message.blocks: 

209 payload["blocks"] = message.blocks 

210 

211 async with httpx.AsyncClient(timeout=self._timeout) as client: 

212 resp = await client.post( 

213 self._webhook_url, # type: ignore[arg-type] 

214 json=payload, 

215 ) 

216 

217 # Slack webhooks return 200 with plain-text ``"ok"`` on success. 

218 if resp.status_code == 200 and resp.text.strip() == "ok": 

219 receipt = MessageDeliveryReceipt( 

220 message_id=str(uuid.uuid4()), 

221 backend="slack_webhook", 

222 channel="slack", 

223 ) 

224 logger.info("slack.webhook_sent", text_preview=message.text[:50]) 

225 return Ok(receipt) 

226 

227 error_text = resp.text.strip() or f"HTTP {resp.status_code}" 

228 logger.warning( 

229 "slack.webhook_send_failed", 

230 status=resp.status_code, 

231 body=error_text, 

232 ) 

233 return Err( 

234 SlackNotificationError( 

235 f"Slack webhook error: {error_text}", 

236 slack_error=error_text, 

237 ) 

238 ) 

239 

240 async def _health_webhook(self, timeout: float) -> HealthCheckResult: 

241 """Lightweight connectivity check for webhook mode.""" 

242 try: 

243 import httpx 

244 

245 async with httpx.AsyncClient(timeout=timeout) as client: 

246 resp = await client.get("https://slack.com") 

247 status = ( 

248 HealthStatus.HEALTHY 

249 if resp.status_code < 500 

250 else HealthStatus.UNHEALTHY 

251 ) 

252 return HealthCheckResult( 

253 component="slack_webhook", 

254 status=status, 

255 details={"http_status": resp.status_code}, 

256 ) 

257 except OSError as exc: 

258 return HealthCheckResult( 

259 component="slack_webhook", 

260 status=HealthStatus.UNHEALTHY, 

261 details={"error": str(exc)}, 

262 ) 

263 

264 # ────────────────────────────────────────────────────────────── 

265 # Bot API mode 

266 # ────────────────────────────────────────────────────────────── 

267 

268 async def _send_bot( 

269 self, message: SlackMessage 

270 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

271 """Send via slack-sdk AsyncWebClient.""" 

272 try: 

273 from slack_sdk.web.async_client import ( # type: ignore[import-not-found] 

274 AsyncWebClient, 

275 ) 

276 except ImportError as exc: 

277 raise ImportError( 

278 "slack-sdk is required for SlackNotifier bot mode. " 

279 "Install with: pip install lexigram-notification[slack]" 

280 ) from exc 

281 

282 channel = message.channel or self._default_channel 

283 if not channel: 

284 return Err( 

285 SlackNotificationError( 

286 "SlackNotifier bot mode requires a channel. " 

287 "Set default_channel or pass message.channel.", 

288 slack_error="missing_channel", 

289 ) 

290 ) 

291 

292 client = AsyncWebClient(token=self._bot_token) 

293 post_kwargs: dict[str, Any] = { 

294 "channel": channel, 

295 "text": _escape_mrkdwn(message.text), 

296 } 

297 if message.blocks: 

298 post_kwargs["blocks"] = message.blocks 

299 if message.thread_ts: 

300 post_kwargs["thread_ts"] = message.thread_ts 

301 

302 resp = await client.chat_postMessage(**post_kwargs) 

303 if resp["ok"]: 

304 receipt = MessageDeliveryReceipt( 

305 message_id=str(uuid.uuid4()), 

306 backend="slack_bot", 

307 channel="slack", 

308 provider_reference=resp.get("ts"), 

309 ) 

310 logger.info( 

311 "slack.bot_sent", 

312 channel=channel, 

313 ts=resp.get("ts"), 

314 text_preview=message.text[:50], 

315 ) 

316 return Ok(receipt) 

317 

318 error_code = resp.get("error", "unknown") 

319 logger.warning("slack.bot_send_failed", channel=channel, error=error_code) 

320 return Err( 

321 SlackNotificationError( 

322 f"Slack bot API error: {error_code}", 

323 slack_error=error_code, 

324 ) 

325 ) 

326 

327 async def _health_bot(self, timeout: float) -> HealthCheckResult: 

328 """Health check via Slack ``auth.test`` API.""" 

329 try: 

330 from slack_sdk.web.async_client import ( 

331 AsyncWebClient, 

332 ) 

333 except ImportError: 

334 return HealthCheckResult( 

335 component="slack_bot", 

336 status=HealthStatus.UNHEALTHY, 

337 message="slack-sdk is not installed", 

338 ) 

339 

340 try: 

341 client = AsyncWebClient(token=self._bot_token, timeout=timeout) 

342 resp = await client.auth_test() 

343 if resp["ok"]: 

344 return HealthCheckResult( 

345 component="slack_bot", 

346 status=HealthStatus.HEALTHY, 

347 details={"team": resp.get("team"), "bot_id": resp.get("bot_id")}, 

348 ) 

349 return HealthCheckResult( 

350 component="slack_bot", 

351 status=HealthStatus.UNHEALTHY, 

352 message=f"auth.test failed: {resp.get('error')}", 

353 ) 

354 except OSError as exc: 

355 return HealthCheckResult( 

356 component="slack_bot", 

357 status=HealthStatus.UNHEALTHY, 

358 details={"error": str(exc)}, 

359 ) 

360 

361 

362__all__ = [ 

363 "SlackMessage", 

364 "SlackNotificationError", 

365 "SlackNotifier", 

366]