@misterhuydo/sentinel 1.0.77 → 1.0.82

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.
@@ -1,384 +1,427 @@
1
- """
2
- slack_bot.py — Slack integration for Sentinel Boss.
3
-
4
- One DevOps member is handled at a time per Sentinel instance.
5
- Others are queued and notified of their position. When the current
6
- session finishes, the next person is picked up automatically.
7
-
8
- Setup (api.slack.com):
9
- 1. Create a Slack App Enable Socket Mode → copy App-Level Token (xapp-...)
10
- 2. Add Bot Token Scopes: app_mentions:read, chat:write, im:history,
11
- channels:history, users:read
12
- 3. Enable Events: app_mention, message.im, message.channels
13
- 4. Install to workspace → copy Bot Token (xoxb-...)
14
- 5. Add to sentinel.properties:
15
- SLACK_BOT_TOKEN=xoxb-...
16
- SLACK_APP_TOKEN=xapp-...
17
- """
18
-
19
- import asyncio
20
- import logging
21
- from dataclasses import dataclass, field
22
- from typing import Optional
23
-
24
- from .sentinel_boss import handle_message
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- # ── Session and Queue ─────────────────────────────────────────────────────────
30
-
31
- @dataclass
32
- class _Session:
33
- user_id: str
34
- user_name: str
35
- channel: str
36
- history: list = field(default_factory=list)
37
-
38
-
39
- class _Queue:
40
- """
41
- Global sequential queue for a Sentinel instance.
42
- One member at a time; others wait.
43
- """
44
-
45
- def __init__(self):
46
- self._lock: asyncio.Lock = asyncio.Lock()
47
- self._active: Optional[_Session] = None
48
- self._waiting: list[tuple[_Session, str]] = [] # (session, first_message)
49
-
50
- async def try_activate(
51
- self, user_id: str, user_name: str, channel: str
52
- ) -> tuple[str, int, Optional[_Session]]:
53
- """
54
- Returns:
55
- ('active', 0, session) — slot is free, session started
56
- ('continuing', 0, session) — same user already has the slot
57
- ('queued', pos, None) — added to wait queue at position pos
58
- """
59
- async with self._lock:
60
- if self._active is None:
61
- self._active = _Session(user_id, user_name, channel)
62
- return "active", 0, self._active
63
-
64
- if self._active.user_id == user_id:
65
- return "continuing", 0, self._active
66
-
67
- # Deduplicate — don't queue the same user twice
68
- if not any(s.user_id == user_id for s, _ in self._waiting):
69
- self._waiting.append((_Session(user_id, user_name, channel), ""))
70
-
71
- pos = next(
72
- i + 1
73
- for i, (s, _) in enumerate(self._waiting)
74
- if s.user_id == user_id
75
- )
76
- return "queued", pos, None
77
-
78
- async def update_waiting_message(self, user_id: str, message: str):
79
- """Overwrite the first message for a queued user (they may rephrase while waiting)."""
80
- async with self._lock:
81
- for i, (s, _) in enumerate(self._waiting):
82
- if s.user_id == user_id:
83
- self._waiting[i] = (s, message)
84
- return
85
-
86
- async def complete(self) -> Optional[tuple[_Session, str]]:
87
- """Release the active slot. Returns (next_session, first_message) or None."""
88
- async with self._lock:
89
- prev = self._active
90
- self._active = None
91
- if self._waiting:
92
- session, msg = self._waiting.pop(0)
93
- self._active = session
94
- return session, msg
95
- return None
96
-
97
- def active_user_name(self) -> Optional[str]:
98
- return self._active.user_name if self._active else None
99
-
100
-
101
- _queue = _Queue()
102
-
103
-
104
- # ── Slack bot ─────────────────────────────────────────────────────────────────
105
-
106
- async def run_slack_bot(cfg_loader, store) -> None:
107
- """
108
- Start Slack Bolt in Socket Mode as an asyncio background task.
109
- Exits silently if tokens are missing or slack-bolt is not installed.
110
- """
111
- try:
112
- from slack_bolt.async_app import AsyncApp
113
- from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
114
- except ImportError:
115
- logger.warning(
116
- "slack-bolt not installed — Slack bot disabled. "
117
- "Run: pip install slack-bolt"
118
- )
119
- return
120
-
121
- cfg = cfg_loader.sentinel
122
- if not cfg.slack_bot_token or not cfg.slack_app_token:
123
- logger.info("SLACK_BOT_TOKEN / SLACK_APP_TOKEN not set — Slack bot disabled")
124
- return
125
-
126
- if cfg.slack_channel:
127
- logger.info("Sentinel Boss restricted to channel: %s", cfg.slack_channel)
128
-
129
- app = AsyncApp(token=cfg.slack_bot_token)
130
-
131
- # ── Channel resolver — converts #name to ID lazily on first event ─────────
132
- _allowed_id: list = [] # single-element list used as mutable cell
133
-
134
- async def _resolve_allowed(client):
135
- raw = cfg.slack_channel.lstrip("#")
136
- if not raw:
137
- return
138
- # Already a Slack channel ID (e.g. C01AB2CD3EF)
139
- if raw.upper().startswith("C") and len(raw) >= 9:
140
- _allowed_id.append(raw.upper())
141
- return
142
- try:
143
- resp = await client.conversations_list(
144
- types="public_channel,private_channel", limit=1000
145
- )
146
- for ch in (resp.get("channels") or []):
147
- if ch.get("name") == raw:
148
- _allowed_id.append(ch["id"])
149
- logger.info("Boss channel resolved: #%s → %s", raw, ch["id"])
150
- return
151
- logger.warning("SLACK_CHANNEL '%s' not found — responding everywhere", cfg.slack_channel)
152
- except Exception as e:
153
- logger.warning("Could not resolve SLACK_CHANNEL '%s': %s", cfg.slack_channel, e)
154
-
155
- def _allowed(channel: str) -> bool:
156
- return not _allowed_id or channel in _allowed_id
157
-
158
- # ── Event handlers ────────────────────────────────────────────────────────
159
-
160
- @app.event("app_mention")
161
- async def on_mention(event, client):
162
- if cfg.slack_channel and not _allowed_id:
163
- await _resolve_allowed(client)
164
- if _allowed(event.get("channel", "")):
165
- await _dispatch(event, client, cfg_loader, store)
166
-
167
- # ── Passive bot watcher — seed DB from config on startup ─────────────────
168
- for bot_id_cfg in cfg.slack_watch_bot_ids:
169
- if bot_id_cfg and not store.is_watched_bot(bot_id_cfg):
170
- store.add_watched_bot(bot_id_cfg, bot_id_cfg, added_by="config")
171
- logger.info("Seeded watched bot from config: %s", bot_id_cfg)
172
-
173
- @app.event("message")
174
- async def on_message(event, client):
175
- # DMs from humans → Boss conversation
176
- if event.get("channel_type") == "im" and not event.get("bot_id"):
177
- await _dispatch(event, client, cfg_loader, store)
178
- return
179
-
180
- # Passive bot watcher — bot messages from DB-registered bots
181
- bot_id = event.get("bot_id", "")
182
- if bot_id and event.get("channel") and store.is_watched_bot(bot_id):
183
- await _handle_bot_message(event, client, cfg_loader, store)
184
-
185
- # ── Start ─────────────────────────────────────────────────────────────────
186
-
187
- handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
188
- logger.info("Sentinel Boss connected to Slack (Socket Mode)")
189
- watched = store.get_watched_bots()
190
- if watched:
191
- logger.info("Sentinel passively watching %d bot(s): %s",
192
- len(watched), [b["bot_name"] for b in watched])
193
- await handler.start_async()
194
-
195
-
196
- # ── Bot-message watcher ───────────────────────────────────────────────────────
197
-
198
- import uuid as _uuid
199
- from pathlib import Path as _Path
200
-
201
-
202
- async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
203
- """
204
- Called when a watched bot posts a message.
205
- Queues the message as an issue in the bot's registered project directory.
206
- All messages from watched bots are queued — no error pre-filtering.
207
- """
208
- bot_id = event.get("bot_id", "")
209
- channel = event.get("channel", "")
210
- ts = event.get("ts", "")
211
-
212
- # Avoid duplicate issues
213
- dedup_key = f"slack-watch:{channel}:{ts}"
214
- if store.is_seen_dedup(dedup_key):
215
- return
216
- store.mark_seen_dedup(dedup_key)
217
-
218
- # Flatten text (handle block-kit attachments too)
219
- text = event.get("text", "")
220
- for att in event.get("attachments", []):
221
- text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
222
- text = text.strip()
223
- if not text:
224
- return
225
-
226
- # Find the project this bot is registered to
227
- bots = store.get_watched_bots()
228
- bot_info = next((b for b in bots if b["bot_id"] == bot_id), None)
229
- project_name = (bot_info or {}).get("project_name") or ""
230
-
231
- # Resolve the project issues directory
232
- workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
233
- if project_name:
234
- # workspace_dir is <sentinel_root>/workspace, project dirs are siblings
235
- project_dirs = [
236
- d for d in workspace.iterdir()
237
- if d.is_dir() and d.name != "workspace"
238
- and (d / "config" / "sentinel.properties").exists()
239
- ]
240
- matched = next(
241
- (d for d in project_dirs
242
- if d.name == project_name or
243
- _read_project_name_from_dir(d) == project_name),
244
- None,
245
- )
246
- issues_dir = (matched / "issues") if matched else (_Path(".") / "issues")
247
- else:
248
- issues_dir = _Path(".") / "issues"
249
-
250
- issues_dir.mkdir(parents=True, exist_ok=True)
251
-
252
- uid = _uuid.uuid4().hex[:8]
253
- fname = f"bot-{project_name or 'unknown'}-{uid}.txt"
254
- content = (
255
- f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
256
- f"SLACK_TS: {ts}\n\n"
257
- f"{text}"
258
- )
259
- (issues_dir / fname).write_text(content, encoding="utf-8")
260
- _Path("SENTINEL_POLL_NOW").touch()
261
- logger.info("Bot watcher queued issue from %s → %s/%s", bot_id, issues_dir, fname)
262
-
263
- # React with 👀 so the team knows Sentinel noticed it
264
- if ts:
265
- try:
266
- await client.reactions_add(channel=channel, timestamp=ts, name="eyes")
267
- except Exception:
268
- pass # reactions:write scope may not be granted — non-fatal
269
-
270
-
271
- def _read_project_name_from_dir(project_dir: _Path) -> str:
272
- """Read PROJECT_NAME from a project's sentinel.properties, or return dir name."""
273
- props = project_dir / "config" / "sentinel.properties"
274
- if not props.exists():
275
- return project_dir.name
276
- try:
277
- for line in props.read_text(encoding="utf-8").splitlines():
278
- line = line.strip()
279
- if line.upper().startswith("PROJECT_NAME="):
280
- return line.split("=", 1)[1].partition("#")[0].strip()
281
- except OSError:
282
- pass
283
- return project_dir.name
284
-
285
-
286
- # ── Dispatcher ────────────────────────────────────────────────────────────────
287
-
288
- async def _dispatch(event: dict, client, cfg_loader, store) -> None:
289
- user_id = event.get("user", "")
290
- channel = event.get("channel", "")
291
- text = _strip_mention(event.get("text", "")).strip()
292
-
293
- if not text:
294
- text = "hello"
295
-
296
- # Allowlist check — if SLACK_ALLOWED_USERS is configured, silently ignore everyone else
297
- allowed = cfg_loader.sentinel.slack_allowed_users
298
- if allowed and user_id not in allowed:
299
- logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
300
- return
301
-
302
- user_name = await _resolve_name(client, user_id)
303
-
304
- status, pos, session = await _queue.try_activate(user_id, user_name, channel)
305
-
306
- if status == "queued":
307
- await _queue.update_waiting_message(user_id, text)
308
- active = _queue.active_user_name() or "someone"
309
- await _post(client, channel,
310
- f"I'm working with *{active}* right now. "
311
- f"You're *#{pos}* in the queue — I'll come to you when done. :hourglass_flowing_sand:"
312
- )
313
- return
314
-
315
- # Active or continuing — process the turn
316
- await _run_turn(session, text, client, cfg_loader, store)
317
-
318
-
319
- # ── Turn processor ────────────────────────────────────────────────────────────
320
-
321
- _MAX_HISTORY_TURNS = 20 # keep last 20 exchanges (~40 messages) to stay well within context limits
322
-
323
- async def _run_turn(session: _Session, message: str, client, cfg_loader, store) -> None:
324
- channel = session.channel
325
-
326
- # Trim history to avoid context overflow on long conversations
327
- if len(session.history) > _MAX_HISTORY_TURNS * 2:
328
- session.history = session.history[-(_MAX_HISTORY_TURNS * 2):]
329
-
330
- # Typing indicator
331
- await _post(client, channel, "_thinking..._")
332
-
333
- reply = ""
334
- is_done = True
335
- try:
336
- reply, is_done = await handle_message(
337
- message, session.history, cfg_loader, store,
338
- slack_client=client,
339
- user_name=session.user_name,
340
- )
341
- except Exception as e:
342
- logger.exception("Sentinel Boss error: %s", e)
343
- reply = f":warning: Unhandled error: {e}"
344
-
345
- await _post(client, channel, reply)
346
-
347
- if is_done:
348
- next_item = await _queue.complete()
349
- if next_item:
350
- next_session, first_msg = next_item
351
- await _post(
352
- client, channel,
353
- f":white_check_mark: Done with *{session.user_name}*. "
354
- f"<@{next_session.user_id}> you're up!"
355
- )
356
- if first_msg:
357
- # Process their first message immediately
358
- await _run_turn(next_session, first_msg, client, cfg_loader, store)
359
-
360
-
361
- # ── Helpers ───────────────────────────────────────────────────────────────────
362
-
363
- async def _post(client, channel: str, text: str) -> None:
364
- if not text:
365
- return
366
- try:
367
- await client.chat_postMessage(channel=channel, text=text)
368
- except Exception as e:
369
- logger.warning("Slack post failed: %s", e)
370
-
371
-
372
- async def _resolve_name(client, user_id: str) -> str:
373
- try:
374
- info = await client.users_info(user=user_id)
375
- profile = info["user"]["profile"]
376
- return profile.get("display_name") or profile.get("real_name") or user_id
377
- except Exception:
378
- return user_id
379
-
380
-
381
- def _strip_mention(text: str) -> str:
382
- """Remove leading <@BOTID> mention from message text."""
383
- import re
384
- return re.sub(r"^<@[A-Z0-9]+>\s*", "", text)
1
+ """
2
+ slack_bot.py — Slack integration for Sentinel Boss.
3
+
4
+ Each user gets their own independent session no global queue.
5
+ Multiple team members can chat with Sentinel at the same time.
6
+
7
+ Setup (api.slack.com):
8
+ 1. Create a Slack App → Enable Socket Mode → copy App-Level Token (xapp-...)
9
+ 2. Add Bot Token Scopes: app_mentions:read, chat:write, im:history,
10
+ channels:history, users:read, files:read
11
+ 3. Enable Events: app_mention, message.im, message.channels
12
+ 4. Install to workspace copy Bot Token (xoxb-...)
13
+ 5. Add to sentinel.properties:
14
+ SLACK_BOT_TOKEN=xoxb-...
15
+ SLACK_APP_TOKEN=xapp-...
16
+ """
17
+
18
+ import asyncio
19
+ import base64
20
+ import logging
21
+ import mimetypes
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from .sentinel_boss import handle_message
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # ── Per-user sessions ────────────────────────────────────────────────────────
32
+ #
33
+ # Each user gets their own independent session — no global queue.
34
+ # Multiple team members can talk to Sentinel simultaneously.
35
+
36
+ @dataclass
37
+ class _Session:
38
+ user_id: str
39
+ user_name: str
40
+ channel: str
41
+ history: list = field(default_factory=list)
42
+ history_loaded: bool = False
43
+ busy: bool = False # True while a turn is being processed
44
+
45
+
46
+ _sessions: dict[str, _Session] = {}
47
+ _sessions_lock = asyncio.Lock()
48
+
49
+
50
+ async def _get_or_create_session(user_id: str, user_name: str, channel: str) -> _Session:
51
+ async with _sessions_lock:
52
+ if user_id not in _sessions:
53
+ _sessions[user_id] = _Session(user_id, user_name, channel)
54
+ return _sessions[user_id]
55
+
56
+
57
+ # ── Slack bot ─────────────────────────────────────────────────────────────────
58
+
59
+ async def run_slack_bot(cfg_loader, store) -> None:
60
+ """
61
+ Start Slack Bolt in Socket Mode as an asyncio background task.
62
+ Exits silently if tokens are missing or slack-bolt is not installed.
63
+ """
64
+ try:
65
+ from slack_bolt.async_app import AsyncApp
66
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
67
+ except ImportError:
68
+ logger.warning(
69
+ "slack-bolt not installed — Slack bot disabled. "
70
+ "Run: pip install slack-bolt"
71
+ )
72
+ return
73
+
74
+ cfg = cfg_loader.sentinel
75
+ if not cfg.slack_bot_token or not cfg.slack_app_token:
76
+ logger.info("SLACK_BOT_TOKEN / SLACK_APP_TOKEN not set — Slack bot disabled")
77
+ return
78
+
79
+ if cfg.slack_channel:
80
+ logger.info("Sentinel Boss restricted to channel: %s", cfg.slack_channel)
81
+
82
+ app = AsyncApp(token=cfg.slack_bot_token)
83
+
84
+ # ── Channel resolver — converts #name to ID lazily on first event ─────────
85
+ _allowed_id: list = [] # single-element list used as mutable cell
86
+
87
+ async def _resolve_allowed(client):
88
+ raw = cfg.slack_channel.lstrip("#")
89
+ if not raw:
90
+ return
91
+ # Already a Slack channel ID (e.g. C01AB2CD3EF)
92
+ if raw.upper().startswith("C") and len(raw) >= 9:
93
+ _allowed_id.append(raw.upper())
94
+ return
95
+ try:
96
+ resp = await client.conversations_list(
97
+ types="public_channel,private_channel", limit=1000
98
+ )
99
+ for ch in (resp.get("channels") or []):
100
+ if ch.get("name") == raw:
101
+ _allowed_id.append(ch["id"])
102
+ logger.info("Boss channel resolved: #%s → %s", raw, ch["id"])
103
+ return
104
+ logger.warning("SLACK_CHANNEL '%s' not found — responding everywhere", cfg.slack_channel)
105
+ except Exception as e:
106
+ logger.warning("Could not resolve SLACK_CHANNEL '%s': %s", cfg.slack_channel, e)
107
+
108
+ def _allowed(channel: str) -> bool:
109
+ return not _allowed_id or channel in _allowed_id
110
+
111
+ # ── Event handlers ────────────────────────────────────────────────────────
112
+
113
+ @app.event("app_mention")
114
+ async def on_mention(event, client):
115
+ if cfg.slack_channel and not _allowed_id:
116
+ await _resolve_allowed(client)
117
+ if _allowed(event.get("channel", "")):
118
+ await _dispatch(event, client, cfg_loader, store)
119
+
120
+ # ── Passive bot watcher — seed DB from config on startup ─────────────────
121
+ for bot_id_cfg in cfg.slack_watch_bot_ids:
122
+ if bot_id_cfg and not store.is_watched_bot(bot_id_cfg):
123
+ store.add_watched_bot(bot_id_cfg, bot_id_cfg, added_by="config")
124
+ logger.info("Seeded watched bot from config: %s", bot_id_cfg)
125
+
126
+ @app.event("message")
127
+ async def on_message(event, client):
128
+ # DMs from humans → Boss conversation
129
+ if event.get("channel_type") == "im" and not event.get("bot_id"):
130
+ await _dispatch(event, client, cfg_loader, store)
131
+ return
132
+
133
+ # Passive bot watcher — bot messages from DB-registered bots
134
+ bot_id = event.get("bot_id", "")
135
+ if bot_id and event.get("channel") and store.is_watched_bot(bot_id):
136
+ await _handle_bot_message(event, client, cfg_loader, store)
137
+
138
+ # ── Start ─────────────────────────────────────────────────────────────────
139
+
140
+ handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
141
+ logger.info("Sentinel Boss connected to Slack (Socket Mode)")
142
+ watched = store.get_watched_bots()
143
+ if watched:
144
+ logger.info("Sentinel passively watching %d bot(s): %s",
145
+ len(watched), [b["bot_name"] for b in watched])
146
+ await handler.start_async()
147
+
148
+
149
+ # ── Bot-message watcher ───────────────────────────────────────────────────────
150
+
151
+ import uuid as _uuid
152
+ from pathlib import Path as _Path
153
+
154
+
155
+ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
156
+ """
157
+ Called when a watched bot posts a message.
158
+ Queues the message as an issue in the bot's registered project directory.
159
+ All messages from watched bots are queued — no error pre-filtering.
160
+ """
161
+ bot_id = event.get("bot_id", "")
162
+ channel = event.get("channel", "")
163
+ ts = event.get("ts", "")
164
+
165
+ # Avoid duplicate issues
166
+ dedup_key = f"slack-watch:{channel}:{ts}"
167
+ if store.is_seen_dedup(dedup_key):
168
+ return
169
+ store.mark_seen_dedup(dedup_key)
170
+
171
+ # Flatten text (handle block-kit attachments too)
172
+ text = event.get("text", "")
173
+ for att in event.get("attachments", []):
174
+ text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
175
+ text = text.strip()
176
+ if not text:
177
+ return
178
+
179
+ # Find the project this bot is registered to
180
+ bots = store.get_watched_bots()
181
+ bot_info = next((b for b in bots if b["bot_id"] == bot_id), None)
182
+ project_name = (bot_info or {}).get("project_name") or ""
183
+
184
+ # Resolve the project issues directory
185
+ workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
186
+ if project_name:
187
+ # workspace_dir is <sentinel_root>/workspace, project dirs are siblings
188
+ project_dirs = [
189
+ d for d in workspace.iterdir()
190
+ if d.is_dir() and d.name != "workspace"
191
+ and (d / "config" / "sentinel.properties").exists()
192
+ ]
193
+ matched = next(
194
+ (d for d in project_dirs
195
+ if d.name == project_name or
196
+ _read_project_name_from_dir(d) == project_name),
197
+ None,
198
+ )
199
+ issues_dir = (matched / "issues") if matched else (_Path(".") / "issues")
200
+ else:
201
+ issues_dir = _Path(".") / "issues"
202
+
203
+ issues_dir.mkdir(parents=True, exist_ok=True)
204
+
205
+ uid = _uuid.uuid4().hex[:8]
206
+ fname = f"bot-{project_name or 'unknown'}-{uid}.txt"
207
+ content = (
208
+ f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
209
+ f"SLACK_TS: {ts}\n\n"
210
+ f"{text}"
211
+ )
212
+ (issues_dir / fname).write_text(content, encoding="utf-8")
213
+ _Path("SENTINEL_POLL_NOW").touch()
214
+ logger.info("Bot watcher queued issue from %s → %s/%s", bot_id, issues_dir, fname)
215
+
216
+ # React with 👀 so the team knows Sentinel noticed it
217
+ if ts:
218
+ try:
219
+ await client.reactions_add(channel=channel, timestamp=ts, name="eyes")
220
+ except Exception:
221
+ pass # reactions:write scope may not be granted non-fatal
222
+
223
+
224
+ def _read_project_name_from_dir(project_dir: _Path) -> str:
225
+ """Read PROJECT_NAME from a project's sentinel.properties, or return dir name."""
226
+ props = project_dir / "config" / "sentinel.properties"
227
+ if not props.exists():
228
+ return project_dir.name
229
+ try:
230
+ for line in props.read_text(encoding="utf-8").splitlines():
231
+ line = line.strip()
232
+ if line.upper().startswith("PROJECT_NAME="):
233
+ return line.split("=", 1)[1].partition("#")[0].strip()
234
+ except OSError:
235
+ pass
236
+ return project_dir.name
237
+
238
+
239
+ # ── File-attachment handling ─────────────────────────────────────────────────
240
+
241
+ _TEXT_MIMETYPES = {
242
+ "text/plain", "text/csv", "text/html", "application/json",
243
+ "application/xml", "text/xml", "text/markdown",
244
+ }
245
+ _TEXT_EXTENSIONS = {
246
+ ".txt", ".log", ".json", ".csv", ".md", ".yaml", ".yml",
247
+ ".properties", ".sh", ".py", ".java", ".kt", ".js", ".ts",
248
+ }
249
+ _IMAGE_MIMETYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
250
+ _MAX_TEXT_BYTES = 8_000 # max chars to inline per text file
251
+
252
+
253
+ async def _fetch_attachments(event: dict, client, workspace_dir: str) -> list[dict]:
254
+ """
255
+ Download files attached to a Slack message and classify them.
256
+
257
+ Returns list of dicts:
258
+ type : "text" | "image" | "file"
259
+ name : original filename
260
+ content : text (for text), base64 bytes (for image), "" (for other)
261
+ path : saved local path
262
+ mime : mimetype string
263
+ """
264
+ files = event.get("files", [])
265
+ if not files:
266
+ return []
267
+
268
+ import aiohttp
269
+
270
+ attach_dir = Path(workspace_dir) / "attachments"
271
+ attach_dir.mkdir(parents=True, exist_ok=True)
272
+ token = getattr(client, "token", "")
273
+
274
+ results = []
275
+ async with aiohttp.ClientSession() as http:
276
+ for file_info in files[:5]: # cap at 5 attachments per message
277
+ url = file_info.get("url_private_download") or file_info.get("url_private", "")
278
+ name = file_info.get("name") or file_info.get("title") or "attachment"
279
+ mime = file_info.get("mimetype", "")
280
+ if not url:
281
+ continue
282
+ try:
283
+ async with http.get(url, headers={"Authorization": f"Bearer {token}"}) as resp:
284
+ if resp.status != 200:
285
+ logger.warning("Boss: cannot download %s (HTTP %d)", name, resp.status)
286
+ continue
287
+ data = await resp.read()
288
+ except Exception as exc:
289
+ logger.warning("Boss: download error for %s: %s", name, exc)
290
+ continue
291
+
292
+ ext = Path(name).suffix.lower()
293
+ dest = attach_dir / name
294
+
295
+ if mime in _IMAGE_MIMETYPES or ext in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
296
+ dest.write_bytes(data)
297
+ results.append({
298
+ "type": "image",
299
+ "name": name,
300
+ "mime": mime or mimetypes.guess_type(name)[0] or "image/png",
301
+ "content": base64.b64encode(data).decode(),
302
+ "path": str(dest),
303
+ })
304
+ elif mime in _TEXT_MIMETYPES or ext in _TEXT_EXTENSIONS:
305
+ text = data.decode("utf-8", errors="replace")[:_MAX_TEXT_BYTES]
306
+ dest.write_text(text, encoding="utf-8")
307
+ results.append({
308
+ "type": "text",
309
+ "name": name,
310
+ "mime": mime,
311
+ "content": text,
312
+ "path": str(dest),
313
+ })
314
+ else:
315
+ dest.write_bytes(data)
316
+ results.append({
317
+ "type": "file",
318
+ "name": name,
319
+ "mime": mime,
320
+ "content": "",
321
+ "path": str(dest),
322
+ })
323
+ logger.info("Boss: downloaded attachment %s (%s, %d bytes)", name, mime, len(data))
324
+
325
+ return results
326
+
327
+
328
+ # ── Dispatcher ────────────────────────────────────────────────────────────────
329
+
330
+ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
331
+ user_id = event.get("user", "")
332
+ channel = event.get("channel", "")
333
+ text = _strip_mention(event.get("text", "")).strip()
334
+
335
+ if not text:
336
+ text = "hello"
337
+
338
+ # Allowlist check — if SLACK_ALLOWED_USERS is configured, silently ignore everyone else
339
+ allowed = cfg_loader.sentinel.slack_allowed_users
340
+ if allowed and user_id not in allowed:
341
+ logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
342
+ return
343
+
344
+ user_name = await _resolve_name(client, user_id)
345
+ session = await _get_or_create_session(user_id, user_name, channel)
346
+
347
+ if session.busy:
348
+ # Still processing a previous turn from this user — drop the duplicate
349
+ logger.info("Boss: %s sent a message while still processing, ignoring", user_id)
350
+ await _post(client, channel, "_Still thinking — one moment..._")
351
+ return
352
+
353
+ # Fetch any attached files (screenshots, logs, docs)
354
+ attachments: list[dict] = []
355
+ if event.get("files"):
356
+ attachments = await _fetch_attachments(event, client, cfg_loader.sentinel.workspace_dir)
357
+ if attachments:
358
+ logger.info("Boss: %d attachment(s) from %s", len(attachments), user_id)
359
+
360
+ await _run_turn(session, text, client, cfg_loader, store, attachments=attachments)
361
+
362
+
363
+ # ── Turn processor ────────────────────────────────────────────────────────────
364
+
365
+ _MAX_HISTORY_TURNS = 20 # keep last 20 exchanges (~40 messages) to stay well within context limits
366
+
367
+ async def _run_turn(session: _Session, message: str, client, cfg_loader, store, attachments: list | None = None) -> None:
368
+ channel = session.channel
369
+
370
+ # Load persisted history from DB on the first turn of a new session
371
+ if not session.history_loaded:
372
+ session.history = store.load_conversation(session.user_id)
373
+ session.history_loaded = True
374
+
375
+ # Trim history to avoid context overflow on long conversations
376
+ if len(session.history) > _MAX_HISTORY_TURNS * 2:
377
+ session.history = session.history[-(_MAX_HISTORY_TURNS * 2):]
378
+
379
+ # Typing indicator
380
+ session.busy = True
381
+ await _post(client, channel, "_thinking..._")
382
+
383
+ reply = ""
384
+ is_done = True
385
+ try:
386
+ reply, is_done = await handle_message(
387
+ message, session.history, cfg_loader, store,
388
+ slack_client=client,
389
+ user_name=session.user_name,
390
+ user_id=session.user_id,
391
+ attachments=attachments or [],
392
+ )
393
+ except Exception as e:
394
+ logger.exception("Sentinel Boss error: %s", e)
395
+ reply = f":warning: Unhandled error: {e}"
396
+ finally:
397
+ session.busy = False
398
+
399
+ await _post(client, channel, reply)
400
+ # If session ended, save current history; if history was just cleared it will already be [] in DB
401
+ store.save_conversation(session.user_id, session.history)
402
+
403
+
404
+ # ── Helpers ───────────────────────────────────────────────────────────────────
405
+
406
+ async def _post(client, channel: str, text: str) -> None:
407
+ if not text:
408
+ return
409
+ try:
410
+ await client.chat_postMessage(channel=channel, text=text)
411
+ except Exception as e:
412
+ logger.warning("Slack post failed: %s", e)
413
+
414
+
415
+ async def _resolve_name(client, user_id: str) -> str:
416
+ try:
417
+ info = await client.users_info(user=user_id)
418
+ profile = info["user"]["profile"]
419
+ return profile.get("display_name") or profile.get("real_name") or user_id
420
+ except Exception:
421
+ return user_id
422
+
423
+
424
+ def _strip_mention(text: str) -> str:
425
+ """Remove leading <@BOTID> mention from message text."""
426
+ import re
427
+ return re.sub(r"^<@[A-Z0-9]+>\s*", "", text)