devscontext 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,804 @@
1
+ """Slack adapter for fetching team discussion context.
2
+
3
+ This adapter connects to Slack Web API to search for messages mentioning
4
+ ticket IDs or keywords, fetches full threads, and extracts decisions/actions.
5
+
6
+ Implements the Adapter interface for the plugin system.
7
+
8
+ Example:
9
+ config = SlackConfig(bot_token="xoxb-...", channels=["engineering"])
10
+ adapter = SlackAdapter(config)
11
+ context = await adapter.fetch_task_context("PROJ-123", ticket)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import re
18
+ import time
19
+ from datetime import UTC, datetime, timedelta
20
+ from typing import TYPE_CHECKING, Any, ClassVar
21
+
22
+ import httpx
23
+
24
+ from devscontext.constants import (
25
+ ADAPTER_SLACK,
26
+ DEFAULT_HTTP_TIMEOUT_SECONDS,
27
+ SLACK_API_BASE_URL,
28
+ SLACK_CHANNEL_HISTORY_CACHE_TTL,
29
+ SLACK_MAX_MESSAGES_PER_CHANNEL,
30
+ SLACK_RATE_LIMIT_REQUESTS_PER_MINUTE,
31
+ SLACK_THREAD_REPLY_LIMIT,
32
+ SOURCE_TYPE_COMMUNICATION,
33
+ )
34
+ from devscontext.logging import get_logger
35
+ from devscontext.models import (
36
+ SlackConfig,
37
+ SlackContext,
38
+ SlackMessage,
39
+ SlackThread,
40
+ )
41
+ from devscontext.plugins.base import Adapter, SearchResult, SourceContext
42
+ from devscontext.utils import extract_keywords
43
+
44
+ if TYPE_CHECKING:
45
+ from devscontext.models import JiraTicket
46
+
47
+ logger = get_logger(__name__)
48
+
49
+
50
+ # Decision patterns for extraction
51
+ DECISION_PATTERNS = [
52
+ re.compile(r"(?:we(?:'ve|'ll| will| have)?\s+)?decided\s+(?:to\s+)?(.+)", re.IGNORECASE),
53
+ re.compile(r"let's\s+(?:go\s+with|use|do)\s+(.+)", re.IGNORECASE),
54
+ re.compile(r"agreed[:\s]+(.+)", re.IGNORECASE),
55
+ re.compile(r"decision[:\s]+(.+)", re.IGNORECASE),
56
+ re.compile(r"we(?:'re| are)\s+going\s+(?:to|with)\s+(.+)", re.IGNORECASE),
57
+ ]
58
+
59
+ # Action item patterns for extraction
60
+ ACTION_PATTERNS = [
61
+ re.compile(r"(?:i(?:'ll| will|'m going to)\s+)(.+)", re.IGNORECASE),
62
+ re.compile(r"@\w+\s+(?:can you|please|will you|could you)\s+(.+)", re.IGNORECASE),
63
+ re.compile(r"action item[:\s]+(.+)", re.IGNORECASE),
64
+ re.compile(r"todo[:\s]+(.+)", re.IGNORECASE),
65
+ re.compile(r"need(?:s)? to\s+(.+)", re.IGNORECASE),
66
+ ]
67
+
68
+
69
+ class RateLimiter:
70
+ """Simple rate limiter for Slack API calls."""
71
+
72
+ def __init__(self, requests_per_minute: int = SLACK_RATE_LIMIT_REQUESTS_PER_MINUTE) -> None:
73
+ """Initialize rate limiter.
74
+
75
+ Args:
76
+ requests_per_minute: Maximum requests allowed per minute.
77
+ """
78
+ self._requests_per_minute = requests_per_minute
79
+ self._request_times: list[float] = []
80
+
81
+ async def acquire(self) -> None:
82
+ """Wait if necessary to respect rate limits."""
83
+ now = time.monotonic()
84
+ minute_ago = now - 60
85
+
86
+ # Remove old requests
87
+ self._request_times = [t for t in self._request_times if t > minute_ago]
88
+
89
+ if len(self._request_times) >= self._requests_per_minute:
90
+ # Wait until oldest request is more than a minute old
91
+ sleep_time = 60 - (now - self._request_times[0]) + 0.1
92
+ if sleep_time > 0:
93
+ logger.debug(f"Rate limiting: sleeping {sleep_time:.2f}s")
94
+ await asyncio.sleep(sleep_time)
95
+
96
+ self._request_times.append(time.monotonic())
97
+
98
+
99
+ class ChannelHistoryCache:
100
+ """Simple cache for channel history to avoid repeated fetches."""
101
+
102
+ def __init__(self, ttl_seconds: int = SLACK_CHANNEL_HISTORY_CACHE_TTL) -> None:
103
+ """Initialize cache.
104
+
105
+ Args:
106
+ ttl_seconds: Time-to-live for cached entries in seconds.
107
+ """
108
+ self._ttl = ttl_seconds
109
+ self._cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
110
+
111
+ def get(self, channel_id: str) -> list[dict[str, Any]] | None:
112
+ """Get cached history if not expired.
113
+
114
+ Args:
115
+ channel_id: The channel ID to look up.
116
+
117
+ Returns:
118
+ Cached messages or None if not found/expired.
119
+ """
120
+ if channel_id in self._cache:
121
+ timestamp, messages = self._cache[channel_id]
122
+ if time.monotonic() - timestamp < self._ttl:
123
+ return messages
124
+ del self._cache[channel_id]
125
+ return None
126
+
127
+ def set(self, channel_id: str, messages: list[dict[str, Any]]) -> None:
128
+ """Cache channel history.
129
+
130
+ Args:
131
+ channel_id: The channel ID to cache.
132
+ messages: The messages to cache.
133
+ """
134
+ self._cache[channel_id] = (time.monotonic(), messages)
135
+
136
+ def clear(self) -> None:
137
+ """Clear all cached data."""
138
+ self._cache.clear()
139
+
140
+
141
+ class SlackAdapter(Adapter):
142
+ """Adapter for fetching context from Slack conversations.
143
+
144
+ Implements the Adapter interface for the plugin system.
145
+ Searches for messages mentioning ticket IDs or keywords,
146
+ fetches full threads, and extracts decisions/actions.
147
+
148
+ Class Attributes:
149
+ name: Adapter identifier ("slack").
150
+ source_type: Source category ("communication").
151
+ config_schema: Configuration model (SlackConfig).
152
+ """
153
+
154
+ name: ClassVar[str] = ADAPTER_SLACK
155
+ source_type: ClassVar[str] = SOURCE_TYPE_COMMUNICATION
156
+ config_schema: ClassVar[type[SlackConfig]] = SlackConfig
157
+
158
+ def __init__(self, config: SlackConfig) -> None:
159
+ """Initialize the Slack adapter.
160
+
161
+ Args:
162
+ config: Slack configuration with bot token and channels.
163
+ """
164
+ self._config = config
165
+ self._client: httpx.AsyncClient | None = None
166
+ self._rate_limiter = RateLimiter()
167
+ self._channel_cache = ChannelHistoryCache()
168
+ self._channel_id_map: dict[str, str] = {} # name -> id
169
+ self._user_name_cache: dict[str, str] = {} # user_id -> display_name
170
+
171
+ def _get_client(self) -> httpx.AsyncClient:
172
+ """Get or create the HTTP client."""
173
+ if self._client is None:
174
+ self._client = httpx.AsyncClient(
175
+ base_url=SLACK_API_BASE_URL,
176
+ headers={
177
+ "Authorization": f"Bearer {self._config.bot_token}",
178
+ "Content-Type": "application/json",
179
+ },
180
+ timeout=DEFAULT_HTTP_TIMEOUT_SECONDS,
181
+ )
182
+ return self._client
183
+
184
+ async def close(self) -> None:
185
+ """Close HTTP client and clear caches."""
186
+ if self._client is not None:
187
+ await self._client.aclose()
188
+ self._client = None
189
+ self._channel_cache.clear()
190
+ self._channel_id_map.clear()
191
+ self._user_name_cache.clear()
192
+
193
+ async def _api_call(
194
+ self,
195
+ method: str,
196
+ endpoint: str,
197
+ params: dict[str, Any] | None = None,
198
+ ) -> dict[str, Any]:
199
+ """Make a rate-limited Slack API call with error handling.
200
+
201
+ Args:
202
+ method: HTTP method (GET or POST).
203
+ endpoint: API endpoint path.
204
+ params: Query parameters or POST body.
205
+
206
+ Returns:
207
+ API response as dict.
208
+ """
209
+ await self._rate_limiter.acquire()
210
+ client = self._get_client()
211
+
212
+ try:
213
+ if method.upper() == "GET":
214
+ response = await client.get(endpoint, params=params)
215
+ else:
216
+ response = await client.post(endpoint, json=params)
217
+
218
+ response.raise_for_status()
219
+ data: dict[str, Any] = response.json()
220
+
221
+ if not data.get("ok"):
222
+ error = data.get("error", "unknown_error")
223
+ logger.warning(
224
+ "Slack API error",
225
+ extra={"endpoint": endpoint, "error": error},
226
+ )
227
+
228
+ # Handle rate limiting response
229
+ if error == "ratelimited":
230
+ retry_after = int(data.get("retry_after", 30))
231
+ logger.info(f"Rate limited, waiting {retry_after}s")
232
+ await asyncio.sleep(retry_after)
233
+ return await self._api_call(method, endpoint, params)
234
+
235
+ return {"ok": False, "error": error}
236
+
237
+ return data
238
+
239
+ except httpx.HTTPStatusError as e:
240
+ if e.response.status_code == 429:
241
+ retry_after = int(e.response.headers.get("Retry-After", "30"))
242
+ logger.info(f"Rate limited (429), waiting {retry_after}s")
243
+ await asyncio.sleep(retry_after)
244
+ return await self._api_call(method, endpoint, params)
245
+ logger.warning(
246
+ "Slack HTTP error",
247
+ extra={"status_code": e.response.status_code, "endpoint": endpoint},
248
+ )
249
+ return {"ok": False, "error": f"http_{e.response.status_code}"}
250
+
251
+ except httpx.RequestError as e:
252
+ logger.warning("Slack request error", extra={"error": str(e)})
253
+ return {"ok": False, "error": "network_error"}
254
+
255
+ async def _resolve_channel_ids(self) -> dict[str, str]:
256
+ """Resolve channel names to IDs.
257
+
258
+ Returns:
259
+ Dict mapping channel names to IDs.
260
+ """
261
+ if self._channel_id_map:
262
+ return self._channel_id_map
263
+
264
+ # Get list of channels the bot is in
265
+ data = await self._api_call(
266
+ "GET",
267
+ "/conversations.list",
268
+ {"types": "public_channel,private_channel", "limit": 200},
269
+ )
270
+
271
+ if not data.get("ok"):
272
+ return {}
273
+
274
+ for channel in data.get("channels", []):
275
+ name = channel.get("name", "")
276
+ channel_id = channel.get("id", "")
277
+ if name and channel_id:
278
+ self._channel_id_map[name] = channel_id
279
+
280
+ return self._channel_id_map
281
+
282
+ async def _resolve_user_name(self, user_id: str) -> str:
283
+ """Resolve user ID to display name.
284
+
285
+ Args:
286
+ user_id: Slack user ID.
287
+
288
+ Returns:
289
+ User display name or the user ID if lookup fails.
290
+ """
291
+ if user_id in self._user_name_cache:
292
+ return self._user_name_cache[user_id]
293
+
294
+ data = await self._api_call("GET", "/users.info", {"user": user_id})
295
+
296
+ if not data.get("ok"):
297
+ return user_id
298
+
299
+ user = data.get("user", {})
300
+ profile = user.get("profile", {})
301
+ name = profile.get("display_name") or profile.get("real_name") or user_id
302
+ self._user_name_cache[user_id] = name
303
+ return name
304
+
305
+ async def _search_messages(
306
+ self,
307
+ query: str,
308
+ max_results: int = 20,
309
+ ) -> list[dict[str, Any]]:
310
+ """Search for messages using Slack search API.
311
+
312
+ Falls back to channel history if search is not available (free plan).
313
+
314
+ Args:
315
+ query: Search query string.
316
+ max_results: Maximum number of results.
317
+
318
+ Returns:
319
+ List of matching message dicts.
320
+ """
321
+ # Try search API first (requires paid plan)
322
+ data = await self._api_call(
323
+ "GET",
324
+ "/search.messages",
325
+ {
326
+ "query": query,
327
+ "count": max_results,
328
+ "sort": "timestamp",
329
+ "sort_dir": "desc",
330
+ },
331
+ )
332
+
333
+ if data.get("ok"):
334
+ matches: list[dict[str, Any]] = data.get("messages", {}).get("matches", [])
335
+ if matches:
336
+ logger.debug(f"Found {len(matches)} messages via search API")
337
+ return matches
338
+
339
+ # Fall back to channel history search
340
+ logger.debug("Search API unavailable, falling back to channel history")
341
+ return await self._search_channel_history(query, max_results)
342
+
343
+ async def _search_channel_history(
344
+ self,
345
+ query: str,
346
+ max_results: int = 20,
347
+ ) -> list[dict[str, Any]]:
348
+ """Search channel history manually (for free Slack plans).
349
+
350
+ Args:
351
+ query: Search query string.
352
+ max_results: Maximum number of results.
353
+
354
+ Returns:
355
+ List of matching message dicts.
356
+ """
357
+ channel_ids = await self._resolve_channel_ids()
358
+
359
+ # Filter to configured channels
360
+ target_channels = [
361
+ (name, channel_ids[name]) for name in self._config.channels if name in channel_ids
362
+ ]
363
+
364
+ if not target_channels:
365
+ logger.warning("No configured channels found")
366
+ return []
367
+
368
+ oldest = (datetime.now(UTC) - timedelta(days=self._config.lookback_days)).timestamp()
369
+ query_lower = query.lower()
370
+ matching_messages: list[dict[str, Any]] = []
371
+
372
+ for channel_name, channel_id in target_channels:
373
+ # Check cache first
374
+ cached = self._channel_cache.get(channel_id)
375
+
376
+ if cached is None:
377
+ data = await self._api_call(
378
+ "GET",
379
+ "/conversations.history",
380
+ {
381
+ "channel": channel_id,
382
+ "oldest": str(oldest),
383
+ "limit": SLACK_MAX_MESSAGES_PER_CHANNEL,
384
+ },
385
+ )
386
+
387
+ if not data.get("ok"):
388
+ continue
389
+
390
+ cached = data.get("messages", [])
391
+ self._channel_cache.set(channel_id, cached)
392
+
393
+ # Search through messages
394
+ for msg in cached:
395
+ text = msg.get("text", "").lower()
396
+ if query_lower in text:
397
+ # Add channel info to message
398
+ msg_copy = dict(msg)
399
+ msg_copy["channel"] = channel_id
400
+ msg_copy["_channel_name"] = channel_name
401
+ matching_messages.append(msg_copy)
402
+
403
+ if len(matching_messages) >= max_results:
404
+ return matching_messages
405
+
406
+ return matching_messages
407
+
408
+ async def _fetch_thread(
409
+ self,
410
+ channel_id: str,
411
+ thread_ts: str,
412
+ ) -> list[dict[str, Any]]:
413
+ """Fetch all replies in a thread.
414
+
415
+ Args:
416
+ channel_id: The channel containing the thread.
417
+ thread_ts: The thread timestamp.
418
+
419
+ Returns:
420
+ List of message dicts in the thread.
421
+ """
422
+ data = await self._api_call(
423
+ "GET",
424
+ "/conversations.replies",
425
+ {
426
+ "channel": channel_id,
427
+ "ts": thread_ts,
428
+ "limit": SLACK_THREAD_REPLY_LIMIT,
429
+ },
430
+ )
431
+
432
+ if not data.get("ok"):
433
+ return []
434
+
435
+ messages: list[dict[str, Any]] = data.get("messages", [])
436
+ return messages
437
+
438
+ def _parse_message(
439
+ self,
440
+ msg: dict[str, Any],
441
+ channel_id: str,
442
+ channel_name: str,
443
+ user_names: dict[str, str],
444
+ ) -> SlackMessage:
445
+ """Parse a Slack message into our model.
446
+
447
+ Args:
448
+ msg: Raw message dict from Slack API.
449
+ channel_id: The channel ID.
450
+ channel_name: The channel name.
451
+ user_names: Dict mapping user IDs to display names.
452
+
453
+ Returns:
454
+ SlackMessage instance.
455
+ """
456
+ user_id = msg.get("user", "unknown")
457
+ ts = msg.get("ts", "0")
458
+
459
+ # Convert timestamp
460
+ try:
461
+ timestamp = datetime.fromtimestamp(float(ts), tz=UTC)
462
+ except (ValueError, TypeError):
463
+ timestamp = datetime.now(UTC)
464
+
465
+ # Get reactions as emoji names
466
+ reactions = []
467
+ for reaction in msg.get("reactions", []):
468
+ reactions.append(reaction.get("name", ""))
469
+
470
+ return SlackMessage(
471
+ message_id=ts,
472
+ channel_id=channel_id,
473
+ channel_name=channel_name,
474
+ user_id=user_id,
475
+ user_name=user_names.get(user_id, user_id),
476
+ text=msg.get("text", ""),
477
+ timestamp=timestamp,
478
+ thread_ts=msg.get("thread_ts") if msg.get("thread_ts") != ts else None,
479
+ permalink=msg.get("permalink"),
480
+ reactions=reactions,
481
+ )
482
+
483
+ def _extract_decisions(self, text: str) -> list[str]:
484
+ """Extract decisions from message text.
485
+
486
+ Args:
487
+ text: Message text to analyze.
488
+
489
+ Returns:
490
+ List of extracted decision strings.
491
+ """
492
+ decisions = []
493
+
494
+ for pattern in DECISION_PATTERNS:
495
+ for match in pattern.finditer(text):
496
+ decision = match.group(1).strip()
497
+ # Filter out too short or too long
498
+ if 10 < len(decision) < 200:
499
+ decisions.append(decision[:200])
500
+
501
+ return decisions[:10] # Limit to 10 decisions
502
+
503
+ def _extract_action_items(self, text: str) -> list[str]:
504
+ """Extract action items from message text.
505
+
506
+ Args:
507
+ text: Message text to analyze.
508
+
509
+ Returns:
510
+ List of extracted action item strings.
511
+ """
512
+ actions = []
513
+
514
+ for pattern in ACTION_PATTERNS:
515
+ for match in pattern.finditer(text):
516
+ action = match.group(1).strip()
517
+ # Filter out too short or too long
518
+ if 5 < len(action) < 200:
519
+ actions.append(action[:200])
520
+
521
+ return actions[:10] # Limit to 10 actions
522
+
523
+ async def fetch_task_context(
524
+ self,
525
+ task_id: str,
526
+ ticket: JiraTicket | None = None,
527
+ ) -> SourceContext:
528
+ """Fetch context from Slack conversations.
529
+
530
+ Search strategy:
531
+ 1. Search for exact ticket ID (e.g., "PROJ-123")
532
+ 2. Search for keywords from ticket title
533
+ 3. Fetch full threads for matching messages
534
+ 4. Extract decisions and action items
535
+
536
+ Args:
537
+ task_id: The task identifier to search for.
538
+ ticket: Optional Jira ticket for keyword extraction.
539
+
540
+ Returns:
541
+ SourceContext with SlackContext data.
542
+ """
543
+ if not self._config.enabled:
544
+ logger.debug("Slack adapter is disabled")
545
+ return SourceContext(
546
+ source_name=self.name,
547
+ source_type=self.source_type,
548
+ data=None,
549
+ raw_text="",
550
+ )
551
+
552
+ if not self._config.bot_token:
553
+ logger.warning("Slack adapter missing bot token")
554
+ return SourceContext(
555
+ source_name=self.name,
556
+ source_type=self.source_type,
557
+ data=None,
558
+ raw_text="",
559
+ )
560
+
561
+ # Build search queries
562
+ search_queries = [task_id]
563
+ if ticket:
564
+ keywords = extract_keywords(ticket.title)[:5]
565
+ search_queries.extend(keywords)
566
+
567
+ # Collect matching messages
568
+ all_matches: list[dict[str, Any]] = []
569
+ seen_ts: set[str] = set()
570
+
571
+ for query in search_queries:
572
+ matches = await self._search_messages(
573
+ query,
574
+ max_results=self._config.max_messages // max(len(search_queries), 1),
575
+ )
576
+ for msg in matches:
577
+ ts = msg.get("ts", "")
578
+ if ts and ts not in seen_ts:
579
+ seen_ts.add(ts)
580
+ all_matches.append(msg)
581
+
582
+ if not all_matches:
583
+ return SourceContext(
584
+ source_name=self.name,
585
+ source_type=self.source_type,
586
+ data=SlackContext(),
587
+ raw_text="",
588
+ metadata={"task_id": task_id, "thread_count": 0},
589
+ )
590
+
591
+ # Resolve channel names and user names
592
+ channel_ids = await self._resolve_channel_ids()
593
+ id_to_name = {v: k for k, v in channel_ids.items()}
594
+
595
+ # Collect unique user IDs
596
+ user_ids: set[str] = set()
597
+ for msg in all_matches:
598
+ if msg.get("user"):
599
+ user_ids.add(msg["user"])
600
+
601
+ # Resolve user names
602
+ user_names: dict[str, str] = {}
603
+ for user_id in user_ids:
604
+ user_names[user_id] = await self._resolve_user_name(user_id)
605
+
606
+ # Group by thread and fetch full threads
607
+ threads: list[SlackThread] = []
608
+ standalone: list[SlackMessage] = []
609
+ processed_threads: set[str] = set()
610
+
611
+ for msg in all_matches:
612
+ channel_id = msg.get("channel", "")
613
+ channel_name = msg.get("_channel_name") or id_to_name.get(channel_id, channel_id)
614
+ thread_ts = msg.get("thread_ts") or msg.get("ts", "")
615
+
616
+ # Skip if we've already processed this thread
617
+ thread_key = f"{channel_id}:{thread_ts}"
618
+ if thread_key in processed_threads:
619
+ continue
620
+ processed_threads.add(thread_key)
621
+
622
+ reply_count = msg.get("reply_count", 0)
623
+ if self._config.include_threads and reply_count > 0:
624
+ # Fetch full thread
625
+ thread_msgs = await self._fetch_thread(channel_id, thread_ts)
626
+
627
+ if thread_msgs:
628
+ # Resolve user names for thread participants
629
+ for thread_msg in thread_msgs:
630
+ thread_user_id: str | None = thread_msg.get("user")
631
+ if thread_user_id and thread_user_id not in user_names:
632
+ user_names[thread_user_id] = await self._resolve_user_name(
633
+ thread_user_id
634
+ )
635
+
636
+ parent = self._parse_message(
637
+ thread_msgs[0], channel_id, channel_name, user_names
638
+ )
639
+ replies = [
640
+ self._parse_message(m, channel_id, channel_name, user_names)
641
+ for m in thread_msgs[1:]
642
+ ]
643
+
644
+ # Extract decisions and actions from all messages
645
+ all_decisions: list[str] = []
646
+ all_actions: list[str] = []
647
+ participants: set[str] = {parent.user_name}
648
+
649
+ for m in [parent, *replies]:
650
+ all_decisions.extend(self._extract_decisions(m.text))
651
+ all_actions.extend(self._extract_action_items(m.text))
652
+ participants.add(m.user_name)
653
+
654
+ threads.append(
655
+ SlackThread(
656
+ parent_message=parent,
657
+ replies=replies,
658
+ participant_names=list(participants),
659
+ decisions=all_decisions[:10],
660
+ action_items=all_actions[:10],
661
+ )
662
+ )
663
+ else:
664
+ # Standalone message
665
+ parsed = self._parse_message(msg, channel_id, channel_name, user_names)
666
+ standalone.append(parsed)
667
+
668
+ slack_context = SlackContext(
669
+ threads=threads,
670
+ standalone_messages=standalone,
671
+ )
672
+
673
+ raw_text = self._format_slack_context(slack_context)
674
+
675
+ logger.info(
676
+ "Slack context assembled",
677
+ extra={
678
+ "task_id": task_id,
679
+ "thread_count": len(threads),
680
+ "standalone_count": len(standalone),
681
+ },
682
+ )
683
+
684
+ return SourceContext(
685
+ source_name=self.name,
686
+ source_type=self.source_type,
687
+ data=slack_context,
688
+ raw_text=raw_text,
689
+ metadata={
690
+ "task_id": task_id,
691
+ "thread_count": len(threads),
692
+ "standalone_count": len(standalone),
693
+ },
694
+ )
695
+
696
+ def _format_slack_context(self, context: SlackContext) -> str:
697
+ """Format Slack context as raw text for synthesis.
698
+
699
+ Args:
700
+ context: SlackContext with threads and messages.
701
+
702
+ Returns:
703
+ Formatted markdown string.
704
+ """
705
+ parts: list[str] = []
706
+
707
+ for thread in context.threads:
708
+ thread_parts = [
709
+ f"## #{thread.parent_message.channel_name} Thread",
710
+ f"**Started:** {thread.parent_message.timestamp.strftime('%Y-%m-%d %H:%M')}",
711
+ f"**Participants:** {', '.join(thread.participant_names)}",
712
+ "",
713
+ f"**{thread.parent_message.user_name}:** {thread.parent_message.text}",
714
+ ]
715
+
716
+ for reply in thread.replies[:10]:
717
+ thread_parts.append(f"**{reply.user_name}:** {reply.text}")
718
+
719
+ if thread.decisions:
720
+ thread_parts.append("\n**Decisions:**")
721
+ for d in thread.decisions:
722
+ thread_parts.append(f"- {d}")
723
+
724
+ if thread.action_items:
725
+ thread_parts.append("\n**Action Items:**")
726
+ for a in thread.action_items:
727
+ thread_parts.append(f"- {a}")
728
+
729
+ parts.append("\n".join(thread_parts))
730
+
731
+ for msg in context.standalone_messages[:10]:
732
+ parts.append(
733
+ f"**#{msg.channel_name}** ({msg.timestamp.strftime('%Y-%m-%d')}) "
734
+ f"**{msg.user_name}:** {msg.text}"
735
+ )
736
+
737
+ return "\n\n---\n\n".join(parts)
738
+
739
+ async def search(
740
+ self,
741
+ query: str,
742
+ max_results: int = 10,
743
+ ) -> list[SearchResult]:
744
+ """Search Slack messages matching the query.
745
+
746
+ Args:
747
+ query: Search terms.
748
+ max_results: Maximum number of results.
749
+
750
+ Returns:
751
+ List of SearchResult items.
752
+ """
753
+ if not self._config.enabled or not self._config.bot_token:
754
+ return []
755
+
756
+ matches = await self._search_messages(query, max_results)
757
+
758
+ results: list[SearchResult] = []
759
+ for msg in matches[:max_results]:
760
+ text = msg.get("text", "")[:300]
761
+
762
+ # Get channel name
763
+ channel = msg.get("channel", "")
764
+ channel_name = msg.get("_channel_name", "")
765
+ if not channel_name and isinstance(channel, dict):
766
+ channel_name = channel.get("name", "")
767
+
768
+ results.append(
769
+ SearchResult(
770
+ source_name=self.name,
771
+ source_type=self.source_type,
772
+ title=f"Slack: #{channel_name}" if channel_name else "Slack message",
773
+ excerpt=text,
774
+ url=msg.get("permalink"),
775
+ metadata={
776
+ "channel": channel_name,
777
+ "ts": msg.get("ts"),
778
+ },
779
+ )
780
+ )
781
+
782
+ return results
783
+
784
+ async def health_check(self) -> bool:
785
+ """Check if Slack is configured and accessible.
786
+
787
+ Returns:
788
+ True if healthy or disabled, False if there's an issue.
789
+ """
790
+ if not self._config.enabled:
791
+ return True
792
+
793
+ if not self._config.bot_token:
794
+ logger.warning("Slack adapter missing bot token")
795
+ return False
796
+
797
+ data = await self._api_call("GET", "/auth.test")
798
+
799
+ if data.get("ok"):
800
+ logger.info("Slack health check passed")
801
+ return True
802
+
803
+ logger.warning(f"Slack health check failed: {data.get('error')}")
804
+ return False