@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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +6 -0
- package/.cairn/session.json +2 -2
- package/.cairn/views/2a85cc_init.js +2 -0
- package/lib/init.js +356 -319
- package/lib/upgrade.js +40 -0
- package/package.json +21 -21
- package/python/sentinel/sentinel_boss.py +1573 -1371
- package/python/sentinel/slack_bot.py +427 -384
- package/python/sentinel/state_store.py +423 -341
|
@@ -1,384 +1,427 @@
|
|
|
1
|
-
"""
|
|
2
|
-
slack_bot.py — Slack integration for Sentinel Boss.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import
|
|
20
|
-
import logging
|
|
21
|
-
|
|
22
|
-
from
|
|
23
|
-
|
|
24
|
-
from
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
await
|
|
346
|
-
|
|
347
|
-
if
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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)
|