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.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|