@misterhuydo/sentinel 1.0.52 → 1.0.53
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/session.json +2 -2
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +1 -3
- package/python/sentinel/issue_watcher.py +9 -1
- package/python/sentinel/sentinel_boss.py +163 -7
- package/python/sentinel/slack_bot.py +66 -73
- package/python/sentinel/state_store.py +44 -4
- package/templates/sentinel.properties +5 -8
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-22T11:21:17.183Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-22T11:32:35.191Z",
|
|
3
|
+
"checkpoint_at": "2026-03-22T11:32:35.192Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -60,8 +60,7 @@ class SentinelConfig:
|
|
|
60
60
|
slack_bot_token: str = "" # xoxb-...
|
|
61
61
|
slack_app_token: str = "" # xapp-... (Socket Mode)
|
|
62
62
|
slack_channel: str = "" # optional: restrict to one channel ID or name
|
|
63
|
-
|
|
64
|
-
slack_watch_bot_ids: list[str] = field(default_factory=list) # optional: only watch these bot IDs
|
|
63
|
+
slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
|
|
65
64
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
66
65
|
|
|
67
66
|
|
|
@@ -155,7 +154,6 @@ class ConfigLoader:
|
|
|
155
154
|
c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
|
|
156
155
|
c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
|
|
157
156
|
c.slack_channel = d.get("SLACK_CHANNEL", "")
|
|
158
|
-
c.slack_watch_channels = _csv(d.get("SLACK_WATCH_CHANNELS", ""))
|
|
159
157
|
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
160
158
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
161
159
|
self.sentinel = c
|
|
@@ -89,8 +89,16 @@ def scan_issues(project_dir: Path) -> list[IssueEvent]:
|
|
|
89
89
|
issues_dir = project_dir / "issues"
|
|
90
90
|
issues_dir.mkdir(exist_ok=True)
|
|
91
91
|
|
|
92
|
+
def _priority(p: Path) -> tuple:
|
|
93
|
+
# slack-* (human) first, bot-* (bot) last, everything else in between
|
|
94
|
+
if p.name.startswith("slack-"):
|
|
95
|
+
return (0, p.name)
|
|
96
|
+
if p.name.startswith("bot-"):
|
|
97
|
+
return (2, p.name)
|
|
98
|
+
return (1, p.name)
|
|
99
|
+
|
|
92
100
|
events = []
|
|
93
|
-
for f in sorted(issues_dir.iterdir()):
|
|
101
|
+
for f in sorted(issues_dir.iterdir(), key=_priority):
|
|
94
102
|
if not f.is_file() or f.name.startswith("."):
|
|
95
103
|
continue
|
|
96
104
|
if f.suffix.lower() in _BINARY_EXTENSIONS:
|
|
@@ -83,6 +83,18 @@ What you can do (tools available):
|
|
|
83
83
|
e.g. "fetch logs", "try fetch_log.sh for SSOLWA", "fetch logs with debug",
|
|
84
84
|
"grab latest logs from STS", "fetch logs without filter"
|
|
85
85
|
|
|
86
|
+
15. watch_bot — Register a Slack bot for passive monitoring. Every message it posts is
|
|
87
|
+
auto-queued as an issue in the bot's registered project.
|
|
88
|
+
ALWAYS requires a project — infer from context or ask the user first.
|
|
89
|
+
e.g. "listen to @alertbot", "watch @bot1 @bot2 for project 1881", "monitor @errorbot"
|
|
90
|
+
|
|
91
|
+
16. unwatch_bot — Remove a Slack bot from the passive watch list.
|
|
92
|
+
e.g. "stop watching @alertbot", "unwatch @errorbot"
|
|
93
|
+
|
|
94
|
+
17. list_watched_bots — Show all Slack bots currently being passively monitored and which projects
|
|
95
|
+
they are delivering to.
|
|
96
|
+
e.g. "which bots are you watching?", "list monitored bots"
|
|
97
|
+
|
|
86
98
|
When someone asks what you can do, what you support, what your capabilities are, or how you can help,
|
|
87
99
|
reply with a short summary grouped by category:
|
|
88
100
|
|
|
@@ -109,6 +121,11 @@ reply with a short summary grouped by category:
|
|
|
109
121
|
• `pull_repo` — git pull on managed application repos — "pull latest code"
|
|
110
122
|
• `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
|
|
111
123
|
|
|
124
|
+
*Slack bot watching*
|
|
125
|
+
• `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues — "listen to @alertbot"
|
|
126
|
+
• `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
|
|
127
|
+
• `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
|
|
128
|
+
|
|
112
129
|
Tone: direct, professional, like a senior engineer who owns the system.
|
|
113
130
|
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
114
131
|
If you don't know something, use a tool to find out before saying you don't know.
|
|
@@ -338,6 +355,60 @@ _TOOLS = [
|
|
|
338
355
|
},
|
|
339
356
|
},
|
|
340
357
|
},
|
|
358
|
+
{
|
|
359
|
+
"name": "watch_bot",
|
|
360
|
+
"description": (
|
|
361
|
+
"Tell Sentinel to passively monitor a Slack bot — queuing its messages as issues. "
|
|
362
|
+
"Extract all <@UXXXXXX> user IDs from the message and pass them here. "
|
|
363
|
+
"Sentinel verifies each is actually a bot (not a human) before adding to the watch list. "
|
|
364
|
+
"IMPORTANT: a bot watcher is only useful if its issues can be delivered to a project. "
|
|
365
|
+
"Try to infer the project from context (bot name, prior messages, available projects). "
|
|
366
|
+
"If it cannot be determined, do NOT call this tool — instead ask the user which project "
|
|
367
|
+
"the bot's alerts belong to, then call this tool with the project filled in. "
|
|
368
|
+
"Use for: 'listen to @alertbot', 'watch @bot1 @bot2', 'monitor @errorbot'."
|
|
369
|
+
),
|
|
370
|
+
"input_schema": {
|
|
371
|
+
"type": "object",
|
|
372
|
+
"properties": {
|
|
373
|
+
"user_ids": {
|
|
374
|
+
"type": "array",
|
|
375
|
+
"items": {"type": "string"},
|
|
376
|
+
"description": "Slack user IDs to watch — extract from <@UXXXXXX> patterns in the message",
|
|
377
|
+
},
|
|
378
|
+
"project": {
|
|
379
|
+
"type": "string",
|
|
380
|
+
"description": "Project short name this bot's issues should be routed to (e.g. '1881', 'elprint'). Infer from context or ask user before calling.",
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
"required": ["user_ids"],
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"name": "unwatch_bot",
|
|
388
|
+
"description": (
|
|
389
|
+
"Stop Sentinel from monitoring a Slack bot. "
|
|
390
|
+
"Use for: 'stop watching @alertbot', 'unwatch @bot', 'remove @errorbot from watchers'."
|
|
391
|
+
),
|
|
392
|
+
"input_schema": {
|
|
393
|
+
"type": "object",
|
|
394
|
+
"properties": {
|
|
395
|
+
"user_ids": {
|
|
396
|
+
"type": "array",
|
|
397
|
+
"items": {"type": "string"},
|
|
398
|
+
"description": "Slack user IDs to remove from the watch list",
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
"required": ["user_ids"],
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
"name": "list_watched_bots",
|
|
406
|
+
"description": (
|
|
407
|
+
"List all Slack bots Sentinel is currently monitoring passively. "
|
|
408
|
+
"Use for: 'who are you watching?', 'which bots are you monitoring?', 'list watched bots'."
|
|
409
|
+
),
|
|
410
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
411
|
+
},
|
|
341
412
|
]
|
|
342
413
|
|
|
343
414
|
|
|
@@ -404,7 +475,7 @@ def _git_pull(path: Path) -> dict:
|
|
|
404
475
|
|
|
405
476
|
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
406
477
|
|
|
407
|
-
def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
|
|
478
|
+
async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None) -> str:
|
|
408
479
|
if name == "get_status":
|
|
409
480
|
hours = int(inputs.get("hours", 24))
|
|
410
481
|
errors = store.get_recent_errors(hours)
|
|
@@ -700,6 +771,89 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
|
|
|
700
771
|
|
|
701
772
|
return json.dumps({"fetched": len(results), "results": results})
|
|
702
773
|
|
|
774
|
+
if name == "watch_bot":
|
|
775
|
+
user_ids = inputs.get("user_ids", [])
|
|
776
|
+
project_arg = inputs.get("project", "").strip()
|
|
777
|
+
if not user_ids:
|
|
778
|
+
return json.dumps({"error": "No user_ids provided"})
|
|
779
|
+
|
|
780
|
+
# Resolve + validate project — required for bot issue routing
|
|
781
|
+
resolved_project = ""
|
|
782
|
+
if project_arg:
|
|
783
|
+
project_dirs = _find_project_dirs(project_arg)
|
|
784
|
+
if not project_dirs:
|
|
785
|
+
all_names = [_read_project_name(d) for d in _find_project_dirs()]
|
|
786
|
+
return json.dumps({
|
|
787
|
+
"error": f"No project found matching '{project_arg}'",
|
|
788
|
+
"available_projects": all_names,
|
|
789
|
+
"action_needed": "Ask the user which project these bot alerts belong to.",
|
|
790
|
+
})
|
|
791
|
+
if len(project_dirs) > 1:
|
|
792
|
+
matches = [_read_project_name(d) for d in project_dirs]
|
|
793
|
+
return json.dumps({
|
|
794
|
+
"error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
|
|
795
|
+
"action_needed": "Ask the user to clarify which project.",
|
|
796
|
+
})
|
|
797
|
+
resolved_project = _read_project_name(project_dirs[0])
|
|
798
|
+
else:
|
|
799
|
+
all_projects = _find_project_dirs()
|
|
800
|
+
if len(all_projects) == 1:
|
|
801
|
+
# Single project in workspace — auto-assign
|
|
802
|
+
resolved_project = _read_project_name(all_projects[0])
|
|
803
|
+
elif all_projects:
|
|
804
|
+
all_names = [_read_project_name(d) for d in all_projects]
|
|
805
|
+
return json.dumps({
|
|
806
|
+
"error": "Cannot determine which project these bot alerts belong to.",
|
|
807
|
+
"available_projects": all_names,
|
|
808
|
+
"action_needed": "Ask the user to specify the project, then retry with project filled in.",
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
results = []
|
|
812
|
+
for uid in user_ids:
|
|
813
|
+
if not slack_client:
|
|
814
|
+
results.append({"user_id": uid, "status": "error", "reason": "no Slack client available"})
|
|
815
|
+
continue
|
|
816
|
+
try:
|
|
817
|
+
info = await slack_client.users_info(user=uid)
|
|
818
|
+
user = info.get("user", {})
|
|
819
|
+
if not user.get("is_bot", False):
|
|
820
|
+
results.append({"user_id": uid, "status": "skipped", "reason": "not a bot — only bots can be watched passively"})
|
|
821
|
+
continue
|
|
822
|
+
bot_name = user.get("real_name") or user.get("name") or uid
|
|
823
|
+
store.add_watched_bot(uid, bot_name, added_by="boss", project_name=resolved_project)
|
|
824
|
+
logger.info("Boss: now watching bot %s (%s) → project '%s'", bot_name, uid, resolved_project or "unset")
|
|
825
|
+
results.append({"user_id": uid, "bot_name": bot_name, "project": resolved_project, "status": "watching"})
|
|
826
|
+
except Exception as e:
|
|
827
|
+
results.append({"user_id": uid, "status": "error", "reason": str(e)})
|
|
828
|
+
return json.dumps({"results": results})
|
|
829
|
+
|
|
830
|
+
if name == "unwatch_bot":
|
|
831
|
+
user_ids = inputs.get("user_ids", [])
|
|
832
|
+
if not user_ids:
|
|
833
|
+
return json.dumps({"error": "No user_ids provided"})
|
|
834
|
+
results = []
|
|
835
|
+
for uid in user_ids:
|
|
836
|
+
removed = store.remove_watched_bot(uid)
|
|
837
|
+
logger.info("Boss: unwatch bot %s → %s", uid, "removed" if removed else "not found")
|
|
838
|
+
results.append({"user_id": uid, "status": "removed" if removed else "not found"})
|
|
839
|
+
return json.dumps({"results": results})
|
|
840
|
+
|
|
841
|
+
if name == "list_watched_bots":
|
|
842
|
+
bots = store.get_watched_bots()
|
|
843
|
+
return json.dumps({
|
|
844
|
+
"count": len(bots),
|
|
845
|
+
"bots": [
|
|
846
|
+
{
|
|
847
|
+
"bot_id": b["bot_id"],
|
|
848
|
+
"bot_name": b["bot_name"],
|
|
849
|
+
"project": b.get("project_name") or "",
|
|
850
|
+
"added_by": b["added_by"],
|
|
851
|
+
"added_at": b["added_at"],
|
|
852
|
+
}
|
|
853
|
+
for b in bots
|
|
854
|
+
],
|
|
855
|
+
})
|
|
856
|
+
|
|
703
857
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
704
858
|
|
|
705
859
|
|
|
@@ -713,10 +867,11 @@ async def _handle_with_cli(
|
|
|
713
867
|
history: list,
|
|
714
868
|
cfg_loader,
|
|
715
869
|
store,
|
|
870
|
+
slack_client=None,
|
|
716
871
|
) -> tuple[str, bool]:
|
|
717
872
|
"""Fallback: use `claude --print` for users without an Anthropic API key."""
|
|
718
|
-
status_json = _run_tool("get_status", {"hours": 24}, cfg_loader, store)
|
|
719
|
-
prs_json = _run_tool("list_pending_prs", {}, cfg_loader, store)
|
|
873
|
+
status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
|
|
874
|
+
prs_json = await _run_tool("list_pending_prs", {}, cfg_loader, store)
|
|
720
875
|
|
|
721
876
|
# Pre-fetch log search if the message is a search request.
|
|
722
877
|
# Use quoted strings as the query, or fall back to the full message.
|
|
@@ -726,7 +881,7 @@ async def _handle_with_cli(
|
|
|
726
881
|
if any(kw in message.lower() for kw in _search_kws):
|
|
727
882
|
quoted = re.findall(r'"([^"]+)"', message)
|
|
728
883
|
query = quoted[0] if quoted else message
|
|
729
|
-
search_json = _run_tool("search_logs", {"query": query}, cfg_loader, store)
|
|
884
|
+
search_json = await _run_tool("search_logs", {"query": query}, cfg_loader, store)
|
|
730
885
|
|
|
731
886
|
paused = Path("SENTINEL_PAUSE").exists()
|
|
732
887
|
repos = list(cfg_loader.repos.keys())
|
|
@@ -784,7 +939,7 @@ async def _handle_with_cli(
|
|
|
784
939
|
action = json.loads(m.group(1))
|
|
785
940
|
name = action.pop("action", "")
|
|
786
941
|
if name:
|
|
787
|
-
result_str = _run_tool(name, action, cfg_loader, store)
|
|
942
|
+
result_str = await _run_tool(name, action, cfg_loader, store)
|
|
788
943
|
logger.info("Boss CLI action: %s → %s", name, result_str[:80])
|
|
789
944
|
except Exception as e:
|
|
790
945
|
logger.warning("Boss action parse error: %s", e)
|
|
@@ -805,6 +960,7 @@ async def handle_message(
|
|
|
805
960
|
history: list,
|
|
806
961
|
cfg_loader,
|
|
807
962
|
store,
|
|
963
|
+
slack_client=None,
|
|
808
964
|
) -> tuple[str, bool]:
|
|
809
965
|
"""
|
|
810
966
|
Process one user message through the Sentinel Boss (Claude with tool use).
|
|
@@ -830,7 +986,7 @@ async def handle_message(
|
|
|
830
986
|
|
|
831
987
|
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
832
988
|
if not api_key:
|
|
833
|
-
return await _handle_with_cli(message, history, cfg_loader, store)
|
|
989
|
+
return await _handle_with_cli(message, history, cfg_loader, store, slack_client=slack_client)
|
|
834
990
|
|
|
835
991
|
client = anthropic.Anthropic(api_key=api_key)
|
|
836
992
|
|
|
@@ -880,7 +1036,7 @@ async def handle_message(
|
|
|
880
1036
|
messages.append({"role": "assistant", "content": response.content})
|
|
881
1037
|
tool_results = []
|
|
882
1038
|
for tc in tool_blocks:
|
|
883
|
-
result = _run_tool(tc.name, tc.input, cfg_loader, store)
|
|
1039
|
+
result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client)
|
|
884
1040
|
logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
|
|
885
1041
|
tool_results.append({
|
|
886
1042
|
"type": "tool_result",
|
|
@@ -164,38 +164,11 @@ async def run_slack_bot(cfg_loader, store) -> None:
|
|
|
164
164
|
if _allowed(event.get("channel", "")):
|
|
165
165
|
await _dispatch(event, client, cfg_loader, store)
|
|
166
166
|
|
|
167
|
-
# ──
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
async def _resolve_watch_channels(client):
|
|
173
|
-
if _watch_resolved[0]:
|
|
174
|
-
return
|
|
175
|
-
_watch_resolved[0] = True
|
|
176
|
-
if not cfg.slack_watch_channels:
|
|
177
|
-
return
|
|
178
|
-
try:
|
|
179
|
-
resp = await client.conversations_list(
|
|
180
|
-
types="public_channel,private_channel", limit=1000
|
|
181
|
-
)
|
|
182
|
-
name_to_id = {ch["name"]: ch["id"] for ch in (resp.get("channels") or [])}
|
|
183
|
-
except Exception as e:
|
|
184
|
-
logger.warning("Could not list channels for watch resolution: %s", e)
|
|
185
|
-
name_to_id = {}
|
|
186
|
-
for raw in cfg.slack_watch_channels:
|
|
187
|
-
raw = raw.lstrip("#")
|
|
188
|
-
if raw.upper().startswith("C") and len(raw) >= 9:
|
|
189
|
-
_watch_ids.add(raw.upper())
|
|
190
|
-
logger.info("Sentinel watching channel (ID): %s", raw.upper())
|
|
191
|
-
elif raw in name_to_id:
|
|
192
|
-
_watch_ids.add(name_to_id[raw])
|
|
193
|
-
logger.info("Sentinel watching channel: #%s → %s", raw, name_to_id[raw])
|
|
194
|
-
else:
|
|
195
|
-
logger.warning("Watch channel '%s' not found — skipping", raw)
|
|
196
|
-
|
|
197
|
-
def _is_watch_channel(channel: str) -> bool:
|
|
198
|
-
return bool(_watch_ids) and channel in _watch_ids
|
|
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)
|
|
199
172
|
|
|
200
173
|
@app.event("message")
|
|
201
174
|
async def on_message(event, client):
|
|
@@ -204,76 +177,80 @@ async def run_slack_bot(cfg_loader, store) -> None:
|
|
|
204
177
|
await _dispatch(event, client, cfg_loader, store)
|
|
205
178
|
return
|
|
206
179
|
|
|
207
|
-
# Passive bot watcher — bot messages
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if _is_watch_channel(event["channel"]):
|
|
212
|
-
await _handle_bot_message(event, client, cfg_loader, store)
|
|
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)
|
|
213
184
|
|
|
214
185
|
# ── Start ─────────────────────────────────────────────────────────────────
|
|
215
186
|
|
|
216
187
|
handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
|
|
217
188
|
logger.info("Sentinel Boss connected to Slack (Socket Mode)")
|
|
218
|
-
|
|
219
|
-
|
|
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])
|
|
220
193
|
await handler.start_async()
|
|
221
194
|
|
|
222
195
|
|
|
223
196
|
# ── Bot-message watcher ───────────────────────────────────────────────────────
|
|
224
197
|
|
|
225
|
-
import re as _re
|
|
226
198
|
import uuid as _uuid
|
|
227
199
|
from pathlib import Path as _Path
|
|
228
200
|
|
|
229
|
-
# Patterns that indicate an error report worth queuing
|
|
230
|
-
_ERROR_RE = _re.compile(
|
|
231
|
-
r"(?:exception|error|failed|failure|fatal|traceback|stacktrace"
|
|
232
|
-
r"|null\s*pointer|out\s*of\s*memory|stack\s*overflow"
|
|
233
|
-
r"|5\d\d\b.*(?:error|failed)" # HTTP 5xx
|
|
234
|
-
r"|WARN|ERROR|FATAL|CRITICAL)",
|
|
235
|
-
_re.IGNORECASE,
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _looks_like_error(text: str) -> bool:
|
|
240
|
-
"""Return True if the bot message appears to be an error report."""
|
|
241
|
-
return bool(_ERROR_RE.search(text))
|
|
242
|
-
|
|
243
201
|
|
|
244
202
|
async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
245
203
|
"""
|
|
246
|
-
Called when a bot posts
|
|
247
|
-
|
|
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.
|
|
248
207
|
"""
|
|
249
|
-
cfg = cfg_loader.sentinel
|
|
250
208
|
bot_id = event.get("bot_id", "")
|
|
251
209
|
channel = event.get("channel", "")
|
|
252
210
|
ts = event.get("ts", "")
|
|
253
211
|
|
|
254
|
-
#
|
|
255
|
-
|
|
212
|
+
# Avoid duplicate issues
|
|
213
|
+
dedup_key = f"slack-watch:{channel}:{ts}"
|
|
214
|
+
if store.is_seen_dedup(dedup_key):
|
|
256
215
|
return
|
|
216
|
+
store.mark_seen_dedup(dedup_key)
|
|
257
217
|
|
|
258
218
|
# Flatten text (handle block-kit attachments too)
|
|
259
219
|
text = event.get("text", "")
|
|
260
220
|
for att in event.get("attachments", []):
|
|
261
221
|
text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
|
|
262
222
|
text = text.strip()
|
|
263
|
-
|
|
264
|
-
if not text or not _looks_like_error(text):
|
|
223
|
+
if not text:
|
|
265
224
|
return
|
|
266
225
|
|
|
267
|
-
#
|
|
268
|
-
|
|
269
|
-
if
|
|
270
|
-
|
|
271
|
-
|
|
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)
|
|
272
251
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
issues_dir.mkdir(exist_ok=True)
|
|
276
|
-
fname = f"slack-watch-{_uuid.uuid4().hex[:8]}.txt"
|
|
252
|
+
uid = _uuid.uuid4().hex[:8]
|
|
253
|
+
fname = f"bot-{project_name or 'unknown'}-{uid}.txt"
|
|
277
254
|
content = (
|
|
278
255
|
f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
|
|
279
256
|
f"SLACK_TS: {ts}\n\n"
|
|
@@ -281,7 +258,7 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
|
281
258
|
)
|
|
282
259
|
(issues_dir / fname).write_text(content, encoding="utf-8")
|
|
283
260
|
_Path("SENTINEL_POLL_NOW").touch()
|
|
284
|
-
logger.info("Bot watcher queued issue from %s
|
|
261
|
+
logger.info("Bot watcher queued issue from %s → %s/%s", bot_id, issues_dir, fname)
|
|
285
262
|
|
|
286
263
|
# React with 👀 so the team knows Sentinel noticed it
|
|
287
264
|
if ts:
|
|
@@ -291,6 +268,21 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
|
291
268
|
pass # reactions:write scope may not be granted — non-fatal
|
|
292
269
|
|
|
293
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
|
+
|
|
294
286
|
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
295
287
|
|
|
296
288
|
async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
@@ -328,7 +320,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
|
|
|
328
320
|
|
|
329
321
|
try:
|
|
330
322
|
reply, is_done = await handle_message(
|
|
331
|
-
message, session.history, cfg_loader, store
|
|
323
|
+
message, session.history, cfg_loader, store,
|
|
324
|
+
slack_client=client,
|
|
332
325
|
)
|
|
333
326
|
except Exception as e:
|
|
334
327
|
logger.exception("Sentinel Boss error: %s", e)
|
|
@@ -74,10 +74,11 @@ class StateStore:
|
|
|
74
74
|
def _migrate(self):
|
|
75
75
|
"""Add new columns to existing DBs without breaking old installs."""
|
|
76
76
|
migrations = [
|
|
77
|
-
("add_sentinel_marker",
|
|
78
|
-
("add_confirmed_at",
|
|
79
|
-
("add_fix_outcome",
|
|
80
|
-
("add_marker_seen_at",
|
|
77
|
+
("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
|
|
78
|
+
("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
|
|
79
|
+
("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
|
|
80
|
+
("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
|
|
81
|
+
("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
|
|
81
82
|
]
|
|
82
83
|
with self._conn() as conn:
|
|
83
84
|
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
@@ -299,3 +300,42 @@ class StateStore:
|
|
|
299
300
|
"INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
|
|
300
301
|
(key, _now()),
|
|
301
302
|
)
|
|
303
|
+
|
|
304
|
+
# ── Watched bots (Slack passive monitor) ─────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def _ensure_watched_bots_table(self, conn):
|
|
307
|
+
conn.execute(
|
|
308
|
+
"CREATE TABLE IF NOT EXISTS watched_bots "
|
|
309
|
+
"(bot_id TEXT PRIMARY KEY, bot_name TEXT, added_by TEXT, added_at TEXT)"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def add_watched_bot(self, bot_id: str, bot_name: str, added_by: str = "config", project_name: str = ""):
|
|
313
|
+
with self._conn() as conn:
|
|
314
|
+
self._ensure_watched_bots_table(conn)
|
|
315
|
+
conn.execute(
|
|
316
|
+
"INSERT OR REPLACE INTO watched_bots (bot_id, bot_name, added_by, added_at, project_name) "
|
|
317
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
318
|
+
(bot_id, bot_name, added_by, _now(), project_name or None),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def remove_watched_bot(self, bot_id: str) -> bool:
|
|
322
|
+
"""Returns True if a row was deleted."""
|
|
323
|
+
with self._conn() as conn:
|
|
324
|
+
self._ensure_watched_bots_table(conn)
|
|
325
|
+
cur = conn.execute("DELETE FROM watched_bots WHERE bot_id = ?", (bot_id,))
|
|
326
|
+
return cur.rowcount > 0
|
|
327
|
+
|
|
328
|
+
def is_watched_bot(self, bot_id: str) -> bool:
|
|
329
|
+
with self._conn() as conn:
|
|
330
|
+
self._ensure_watched_bots_table(conn)
|
|
331
|
+
return conn.execute(
|
|
332
|
+
"SELECT 1 FROM watched_bots WHERE bot_id = ?", (bot_id,)
|
|
333
|
+
).fetchone() is not None
|
|
334
|
+
|
|
335
|
+
def get_watched_bots(self) -> list[dict]:
|
|
336
|
+
with self._conn() as conn:
|
|
337
|
+
self._ensure_watched_bots_table(conn)
|
|
338
|
+
rows = conn.execute(
|
|
339
|
+
"SELECT * FROM watched_bots ORDER BY added_at"
|
|
340
|
+
).fetchall()
|
|
341
|
+
return [dict(r) for r in rows]
|
|
@@ -36,12 +36,9 @@ WORKSPACE_DIR=./workspace
|
|
|
36
36
|
# Note: requires conversations:read scope on the Slack App if using channel name
|
|
37
37
|
# SLACK_CHANNEL=devops-sentinel
|
|
38
38
|
|
|
39
|
-
# Passive bot watcher
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# Comma-separated list of
|
|
44
|
-
# SLACK_WATCH_CHANNELS=alerts-1881, errors-prod
|
|
45
|
-
# Optional: only watch messages from specific bot IDs (comma-separated Slack bot IDs).
|
|
46
|
-
# Omit to watch all bots in the channel.
|
|
39
|
+
# Passive bot watcher — seed the watch list on startup with known bot IDs.
|
|
40
|
+
# Sentinel will passively queue every message from these bots as issues (no @mention needed).
|
|
41
|
+
# You can also add bots at runtime: "@Sentinel listen to @alertbot for project 1881"
|
|
42
|
+
# Use "@Sentinel list watched bots" to see the current list.
|
|
43
|
+
# Comma-separated list of Slack bot IDs (starts with B, not U).
|
|
47
44
|
# SLACK_WATCH_BOT_IDS=B12345678, B87654321
|