@misterhuydo/sentinel 1.0.15 → 1.0.16
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
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-21T18:10:35.571Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-21T17:
|
|
3
|
-
"checkpoint_at": "2026-03-21T17:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-21T17:52:09.310Z",
|
|
3
|
+
"checkpoint_at": "2026-03-21T17:52:09.311Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -58,6 +58,7 @@ class SentinelConfig:
|
|
|
58
58
|
upgrade_check_hours: int = 6 # hours between npm upgrade checks
|
|
59
59
|
slack_bot_token: str = "" # xoxb-...
|
|
60
60
|
slack_app_token: str = "" # xapp-... (Socket Mode)
|
|
61
|
+
slack_channel: str = "" # optional: restrict to one channel ID or name
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
@dataclass
|
|
@@ -149,6 +150,7 @@ class ConfigLoader:
|
|
|
149
150
|
c.upgrade_check_hours = int(d.get("UPGRADE_CHECK_HOURS", 6))
|
|
150
151
|
c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
|
|
151
152
|
c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
|
|
153
|
+
c.slack_channel = d.get("SLACK_CHANNEL", "")
|
|
152
154
|
self.sentinel = c
|
|
153
155
|
|
|
154
156
|
def _load_log_sources(self):
|
|
@@ -1,228 +1,261 @@
|
|
|
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
|
-
|
|
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
|
-
async def
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
@app.event("message")
|
|
168
|
+
async def on_message(event, client):
|
|
169
|
+
# DMs are always allowed regardless of SLACK_CHANNEL restriction
|
|
170
|
+
if event.get("channel_type") == "im" and not event.get("bot_id"):
|
|
171
|
+
await _dispatch(event, client, cfg_loader, store)
|
|
172
|
+
|
|
173
|
+
# ── Start ─────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
|
|
176
|
+
logger.info("Sentinel Boss connected to Slack (Socket Mode)")
|
|
177
|
+
await handler.start_async()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
183
|
+
user_id = event.get("user", "")
|
|
184
|
+
channel = event.get("channel", "")
|
|
185
|
+
text = _strip_mention(event.get("text", "")).strip()
|
|
186
|
+
|
|
187
|
+
if not text:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
user_name = await _resolve_name(client, user_id)
|
|
191
|
+
|
|
192
|
+
status, pos, session = await _queue.try_activate(user_id, user_name, channel)
|
|
193
|
+
|
|
194
|
+
if status == "queued":
|
|
195
|
+
await _queue.update_waiting_message(user_id, text)
|
|
196
|
+
active = _queue.active_user_name() or "someone"
|
|
197
|
+
await _post(client, channel,
|
|
198
|
+
f"I'm working with *{active}* right now. "
|
|
199
|
+
f"You're *#{pos}* in the queue — I'll come to you when done. :hourglass_flowing_sand:"
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Active or continuing — process the turn
|
|
204
|
+
await _run_turn(session, text, client, cfg_loader, store)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Turn processor ────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async def _run_turn(session: _Session, message: str, client, cfg_loader, store) -> None:
|
|
210
|
+
channel = session.channel
|
|
211
|
+
|
|
212
|
+
# Typing indicator
|
|
213
|
+
await _post(client, channel, "_thinking..._")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
reply, is_done = await handle_message(
|
|
217
|
+
message, session.history, cfg_loader, store
|
|
218
|
+
)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.exception("Sentinel Boss error: %s", e)
|
|
221
|
+
await _post(client, channel, f":warning: Unhandled error: {e}")
|
|
222
|
+
is_done = True
|
|
223
|
+
|
|
224
|
+
await _post(client, channel, reply)
|
|
225
|
+
|
|
226
|
+
if is_done:
|
|
227
|
+
next_item = await _queue.complete()
|
|
228
|
+
if next_item:
|
|
229
|
+
next_session, first_msg = next_item
|
|
230
|
+
await _post(
|
|
231
|
+
client, channel,
|
|
232
|
+
f":white_check_mark: Done with *{session.user_name}*. "
|
|
233
|
+
f"<@{next_session.user_id}> — you're up!"
|
|
234
|
+
)
|
|
235
|
+
if first_msg:
|
|
236
|
+
# Process their first message immediately
|
|
237
|
+
await _run_turn(next_session, first_msg, client, cfg_loader, store)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async def _post(client, channel: str, text: str) -> None:
|
|
243
|
+
try:
|
|
244
|
+
await client.chat_postMessage(channel=channel, text=text)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.warning("Slack post failed: %s", e)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _resolve_name(client, user_id: str) -> str:
|
|
250
|
+
try:
|
|
251
|
+
info = await client.users_info(user=user_id)
|
|
252
|
+
profile = info["user"]["profile"]
|
|
253
|
+
return profile.get("display_name") or profile.get("real_name") or user_id
|
|
254
|
+
except Exception:
|
|
255
|
+
return user_id
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _strip_mention(text: str) -> str:
|
|
259
|
+
"""Remove leading <@BOTID> mention from message text."""
|
|
260
|
+
import re
|
|
261
|
+
return re.sub(r"^<@[A-Z0-9]+>\s*", "", text)
|
|
@@ -25,3 +25,7 @@ WORKSPACE_DIR=./workspace
|
|
|
25
25
|
# Then install to workspace and paste both tokens here.
|
|
26
26
|
# SLACK_BOT_TOKEN=xoxb-...
|
|
27
27
|
# SLACK_APP_TOKEN=xapp-...
|
|
28
|
+
# Restrict Sentinel Boss to one channel (optional — omit to allow all channels + DMs)
|
|
29
|
+
# Use the channel name (e.g. devops-sentinel) or the Slack channel ID (e.g. C01AB2CD3EF)
|
|
30
|
+
# Note: requires conversations:read scope on the Slack App if using channel name
|
|
31
|
+
# SLACK_CHANNEL=devops-sentinel
|