@misterhuydo/sentinel 1.0.51 → 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 +2 -0
- package/python/sentinel/issue_watcher.py +9 -1
- package/python/sentinel/sentinel_boss.py +163 -7
- package/python/sentinel/slack_bot.py +109 -2
- package/python/sentinel/state_store.py +70 -4
- package/templates/sentinel.properties +8 -1
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,6 +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
|
+
slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
|
|
63
64
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
64
65
|
|
|
65
66
|
|
|
@@ -153,6 +154,7 @@ class ConfigLoader:
|
|
|
153
154
|
c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
|
|
154
155
|
c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
|
|
155
156
|
c.slack_channel = d.get("SLACK_CHANNEL", "")
|
|
157
|
+
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
156
158
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
157
159
|
self.sentinel = c
|
|
158
160
|
|
|
@@ -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,19 +164,125 @@ 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
|
+
# ── 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
|
+
|
|
167
173
|
@app.event("message")
|
|
168
174
|
async def on_message(event, client):
|
|
169
|
-
# DMs
|
|
175
|
+
# DMs from humans → Boss conversation
|
|
170
176
|
if event.get("channel_type") == "im" and not event.get("bot_id"):
|
|
171
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)
|
|
172
184
|
|
|
173
185
|
# ── Start ─────────────────────────────────────────────────────────────────
|
|
174
186
|
|
|
175
187
|
handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
|
|
176
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])
|
|
177
193
|
await handler.start_async()
|
|
178
194
|
|
|
179
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
|
+
|
|
180
286
|
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
181
287
|
|
|
182
288
|
async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
@@ -214,7 +320,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
|
|
|
214
320
|
|
|
215
321
|
try:
|
|
216
322
|
reply, is_done = await handle_message(
|
|
217
|
-
message, session.history, cfg_loader, store
|
|
323
|
+
message, session.history, cfg_loader, store,
|
|
324
|
+
slack_client=client,
|
|
218
325
|
)
|
|
219
326
|
except Exception as e:
|
|
220
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()}
|
|
@@ -273,3 +274,68 @@ class StateStore:
|
|
|
273
274
|
if row:
|
|
274
275
|
return datetime.fromisoformat(row["sent_at"])
|
|
275
276
|
return None
|
|
277
|
+
|
|
278
|
+
# ── Dedup store (used by Slack bot watcher) ───────────────────────────────
|
|
279
|
+
|
|
280
|
+
def is_seen_dedup(self, key: str) -> bool:
|
|
281
|
+
"""Return True if this dedup key has already been processed."""
|
|
282
|
+
with self._conn() as conn:
|
|
283
|
+
conn.execute(
|
|
284
|
+
"CREATE TABLE IF NOT EXISTS dedup "
|
|
285
|
+
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
286
|
+
)
|
|
287
|
+
row = conn.execute(
|
|
288
|
+
"SELECT key FROM dedup WHERE key = ?", (key,)
|
|
289
|
+
).fetchone()
|
|
290
|
+
return row is not None
|
|
291
|
+
|
|
292
|
+
def mark_seen_dedup(self, key: str):
|
|
293
|
+
"""Record a dedup key so it won't be processed again."""
|
|
294
|
+
with self._conn() as conn:
|
|
295
|
+
conn.execute(
|
|
296
|
+
"CREATE TABLE IF NOT EXISTS dedup "
|
|
297
|
+
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
298
|
+
)
|
|
299
|
+
conn.execute(
|
|
300
|
+
"INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
|
|
301
|
+
(key, _now()),
|
|
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]
|
|
@@ -27,7 +27,7 @@ WORKSPACE_DIR=./workspace
|
|
|
27
27
|
|
|
28
28
|
# Slack Bot (optional) — Sentinel Boss conversational interface
|
|
29
29
|
# Create a Slack App at api.slack.com, enable Socket Mode, add scopes:
|
|
30
|
-
# app_mentions:read, chat:write, im:history, channels:history, users:read
|
|
30
|
+
# app_mentions:read, chat:write, im:history, channels:history, users:read, reactions:write
|
|
31
31
|
# Then install to workspace and paste both tokens here.
|
|
32
32
|
# SLACK_BOT_TOKEN=xoxb-...
|
|
33
33
|
# SLACK_APP_TOKEN=xapp-...
|
|
@@ -35,3 +35,10 @@ WORKSPACE_DIR=./workspace
|
|
|
35
35
|
# Use the channel name (e.g. devops-sentinel) or the Slack channel ID (e.g. C01AB2CD3EF)
|
|
36
36
|
# Note: requires conversations:read scope on the Slack App if using channel name
|
|
37
37
|
# SLACK_CHANNEL=devops-sentinel
|
|
38
|
+
|
|
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).
|
|
44
|
+
# SLACK_WATCH_BOT_IDS=B12345678, B87654321
|