takopi-slack-plugin 0.0.15__py3-none-any.whl
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.
- takopi_slack_plugin/__init__.py +1 -0
- takopi_slack_plugin/backend.py +193 -0
- takopi_slack_plugin/bridge.py +1380 -0
- takopi_slack_plugin/client.py +254 -0
- takopi_slack_plugin/commands/__init__.py +3 -0
- takopi_slack_plugin/commands/dispatch.py +114 -0
- takopi_slack_plugin/commands/executor.py +192 -0
- takopi_slack_plugin/config.py +60 -0
- takopi_slack_plugin/engine.py +142 -0
- takopi_slack_plugin/onboarding.py +58 -0
- takopi_slack_plugin/outbox.py +165 -0
- takopi_slack_plugin/overrides.py +20 -0
- takopi_slack_plugin/thread_sessions.py +289 -0
- takopi_slack_plugin-0.0.15.dist-info/METADATA +151 -0
- takopi_slack_plugin-0.0.15.dist-info/RECORD +17 -0
- takopi_slack_plugin-0.0.15.dist-info/WHEEL +4 -0
- takopi_slack_plugin-0.0.15.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Awaitable, Callable
|
|
8
|
+
from urllib.parse import parse_qs
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import websockets
|
|
12
|
+
from websockets.exceptions import WebSocketException
|
|
13
|
+
|
|
14
|
+
from takopi.api import (
|
|
15
|
+
ConfigError,
|
|
16
|
+
DirectiveError,
|
|
17
|
+
ExecBridgeConfig,
|
|
18
|
+
MessageRef,
|
|
19
|
+
RenderedMessage,
|
|
20
|
+
RunContext,
|
|
21
|
+
RunningTasks,
|
|
22
|
+
SendOptions,
|
|
23
|
+
TransportRuntime,
|
|
24
|
+
get_logger,
|
|
25
|
+
)
|
|
26
|
+
from takopi.directives import parse_directives
|
|
27
|
+
from takopi.runners.run_options import EngineRunOptions
|
|
28
|
+
|
|
29
|
+
from .client import SlackApiError, SlackClient, SlackMessage, open_socket_url
|
|
30
|
+
from .commands import dispatch_command, split_command_args
|
|
31
|
+
from .engine import run_engine, send_plain
|
|
32
|
+
from .outbox import DELETE_PRIORITY, EDIT_PRIORITY, SEND_PRIORITY, OutboxOp, SlackOutbox
|
|
33
|
+
from .overrides import REASONING_LEVELS, is_valid_reasoning_level, supports_reasoning
|
|
34
|
+
from .thread_sessions import SlackThreadSessionStore
|
|
35
|
+
|
|
36
|
+
logger = get_logger(__name__)
|
|
37
|
+
|
|
38
|
+
MAX_SLACK_TEXT = 3900
|
|
39
|
+
MAX_BLOCK_TEXT = 2800
|
|
40
|
+
CANCEL_ACTION_ID = "takopi-slack:cancel"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SlackPresenter:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
message_overflow: str = "trim",
|
|
48
|
+
max_chars: int = MAX_SLACK_TEXT,
|
|
49
|
+
max_actions: int = 5,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._message_overflow = message_overflow
|
|
52
|
+
self._max_chars = max(1, int(max_chars))
|
|
53
|
+
self._max_actions = max(0, int(max_actions))
|
|
54
|
+
|
|
55
|
+
def render_progress(
|
|
56
|
+
self,
|
|
57
|
+
state,
|
|
58
|
+
*,
|
|
59
|
+
elapsed_s: float,
|
|
60
|
+
label: str = "working",
|
|
61
|
+
) -> RenderedMessage:
|
|
62
|
+
text = _render_progress_text(
|
|
63
|
+
state,
|
|
64
|
+
elapsed_s=elapsed_s,
|
|
65
|
+
label=label,
|
|
66
|
+
max_actions=self._max_actions,
|
|
67
|
+
)
|
|
68
|
+
rendered = RenderedMessage(text=_trim_text(text, self._max_chars))
|
|
69
|
+
show_cancel = not _is_cancelled_label(label)
|
|
70
|
+
rendered.extra["show_cancel"] = show_cancel
|
|
71
|
+
if not show_cancel:
|
|
72
|
+
rendered.extra["clear_blocks"] = True
|
|
73
|
+
return rendered
|
|
74
|
+
|
|
75
|
+
def render_final(
|
|
76
|
+
self,
|
|
77
|
+
state,
|
|
78
|
+
*,
|
|
79
|
+
elapsed_s: float,
|
|
80
|
+
status: str,
|
|
81
|
+
answer: str,
|
|
82
|
+
) -> RenderedMessage:
|
|
83
|
+
text = _render_final_text(
|
|
84
|
+
state,
|
|
85
|
+
elapsed_s=elapsed_s,
|
|
86
|
+
status=status,
|
|
87
|
+
answer=answer,
|
|
88
|
+
)
|
|
89
|
+
if self._message_overflow == "split":
|
|
90
|
+
chunks = _split_text(text, self._max_chars)
|
|
91
|
+
message = RenderedMessage(text=chunks[0])
|
|
92
|
+
message.extra["clear_blocks"] = True
|
|
93
|
+
if len(chunks) > 1:
|
|
94
|
+
message.extra["followups"] = [
|
|
95
|
+
RenderedMessage(text=chunk) for chunk in chunks[1:]
|
|
96
|
+
]
|
|
97
|
+
return message
|
|
98
|
+
rendered = RenderedMessage(text=_trim_text(text, self._max_chars))
|
|
99
|
+
rendered.extra["clear_blocks"] = True
|
|
100
|
+
return rendered
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class SlackBridgeConfig:
|
|
105
|
+
client: SlackClient
|
|
106
|
+
runtime: TransportRuntime
|
|
107
|
+
channel_id: str
|
|
108
|
+
app_token: str
|
|
109
|
+
startup_msg: str
|
|
110
|
+
exec_cfg: ExecBridgeConfig
|
|
111
|
+
thread_store: SlackThreadSessionStore | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class CommandContext:
|
|
116
|
+
default_context: RunContext | None
|
|
117
|
+
default_engine_override: str | None
|
|
118
|
+
engine_overrides_resolver: Callable[[str], Awaitable[EngineRunOptions | None]]
|
|
119
|
+
on_thread_known: Callable[[Any, anyio.Event], Awaitable[None]] | None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SlackTransport:
|
|
123
|
+
def __init__(self, client: SlackClient) -> None:
|
|
124
|
+
self._client = client
|
|
125
|
+
self._outbox = SlackOutbox()
|
|
126
|
+
self._send_counter = 0
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _extract_followups(message: RenderedMessage) -> list[RenderedMessage]:
|
|
130
|
+
followups = message.extra.get("followups")
|
|
131
|
+
if not isinstance(followups, list):
|
|
132
|
+
return []
|
|
133
|
+
return [item for item in followups if isinstance(item, RenderedMessage)]
|
|
134
|
+
|
|
135
|
+
def _next_send_key(self, channel_id: str) -> tuple[str, str, int]:
|
|
136
|
+
self._send_counter += 1
|
|
137
|
+
return ("send", channel_id, self._send_counter)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _edit_key(channel_id: str, ts: str) -> tuple[str, str, str]:
|
|
141
|
+
return ("edit", channel_id, ts)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _delete_key(channel_id: str, ts: str) -> tuple[str, str, str]:
|
|
145
|
+
return ("delete", channel_id, ts)
|
|
146
|
+
|
|
147
|
+
def _prepare_blocks(
|
|
148
|
+
self, message: RenderedMessage, *, allow_clear: bool
|
|
149
|
+
) -> list[dict[str, Any]] | None:
|
|
150
|
+
extra = message.extra
|
|
151
|
+
blocks = extra.get("blocks")
|
|
152
|
+
if isinstance(blocks, list):
|
|
153
|
+
return blocks
|
|
154
|
+
if extra.get("show_cancel"):
|
|
155
|
+
return _build_cancel_blocks(message.text)
|
|
156
|
+
if allow_clear and extra.get("clear_blocks"):
|
|
157
|
+
return []
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
async def close(self) -> None:
|
|
161
|
+
await self._outbox.close()
|
|
162
|
+
await self._client.close()
|
|
163
|
+
|
|
164
|
+
async def send(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
channel_id: int | str,
|
|
168
|
+
message: RenderedMessage,
|
|
169
|
+
options: SendOptions | None = None,
|
|
170
|
+
) -> MessageRef | None:
|
|
171
|
+
channel = str(channel_id)
|
|
172
|
+
thread_ts = None
|
|
173
|
+
if options is not None and options.thread_id is not None:
|
|
174
|
+
thread_ts = str(options.thread_id)
|
|
175
|
+
followups = self._extract_followups(message)
|
|
176
|
+
blocks = self._prepare_blocks(message, allow_clear=False)
|
|
177
|
+
sent = await self._enqueue_send(
|
|
178
|
+
channel_id=channel,
|
|
179
|
+
text=message.text,
|
|
180
|
+
blocks=blocks,
|
|
181
|
+
thread_ts=thread_ts,
|
|
182
|
+
)
|
|
183
|
+
ref = MessageRef(
|
|
184
|
+
channel_id=channel,
|
|
185
|
+
message_id=sent.ts,
|
|
186
|
+
raw=sent,
|
|
187
|
+
thread_id=thread_ts,
|
|
188
|
+
)
|
|
189
|
+
if options is not None and options.replace is not None:
|
|
190
|
+
await self.delete(
|
|
191
|
+
ref=MessageRef(
|
|
192
|
+
channel_id=channel,
|
|
193
|
+
message_id=str(options.replace.message_id),
|
|
194
|
+
thread_id=thread_ts,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
followup_thread = None
|
|
198
|
+
if message.extra.get("followup_thread_id") is not None:
|
|
199
|
+
followup_thread = str(message.extra.get("followup_thread_id"))
|
|
200
|
+
if followup_thread is None:
|
|
201
|
+
followup_thread = thread_ts
|
|
202
|
+
for followup in followups:
|
|
203
|
+
await self._enqueue_send(
|
|
204
|
+
channel_id=channel,
|
|
205
|
+
text=followup.text,
|
|
206
|
+
blocks=None,
|
|
207
|
+
thread_ts=followup_thread,
|
|
208
|
+
)
|
|
209
|
+
return ref
|
|
210
|
+
|
|
211
|
+
async def edit(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
ref: MessageRef,
|
|
215
|
+
message: RenderedMessage,
|
|
216
|
+
wait: bool = True,
|
|
217
|
+
) -> MessageRef | None:
|
|
218
|
+
blocks = self._prepare_blocks(message, allow_clear=True)
|
|
219
|
+
updated = await self._enqueue_edit(
|
|
220
|
+
channel_id=str(ref.channel_id),
|
|
221
|
+
ts=str(ref.message_id),
|
|
222
|
+
text=message.text,
|
|
223
|
+
blocks=blocks,
|
|
224
|
+
wait=wait,
|
|
225
|
+
)
|
|
226
|
+
if updated is None:
|
|
227
|
+
return ref if not wait else None
|
|
228
|
+
return MessageRef(
|
|
229
|
+
channel_id=ref.channel_id,
|
|
230
|
+
message_id=updated.ts,
|
|
231
|
+
raw=updated,
|
|
232
|
+
thread_id=ref.thread_id,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def delete(self, *, ref: MessageRef) -> bool:
|
|
236
|
+
return await self._enqueue_delete(
|
|
237
|
+
channel_id=str(ref.channel_id),
|
|
238
|
+
ts=str(ref.message_id),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def _enqueue_send(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
channel_id: str,
|
|
245
|
+
text: str,
|
|
246
|
+
blocks: list[dict[str, Any]] | None,
|
|
247
|
+
thread_ts: str | None,
|
|
248
|
+
) -> SlackMessage:
|
|
249
|
+
key = self._next_send_key(channel_id)
|
|
250
|
+
op = OutboxOp(
|
|
251
|
+
execute=lambda: self._client.post_message(
|
|
252
|
+
channel_id=channel_id,
|
|
253
|
+
text=text,
|
|
254
|
+
blocks=blocks,
|
|
255
|
+
thread_ts=thread_ts,
|
|
256
|
+
),
|
|
257
|
+
priority=SEND_PRIORITY,
|
|
258
|
+
queued_at=time.monotonic(),
|
|
259
|
+
channel_id=channel_id,
|
|
260
|
+
)
|
|
261
|
+
return await self._outbox.enqueue(key=key, op=op, wait=True)
|
|
262
|
+
|
|
263
|
+
async def _enqueue_edit(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
channel_id: str,
|
|
267
|
+
ts: str,
|
|
268
|
+
text: str,
|
|
269
|
+
blocks: list[dict[str, Any]] | None,
|
|
270
|
+
wait: bool,
|
|
271
|
+
) -> SlackMessage | None:
|
|
272
|
+
key = self._edit_key(channel_id, ts)
|
|
273
|
+
op = OutboxOp(
|
|
274
|
+
execute=lambda: self._client.update_message(
|
|
275
|
+
channel_id=channel_id,
|
|
276
|
+
ts=ts,
|
|
277
|
+
text=text,
|
|
278
|
+
blocks=blocks,
|
|
279
|
+
),
|
|
280
|
+
priority=EDIT_PRIORITY,
|
|
281
|
+
queued_at=time.monotonic(),
|
|
282
|
+
channel_id=channel_id,
|
|
283
|
+
)
|
|
284
|
+
return await self._outbox.enqueue(key=key, op=op, wait=wait)
|
|
285
|
+
|
|
286
|
+
async def _enqueue_delete(self, *, channel_id: str, ts: str) -> bool:
|
|
287
|
+
edit_key = self._edit_key(channel_id, ts)
|
|
288
|
+
await self._outbox.drop_pending(key=edit_key)
|
|
289
|
+
delete_key = self._delete_key(channel_id, ts)
|
|
290
|
+
op = OutboxOp(
|
|
291
|
+
execute=lambda: self._client.delete_message(
|
|
292
|
+
channel_id=channel_id,
|
|
293
|
+
ts=ts,
|
|
294
|
+
),
|
|
295
|
+
priority=DELETE_PRIORITY,
|
|
296
|
+
queued_at=time.monotonic(),
|
|
297
|
+
channel_id=channel_id,
|
|
298
|
+
)
|
|
299
|
+
result = await self._outbox.enqueue(key=delete_key, op=op, wait=True)
|
|
300
|
+
return bool(result)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _is_cancelled_label(label: str) -> bool:
|
|
304
|
+
stripped = label.strip()
|
|
305
|
+
if stripped.startswith("`") and stripped.endswith("`") and len(stripped) >= 2:
|
|
306
|
+
stripped = stripped[1:-1]
|
|
307
|
+
return stripped.lower() == "cancelled"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _format_elapsed(elapsed_s: float) -> str:
|
|
311
|
+
total = max(0, int(elapsed_s))
|
|
312
|
+
minutes, seconds = divmod(total, 60)
|
|
313
|
+
hours, minutes = divmod(minutes, 60)
|
|
314
|
+
if hours:
|
|
315
|
+
return f"{hours}h {minutes:02d}m"
|
|
316
|
+
if minutes:
|
|
317
|
+
return f"{minutes}m {seconds:02d}s"
|
|
318
|
+
return f"{seconds}s"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _format_header(
|
|
322
|
+
elapsed_s: float, step: int | None, *, label: str, engine: str
|
|
323
|
+
) -> str:
|
|
324
|
+
elapsed = _format_elapsed(elapsed_s)
|
|
325
|
+
parts = [label, engine, elapsed]
|
|
326
|
+
if step is not None:
|
|
327
|
+
parts.append(f"step {step}")
|
|
328
|
+
return " · ".join(parts)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _shorten(text: str, width: int | None) -> str:
|
|
332
|
+
if width is None:
|
|
333
|
+
return text
|
|
334
|
+
if width <= 0:
|
|
335
|
+
return ""
|
|
336
|
+
if len(text) <= width:
|
|
337
|
+
return text
|
|
338
|
+
if width <= 3:
|
|
339
|
+
return text[:width]
|
|
340
|
+
return f"{text[: width - 3]}..."
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _format_action_title(action) -> str:
|
|
344
|
+
title = str(action.title or "").strip()
|
|
345
|
+
if not title:
|
|
346
|
+
title = action.kind
|
|
347
|
+
if action.kind == "command":
|
|
348
|
+
return f"`{_shorten(title, 160)}`"
|
|
349
|
+
if action.kind == "tool":
|
|
350
|
+
return f"tool: {_shorten(title, 160)}"
|
|
351
|
+
if action.kind == "file_change":
|
|
352
|
+
return f"files: {_shorten(title, 160)}"
|
|
353
|
+
if action.kind in {"note", "warning"}:
|
|
354
|
+
return _shorten(title, 200)
|
|
355
|
+
return _shorten(title, 160)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _action_status(action_state) -> str:
|
|
359
|
+
if action_state.completed:
|
|
360
|
+
if action_state.ok is False:
|
|
361
|
+
return "err"
|
|
362
|
+
return "ok"
|
|
363
|
+
if action_state.display_phase == "updated":
|
|
364
|
+
return "upd"
|
|
365
|
+
return "run"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _format_action_line(action_state) -> str:
|
|
369
|
+
status = _action_status(action_state)
|
|
370
|
+
title = _format_action_title(action_state.action)
|
|
371
|
+
return f"[{status}] {title}"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _format_actions(actions, *, max_actions: int) -> str | None:
|
|
375
|
+
if not actions:
|
|
376
|
+
return None
|
|
377
|
+
if max_actions <= 0:
|
|
378
|
+
return None
|
|
379
|
+
visible = actions[-max_actions:]
|
|
380
|
+
return "\n".join(_format_action_line(item) for item in visible)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _format_footer(state) -> str | None:
|
|
384
|
+
lines: list[str] = []
|
|
385
|
+
if state.context_line:
|
|
386
|
+
lines.append(state.context_line)
|
|
387
|
+
if state.resume_line:
|
|
388
|
+
lines.append(state.resume_line)
|
|
389
|
+
if not lines:
|
|
390
|
+
return None
|
|
391
|
+
return "\n".join(lines)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _assemble_sections(*chunks: str | None) -> str:
|
|
395
|
+
return "\n\n".join(chunk for chunk in chunks if chunk)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _render_progress_text(
|
|
399
|
+
state,
|
|
400
|
+
*,
|
|
401
|
+
elapsed_s: float,
|
|
402
|
+
label: str,
|
|
403
|
+
max_actions: int,
|
|
404
|
+
) -> str:
|
|
405
|
+
step = state.action_count or None
|
|
406
|
+
header = _format_header(elapsed_s, step, label=label, engine=state.engine)
|
|
407
|
+
body = _format_actions(state.actions, max_actions=max_actions)
|
|
408
|
+
footer = _format_footer(state)
|
|
409
|
+
return _assemble_sections(header, body, footer)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _render_final_text(
|
|
413
|
+
state,
|
|
414
|
+
*,
|
|
415
|
+
elapsed_s: float,
|
|
416
|
+
status: str,
|
|
417
|
+
answer: str,
|
|
418
|
+
) -> str:
|
|
419
|
+
step = state.action_count or None
|
|
420
|
+
header = _format_header(elapsed_s, step, label=status, engine=state.engine)
|
|
421
|
+
body = (answer or "").strip() or None
|
|
422
|
+
footer = _format_footer(state)
|
|
423
|
+
return _assemble_sections(header, body, footer)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _trim_text(text: str, max_chars: int) -> str:
|
|
427
|
+
if len(text) <= max_chars:
|
|
428
|
+
return text
|
|
429
|
+
if max_chars <= 1:
|
|
430
|
+
return text[:max_chars]
|
|
431
|
+
if max_chars <= 3:
|
|
432
|
+
return text[:max_chars]
|
|
433
|
+
return f"{text[: max_chars - 3]}..."
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _split_text(text: str, max_chars: int) -> list[str]:
|
|
437
|
+
if max_chars <= 0:
|
|
438
|
+
return [text]
|
|
439
|
+
if len(text) <= max_chars:
|
|
440
|
+
return [text]
|
|
441
|
+
chunks = []
|
|
442
|
+
start = 0
|
|
443
|
+
while start < len(text):
|
|
444
|
+
chunks.append(text[start : start + max_chars])
|
|
445
|
+
start += max_chars
|
|
446
|
+
return chunks
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _trim_block_text(text: str) -> str:
|
|
450
|
+
if len(text) <= MAX_BLOCK_TEXT:
|
|
451
|
+
return text
|
|
452
|
+
if MAX_BLOCK_TEXT <= 3:
|
|
453
|
+
return text[:MAX_BLOCK_TEXT]
|
|
454
|
+
return f"{text[: MAX_BLOCK_TEXT - 3]}..."
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _build_cancel_blocks(text: str) -> list[dict[str, Any]]:
|
|
458
|
+
body = _trim_block_text(text)
|
|
459
|
+
return [
|
|
460
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": body}},
|
|
461
|
+
{
|
|
462
|
+
"type": "actions",
|
|
463
|
+
"elements": [
|
|
464
|
+
{
|
|
465
|
+
"type": "button",
|
|
466
|
+
"text": {"type": "plain_text", "text": "cancel"},
|
|
467
|
+
"action_id": CANCEL_ACTION_ID,
|
|
468
|
+
"style": "danger",
|
|
469
|
+
"value": "cancel",
|
|
470
|
+
}
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _mention_regex(bot_user_id: str) -> re.Pattern[str]:
|
|
477
|
+
escaped = re.escape(bot_user_id)
|
|
478
|
+
return re.compile(rf"<@{escaped}(\|[^>]+)?>")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _strip_bot_mention(text: str, *, bot_user_id: str | None) -> str:
|
|
482
|
+
cleaned = text
|
|
483
|
+
if bot_user_id is not None:
|
|
484
|
+
pattern = _mention_regex(bot_user_id)
|
|
485
|
+
cleaned = pattern.sub("", text)
|
|
486
|
+
return cleaned.strip()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _parse_form_payload(raw: str) -> dict[str, str]:
|
|
490
|
+
parsed = parse_qs(raw, keep_blank_values=True)
|
|
491
|
+
return {key: values[-1] if values else "" for key, values in parsed.items()}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _coerce_socket_payload(payload: object) -> dict[str, Any] | None:
|
|
495
|
+
if isinstance(payload, dict):
|
|
496
|
+
return payload
|
|
497
|
+
if isinstance(payload, str):
|
|
498
|
+
raw = payload.strip()
|
|
499
|
+
if raw.startswith("{") and raw.endswith("}"):
|
|
500
|
+
try:
|
|
501
|
+
value = json.loads(raw)
|
|
502
|
+
except json.JSONDecodeError:
|
|
503
|
+
value = None
|
|
504
|
+
if isinstance(value, dict):
|
|
505
|
+
return value
|
|
506
|
+
parsed = _parse_form_payload(raw)
|
|
507
|
+
if "payload" in parsed:
|
|
508
|
+
try:
|
|
509
|
+
decoded = json.loads(parsed["payload"])
|
|
510
|
+
except json.JSONDecodeError:
|
|
511
|
+
decoded = None
|
|
512
|
+
if isinstance(decoded, dict):
|
|
513
|
+
return decoded
|
|
514
|
+
return parsed
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _should_skip_message(message: SlackMessage, bot_user_id: str | None) -> bool:
|
|
519
|
+
if not message.ts:
|
|
520
|
+
return True
|
|
521
|
+
if message.subtype is not None:
|
|
522
|
+
return True
|
|
523
|
+
if message.bot_id is not None:
|
|
524
|
+
return True
|
|
525
|
+
if message.user is None:
|
|
526
|
+
return True
|
|
527
|
+
if bot_user_id is not None and message.user == bot_user_id:
|
|
528
|
+
return True
|
|
529
|
+
if not message.text or not message.text.strip():
|
|
530
|
+
return True
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def _send_startup(cfg: SlackBridgeConfig) -> None:
|
|
535
|
+
if not cfg.startup_msg.strip():
|
|
536
|
+
return
|
|
537
|
+
message = RenderedMessage(text=cfg.startup_msg)
|
|
538
|
+
sent = await cfg.exec_cfg.transport.send(
|
|
539
|
+
channel_id=cfg.channel_id,
|
|
540
|
+
message=message,
|
|
541
|
+
)
|
|
542
|
+
if sent is not None:
|
|
543
|
+
logger.info("startup.sent", channel_id=cfg.channel_id)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async def _handle_slack_message(
|
|
547
|
+
cfg: SlackBridgeConfig,
|
|
548
|
+
message: SlackMessage,
|
|
549
|
+
text: str,
|
|
550
|
+
running_tasks: RunningTasks,
|
|
551
|
+
) -> None:
|
|
552
|
+
channel_id = cfg.channel_id
|
|
553
|
+
is_thread_reply = message.thread_ts is not None
|
|
554
|
+
thread_id = message.thread_ts or message.ts
|
|
555
|
+
thread_store = cfg.thread_store
|
|
556
|
+
try:
|
|
557
|
+
# Reuse Takopi's directive parser to avoid double parsing.
|
|
558
|
+
directives = parse_directives(
|
|
559
|
+
text,
|
|
560
|
+
engine_ids=cfg.runtime.engine_ids,
|
|
561
|
+
projects=cfg.runtime._projects,
|
|
562
|
+
)
|
|
563
|
+
except DirectiveError as exc:
|
|
564
|
+
await send_plain(
|
|
565
|
+
cfg.exec_cfg,
|
|
566
|
+
channel_id=channel_id,
|
|
567
|
+
user_msg_id=message.ts,
|
|
568
|
+
thread_id=thread_id,
|
|
569
|
+
text=f"error:\n{exc}",
|
|
570
|
+
notify=False,
|
|
571
|
+
)
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
context: RunContext | None = None
|
|
575
|
+
engine_override = directives.engine
|
|
576
|
+
prompt = directives.prompt
|
|
577
|
+
if directives.project is not None and directives.branch is not None:
|
|
578
|
+
context = RunContext(project=directives.project, branch=directives.branch)
|
|
579
|
+
if thread_store is not None and thread_id is not None:
|
|
580
|
+
await thread_store.set_context(
|
|
581
|
+
channel_id=channel_id,
|
|
582
|
+
thread_id=thread_id,
|
|
583
|
+
context=context,
|
|
584
|
+
)
|
|
585
|
+
if engine_override is None:
|
|
586
|
+
engine_override = await thread_store.get_default_engine(
|
|
587
|
+
channel_id=channel_id,
|
|
588
|
+
thread_id=thread_id,
|
|
589
|
+
)
|
|
590
|
+
elif is_thread_reply and thread_store is not None and thread_id is not None:
|
|
591
|
+
context = await thread_store.get_context(
|
|
592
|
+
channel_id=channel_id,
|
|
593
|
+
thread_id=thread_id,
|
|
594
|
+
)
|
|
595
|
+
if context is not None and engine_override is None:
|
|
596
|
+
engine_override = await thread_store.get_default_engine(
|
|
597
|
+
channel_id=channel_id,
|
|
598
|
+
thread_id=thread_id,
|
|
599
|
+
)
|
|
600
|
+
if directives.project is None and directives.branch is not None:
|
|
601
|
+
prompt = f"@{directives.branch} {prompt}".strip()
|
|
602
|
+
|
|
603
|
+
if context is None:
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
if not prompt.strip():
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
# Router access avoids re-parsing directives in runtime.resolve_message.
|
|
610
|
+
resume_token = cfg.runtime._router.resolve_resume(prompt, None)
|
|
611
|
+
engine_for_session = cfg.runtime.resolve_engine(
|
|
612
|
+
engine_override=engine_override,
|
|
613
|
+
context=context,
|
|
614
|
+
)
|
|
615
|
+
if thread_store is not None and thread_id is not None:
|
|
616
|
+
if resume_token is not None:
|
|
617
|
+
await thread_store.set_resume(
|
|
618
|
+
channel_id=channel_id,
|
|
619
|
+
thread_id=thread_id,
|
|
620
|
+
token=resume_token,
|
|
621
|
+
)
|
|
622
|
+
else:
|
|
623
|
+
resume_token = await thread_store.get_resume(
|
|
624
|
+
channel_id=channel_id,
|
|
625
|
+
thread_id=thread_id,
|
|
626
|
+
engine=engine_for_session,
|
|
627
|
+
)
|
|
628
|
+
run_options = await _resolve_run_options(
|
|
629
|
+
thread_store,
|
|
630
|
+
channel_id=channel_id,
|
|
631
|
+
thread_id=thread_id,
|
|
632
|
+
engine_id=engine_for_session,
|
|
633
|
+
)
|
|
634
|
+
on_thread_known = _make_resume_saver(
|
|
635
|
+
thread_store,
|
|
636
|
+
channel_id=channel_id,
|
|
637
|
+
thread_id=thread_id,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
await run_engine(
|
|
641
|
+
exec_cfg=cfg.exec_cfg,
|
|
642
|
+
runtime=cfg.runtime,
|
|
643
|
+
running_tasks=running_tasks,
|
|
644
|
+
channel_id=channel_id,
|
|
645
|
+
user_msg_id=message.ts,
|
|
646
|
+
text=prompt,
|
|
647
|
+
resume_token=resume_token,
|
|
648
|
+
context=context,
|
|
649
|
+
engine_override=engine_override,
|
|
650
|
+
thread_id=thread_id,
|
|
651
|
+
on_thread_known=on_thread_known,
|
|
652
|
+
run_options=run_options,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
async def _safe_handle_slack_message(
|
|
657
|
+
cfg: SlackBridgeConfig,
|
|
658
|
+
message: SlackMessage,
|
|
659
|
+
text: str,
|
|
660
|
+
running_tasks: RunningTasks,
|
|
661
|
+
) -> None:
|
|
662
|
+
try:
|
|
663
|
+
await _handle_slack_message(cfg, message, text, running_tasks)
|
|
664
|
+
except Exception as exc:
|
|
665
|
+
logger.exception(
|
|
666
|
+
"slack.message_failed",
|
|
667
|
+
error=str(exc),
|
|
668
|
+
error_type=exc.__class__.__name__,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _session_thread_id(channel_id: str, thread_ts: str | None) -> str:
|
|
673
|
+
return thread_ts if thread_ts else channel_id
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
async def _respond_ephemeral(
|
|
677
|
+
cfg: SlackBridgeConfig,
|
|
678
|
+
*,
|
|
679
|
+
response_url: str | None,
|
|
680
|
+
channel_id: str,
|
|
681
|
+
text: str,
|
|
682
|
+
) -> None:
|
|
683
|
+
if response_url:
|
|
684
|
+
await cfg.client.post_response(
|
|
685
|
+
response_url=response_url,
|
|
686
|
+
text=text,
|
|
687
|
+
response_type="ephemeral",
|
|
688
|
+
)
|
|
689
|
+
return
|
|
690
|
+
await cfg.client.post_message(channel_id=channel_id, text=text)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _extract_command_text(tokens: tuple[str, ...], raw_text: str) -> tuple[str, str]:
|
|
694
|
+
head = tokens[0]
|
|
695
|
+
command_id = head.lstrip("/").lower()
|
|
696
|
+
args_text = raw_text[len(head) :].strip()
|
|
697
|
+
return command_id, args_text
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _parse_thread_ts(value: object) -> str | None:
|
|
701
|
+
if isinstance(value, str) and value.strip():
|
|
702
|
+
return value
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
async def _resolve_run_options(
|
|
707
|
+
thread_store: SlackThreadSessionStore | None,
|
|
708
|
+
*,
|
|
709
|
+
channel_id: str,
|
|
710
|
+
thread_id: str | None,
|
|
711
|
+
engine_id: str,
|
|
712
|
+
) -> EngineRunOptions | None:
|
|
713
|
+
if thread_store is None or thread_id is None:
|
|
714
|
+
return None
|
|
715
|
+
model = await thread_store.get_model_override(
|
|
716
|
+
channel_id=channel_id,
|
|
717
|
+
thread_id=thread_id,
|
|
718
|
+
engine=engine_id,
|
|
719
|
+
)
|
|
720
|
+
reasoning = await thread_store.get_reasoning_override(
|
|
721
|
+
channel_id=channel_id,
|
|
722
|
+
thread_id=thread_id,
|
|
723
|
+
engine=engine_id,
|
|
724
|
+
)
|
|
725
|
+
if model or reasoning:
|
|
726
|
+
return EngineRunOptions(model=model, reasoning=reasoning)
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _make_resume_saver(
|
|
731
|
+
thread_store: SlackThreadSessionStore | None,
|
|
732
|
+
*,
|
|
733
|
+
channel_id: str,
|
|
734
|
+
thread_id: str | None,
|
|
735
|
+
):
|
|
736
|
+
if thread_store is None or thread_id is None:
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
async def _note_resume(token, done: anyio.Event) -> None:
|
|
740
|
+
_ = done
|
|
741
|
+
await thread_store.set_resume(
|
|
742
|
+
channel_id=channel_id,
|
|
743
|
+
thread_id=thread_id,
|
|
744
|
+
token=token,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
return _note_resume
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
async def _resolve_command_context(
|
|
751
|
+
cfg: SlackBridgeConfig,
|
|
752
|
+
*,
|
|
753
|
+
channel_id: str,
|
|
754
|
+
thread_id: str,
|
|
755
|
+
) -> CommandContext | None:
|
|
756
|
+
thread_store = cfg.thread_store
|
|
757
|
+
if thread_store is None:
|
|
758
|
+
return None
|
|
759
|
+
default_context = await thread_store.get_context(
|
|
760
|
+
channel_id=channel_id,
|
|
761
|
+
thread_id=thread_id,
|
|
762
|
+
)
|
|
763
|
+
default_engine_override = await thread_store.get_default_engine(
|
|
764
|
+
channel_id=channel_id,
|
|
765
|
+
thread_id=thread_id,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
async def engine_overrides_resolver(
|
|
769
|
+
engine_id: str,
|
|
770
|
+
) -> EngineRunOptions | None:
|
|
771
|
+
return await _resolve_run_options(
|
|
772
|
+
thread_store,
|
|
773
|
+
channel_id=channel_id,
|
|
774
|
+
thread_id=thread_id,
|
|
775
|
+
engine_id=engine_id,
|
|
776
|
+
)
|
|
777
|
+
on_thread_known = _make_resume_saver(
|
|
778
|
+
thread_store,
|
|
779
|
+
channel_id=channel_id,
|
|
780
|
+
thread_id=thread_id,
|
|
781
|
+
)
|
|
782
|
+
return CommandContext(
|
|
783
|
+
default_context=default_context,
|
|
784
|
+
default_engine_override=default_engine_override,
|
|
785
|
+
engine_overrides_resolver=engine_overrides_resolver,
|
|
786
|
+
on_thread_known=on_thread_known,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
async def _handle_slash_command(
|
|
791
|
+
cfg: SlackBridgeConfig,
|
|
792
|
+
payload: dict[str, Any],
|
|
793
|
+
running_tasks: RunningTasks,
|
|
794
|
+
) -> None:
|
|
795
|
+
channel_id = payload.get("channel_id")
|
|
796
|
+
if not isinstance(channel_id, str) or channel_id != cfg.channel_id:
|
|
797
|
+
return
|
|
798
|
+
text = payload.get("text") or ""
|
|
799
|
+
response_url = payload.get("response_url")
|
|
800
|
+
thread_ts = _parse_thread_ts(payload.get("thread_ts") or payload.get("message_ts"))
|
|
801
|
+
thread_id = _session_thread_id(channel_id, thread_ts)
|
|
802
|
+
|
|
803
|
+
tokens = split_command_args(text)
|
|
804
|
+
if not tokens:
|
|
805
|
+
await _respond_ephemeral(
|
|
806
|
+
cfg,
|
|
807
|
+
response_url=response_url,
|
|
808
|
+
channel_id=channel_id,
|
|
809
|
+
text=_slash_usage(),
|
|
810
|
+
)
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
command_id, args_text = _extract_command_text(tokens, text)
|
|
814
|
+
if command_id in {"help", "usage"}:
|
|
815
|
+
await _respond_ephemeral(
|
|
816
|
+
cfg,
|
|
817
|
+
response_url=response_url,
|
|
818
|
+
channel_id=channel_id,
|
|
819
|
+
text=_slash_usage(),
|
|
820
|
+
)
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
thread_store = cfg.thread_store
|
|
824
|
+
if thread_store is None:
|
|
825
|
+
await _respond_ephemeral(
|
|
826
|
+
cfg,
|
|
827
|
+
response_url=response_url,
|
|
828
|
+
channel_id=channel_id,
|
|
829
|
+
text="Slack thread state store is not configured.",
|
|
830
|
+
)
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
if command_id == "status":
|
|
834
|
+
state = await thread_store.get_state(
|
|
835
|
+
channel_id=channel_id,
|
|
836
|
+
thread_id=thread_id,
|
|
837
|
+
)
|
|
838
|
+
await _respond_ephemeral(
|
|
839
|
+
cfg,
|
|
840
|
+
response_url=response_url,
|
|
841
|
+
channel_id=channel_id,
|
|
842
|
+
text=_format_status(state),
|
|
843
|
+
)
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
if command_id == "engine":
|
|
847
|
+
if len(tokens) < 2:
|
|
848
|
+
await _respond_ephemeral(
|
|
849
|
+
cfg,
|
|
850
|
+
response_url=response_url,
|
|
851
|
+
channel_id=channel_id,
|
|
852
|
+
text="usage: /takopi engine <engine|clear>",
|
|
853
|
+
)
|
|
854
|
+
return
|
|
855
|
+
engine_value = tokens[1].strip()
|
|
856
|
+
if engine_value.lower() == "clear":
|
|
857
|
+
await thread_store.set_default_engine(
|
|
858
|
+
channel_id=channel_id,
|
|
859
|
+
thread_id=thread_id,
|
|
860
|
+
engine=None,
|
|
861
|
+
)
|
|
862
|
+
await _respond_ephemeral(
|
|
863
|
+
cfg,
|
|
864
|
+
response_url=response_url,
|
|
865
|
+
channel_id=channel_id,
|
|
866
|
+
text="default engine cleared for this thread.",
|
|
867
|
+
)
|
|
868
|
+
return
|
|
869
|
+
engine_id = engine_value.lower()
|
|
870
|
+
if engine_id not in cfg.runtime.engine_ids:
|
|
871
|
+
await _respond_ephemeral(
|
|
872
|
+
cfg,
|
|
873
|
+
response_url=response_url,
|
|
874
|
+
channel_id=channel_id,
|
|
875
|
+
text=f"unknown engine: `{engine_value}`",
|
|
876
|
+
)
|
|
877
|
+
return
|
|
878
|
+
await thread_store.set_default_engine(
|
|
879
|
+
channel_id=channel_id,
|
|
880
|
+
thread_id=thread_id,
|
|
881
|
+
engine=engine_id,
|
|
882
|
+
)
|
|
883
|
+
await _respond_ephemeral(
|
|
884
|
+
cfg,
|
|
885
|
+
response_url=response_url,
|
|
886
|
+
channel_id=channel_id,
|
|
887
|
+
text=f"default engine set to `{engine_id}` for this thread.",
|
|
888
|
+
)
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
if command_id == "model":
|
|
892
|
+
if len(tokens) < 3:
|
|
893
|
+
await _respond_ephemeral(
|
|
894
|
+
cfg,
|
|
895
|
+
response_url=response_url,
|
|
896
|
+
channel_id=channel_id,
|
|
897
|
+
text="usage: /takopi model <engine> <model|clear>",
|
|
898
|
+
)
|
|
899
|
+
return
|
|
900
|
+
engine_id = tokens[1].strip().lower()
|
|
901
|
+
model = tokens[2].strip()
|
|
902
|
+
if engine_id not in cfg.runtime.engine_ids:
|
|
903
|
+
await _respond_ephemeral(
|
|
904
|
+
cfg,
|
|
905
|
+
response_url=response_url,
|
|
906
|
+
channel_id=channel_id,
|
|
907
|
+
text=f"unknown engine: `{engine_id}`",
|
|
908
|
+
)
|
|
909
|
+
return
|
|
910
|
+
value = None if model.lower() == "clear" else model
|
|
911
|
+
await thread_store.set_model_override(
|
|
912
|
+
channel_id=channel_id,
|
|
913
|
+
thread_id=thread_id,
|
|
914
|
+
engine=engine_id,
|
|
915
|
+
model=value,
|
|
916
|
+
)
|
|
917
|
+
status = "cleared" if value is None else f"set to `{value}`"
|
|
918
|
+
await _respond_ephemeral(
|
|
919
|
+
cfg,
|
|
920
|
+
response_url=response_url,
|
|
921
|
+
channel_id=channel_id,
|
|
922
|
+
text=f"model override {status} for `{engine_id}`.",
|
|
923
|
+
)
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
if command_id == "reasoning":
|
|
927
|
+
if len(tokens) < 3:
|
|
928
|
+
await _respond_ephemeral(
|
|
929
|
+
cfg,
|
|
930
|
+
response_url=response_url,
|
|
931
|
+
channel_id=channel_id,
|
|
932
|
+
text="usage: /takopi reasoning <engine> <level|clear>",
|
|
933
|
+
)
|
|
934
|
+
return
|
|
935
|
+
engine_id = tokens[1].strip().lower()
|
|
936
|
+
level = tokens[2].strip().lower()
|
|
937
|
+
if engine_id not in cfg.runtime.engine_ids:
|
|
938
|
+
await _respond_ephemeral(
|
|
939
|
+
cfg,
|
|
940
|
+
response_url=response_url,
|
|
941
|
+
channel_id=channel_id,
|
|
942
|
+
text=f"unknown engine: `{engine_id}`",
|
|
943
|
+
)
|
|
944
|
+
return
|
|
945
|
+
if level == "clear":
|
|
946
|
+
await thread_store.set_reasoning_override(
|
|
947
|
+
channel_id=channel_id,
|
|
948
|
+
thread_id=thread_id,
|
|
949
|
+
engine=engine_id,
|
|
950
|
+
level=None,
|
|
951
|
+
)
|
|
952
|
+
await _respond_ephemeral(
|
|
953
|
+
cfg,
|
|
954
|
+
response_url=response_url,
|
|
955
|
+
channel_id=channel_id,
|
|
956
|
+
text=f"reasoning override cleared for `{engine_id}`.",
|
|
957
|
+
)
|
|
958
|
+
return
|
|
959
|
+
if not is_valid_reasoning_level(level):
|
|
960
|
+
valid = ", ".join(sorted(REASONING_LEVELS))
|
|
961
|
+
await _respond_ephemeral(
|
|
962
|
+
cfg,
|
|
963
|
+
response_url=response_url,
|
|
964
|
+
channel_id=channel_id,
|
|
965
|
+
text=f"invalid reasoning level. valid: {valid}",
|
|
966
|
+
)
|
|
967
|
+
return
|
|
968
|
+
if not supports_reasoning(engine_id):
|
|
969
|
+
await _respond_ephemeral(
|
|
970
|
+
cfg,
|
|
971
|
+
response_url=response_url,
|
|
972
|
+
channel_id=channel_id,
|
|
973
|
+
text=f"engine `{engine_id}` does not support reasoning overrides.",
|
|
974
|
+
)
|
|
975
|
+
return
|
|
976
|
+
await thread_store.set_reasoning_override(
|
|
977
|
+
channel_id=channel_id,
|
|
978
|
+
thread_id=thread_id,
|
|
979
|
+
engine=engine_id,
|
|
980
|
+
level=level,
|
|
981
|
+
)
|
|
982
|
+
await _respond_ephemeral(
|
|
983
|
+
cfg,
|
|
984
|
+
response_url=response_url,
|
|
985
|
+
channel_id=channel_id,
|
|
986
|
+
text=f"reasoning override set to `{level}` for `{engine_id}`.",
|
|
987
|
+
)
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
if command_id == "session" and len(tokens) >= 2 and tokens[1].lower() == "clear":
|
|
991
|
+
await thread_store.clear_resumes(
|
|
992
|
+
channel_id=channel_id,
|
|
993
|
+
thread_id=thread_id,
|
|
994
|
+
)
|
|
995
|
+
await _respond_ephemeral(
|
|
996
|
+
cfg,
|
|
997
|
+
response_url=response_url,
|
|
998
|
+
channel_id=channel_id,
|
|
999
|
+
text="resume tokens cleared for this thread.",
|
|
1000
|
+
)
|
|
1001
|
+
return
|
|
1002
|
+
|
|
1003
|
+
if response_url:
|
|
1004
|
+
await _respond_ephemeral(
|
|
1005
|
+
cfg,
|
|
1006
|
+
response_url=response_url,
|
|
1007
|
+
channel_id=channel_id,
|
|
1008
|
+
text="running...",
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
command_context = await _resolve_command_context(
|
|
1012
|
+
cfg,
|
|
1013
|
+
channel_id=channel_id,
|
|
1014
|
+
thread_id=thread_id,
|
|
1015
|
+
)
|
|
1016
|
+
if command_context is None:
|
|
1017
|
+
return
|
|
1018
|
+
|
|
1019
|
+
handled = await dispatch_command(
|
|
1020
|
+
cfg,
|
|
1021
|
+
command_id=command_id,
|
|
1022
|
+
args_text=args_text,
|
|
1023
|
+
full_text=f"/{command_id} {args_text}".strip(),
|
|
1024
|
+
channel_id=channel_id,
|
|
1025
|
+
message_id="0",
|
|
1026
|
+
thread_id=thread_ts,
|
|
1027
|
+
reply_ref=None,
|
|
1028
|
+
reply_text=None,
|
|
1029
|
+
running_tasks=running_tasks,
|
|
1030
|
+
on_thread_known=command_context.on_thread_known,
|
|
1031
|
+
default_engine_override=command_context.default_engine_override,
|
|
1032
|
+
default_context=command_context.default_context,
|
|
1033
|
+
engine_overrides_resolver=command_context.engine_overrides_resolver,
|
|
1034
|
+
)
|
|
1035
|
+
if not handled:
|
|
1036
|
+
await _respond_ephemeral(
|
|
1037
|
+
cfg,
|
|
1038
|
+
response_url=response_url,
|
|
1039
|
+
channel_id=channel_id,
|
|
1040
|
+
text=f"unknown command `{command_id}`.",
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
async def _handle_interactive(
|
|
1045
|
+
cfg: SlackBridgeConfig,
|
|
1046
|
+
payload: dict[str, Any],
|
|
1047
|
+
running_tasks: RunningTasks,
|
|
1048
|
+
) -> None:
|
|
1049
|
+
payload_type = payload.get("type")
|
|
1050
|
+
if payload_type == "block_actions":
|
|
1051
|
+
await _handle_cancel_action(cfg, payload, running_tasks)
|
|
1052
|
+
return
|
|
1053
|
+
if payload_type in {"message_action", "shortcut"}:
|
|
1054
|
+
await _handle_shortcut(cfg, payload, running_tasks)
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
async def _handle_cancel_action(
|
|
1058
|
+
cfg: SlackBridgeConfig,
|
|
1059
|
+
payload: dict[str, Any],
|
|
1060
|
+
running_tasks: RunningTasks,
|
|
1061
|
+
) -> None:
|
|
1062
|
+
actions = payload.get("actions")
|
|
1063
|
+
if not isinstance(actions, list):
|
|
1064
|
+
return
|
|
1065
|
+
if not any(
|
|
1066
|
+
isinstance(action, dict) and action.get("action_id") == CANCEL_ACTION_ID
|
|
1067
|
+
for action in actions
|
|
1068
|
+
):
|
|
1069
|
+
return
|
|
1070
|
+
channel = payload.get("channel") or {}
|
|
1071
|
+
channel_id = channel.get("id") if isinstance(channel, dict) else None
|
|
1072
|
+
container = payload.get("container") or {}
|
|
1073
|
+
message = payload.get("message") or {}
|
|
1074
|
+
message_ts = None
|
|
1075
|
+
if isinstance(message, dict):
|
|
1076
|
+
message_ts = message.get("ts")
|
|
1077
|
+
if not message_ts and isinstance(container, dict):
|
|
1078
|
+
message_ts = container.get("message_ts")
|
|
1079
|
+
if not isinstance(channel_id, str) or not isinstance(message_ts, str):
|
|
1080
|
+
return
|
|
1081
|
+
|
|
1082
|
+
cancelled = _request_cancel(running_tasks, channel_id, message_ts)
|
|
1083
|
+
if not cancelled:
|
|
1084
|
+
return
|
|
1085
|
+
|
|
1086
|
+
response_url = payload.get("response_url")
|
|
1087
|
+
await _respond_ephemeral(
|
|
1088
|
+
cfg,
|
|
1089
|
+
response_url=response_url if isinstance(response_url, str) else None,
|
|
1090
|
+
channel_id=channel_id,
|
|
1091
|
+
text="cancellation requested.",
|
|
1092
|
+
)
|
|
1093
|
+
message_text = None
|
|
1094
|
+
if isinstance(message, dict):
|
|
1095
|
+
message_text = message.get("text")
|
|
1096
|
+
await cfg.client.update_message(
|
|
1097
|
+
channel_id=channel_id,
|
|
1098
|
+
ts=message_ts,
|
|
1099
|
+
text=message_text or "cancel requested",
|
|
1100
|
+
blocks=[],
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
async def _handle_shortcut(
|
|
1105
|
+
cfg: SlackBridgeConfig,
|
|
1106
|
+
payload: dict[str, Any],
|
|
1107
|
+
running_tasks: RunningTasks,
|
|
1108
|
+
) -> None:
|
|
1109
|
+
channel = payload.get("channel") or {}
|
|
1110
|
+
channel_id = channel.get("id") if isinstance(channel, dict) else None
|
|
1111
|
+
if not isinstance(channel_id, str) or channel_id != cfg.channel_id:
|
|
1112
|
+
return
|
|
1113
|
+
message = payload.get("message") or {}
|
|
1114
|
+
message_text = message.get("text") if isinstance(message, dict) else None
|
|
1115
|
+
message_ts = message.get("ts") if isinstance(message, dict) else None
|
|
1116
|
+
thread_ts = _parse_thread_ts(message.get("thread_ts") if isinstance(message, dict) else None)
|
|
1117
|
+
response_url = payload.get("response_url")
|
|
1118
|
+
if not isinstance(message_text, str) or not message_text.strip():
|
|
1119
|
+
await _respond_ephemeral(
|
|
1120
|
+
cfg,
|
|
1121
|
+
response_url=response_url if isinstance(response_url, str) else None,
|
|
1122
|
+
channel_id=channel_id,
|
|
1123
|
+
text="shortcut message has no text to process.",
|
|
1124
|
+
)
|
|
1125
|
+
return
|
|
1126
|
+
|
|
1127
|
+
callback_id = payload.get("callback_id") or payload.get("action_id")
|
|
1128
|
+
if not isinstance(callback_id, str) or not callback_id.startswith("takopi:"):
|
|
1129
|
+
return
|
|
1130
|
+
command_id = callback_id.split(":", 1)[1].strip().lower()
|
|
1131
|
+
if not command_id:
|
|
1132
|
+
return
|
|
1133
|
+
args_text = message_text.strip()
|
|
1134
|
+
|
|
1135
|
+
if response_url:
|
|
1136
|
+
await _respond_ephemeral(
|
|
1137
|
+
cfg,
|
|
1138
|
+
response_url=response_url,
|
|
1139
|
+
channel_id=channel_id,
|
|
1140
|
+
text="running...",
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
thread_id = _session_thread_id(channel_id, thread_ts)
|
|
1144
|
+
command_context = await _resolve_command_context(
|
|
1145
|
+
cfg,
|
|
1146
|
+
channel_id=channel_id,
|
|
1147
|
+
thread_id=thread_id,
|
|
1148
|
+
)
|
|
1149
|
+
if command_context is None:
|
|
1150
|
+
return
|
|
1151
|
+
|
|
1152
|
+
reply_ref = None
|
|
1153
|
+
reply_text = None
|
|
1154
|
+
if isinstance(message_ts, str):
|
|
1155
|
+
reply_ref = MessageRef(
|
|
1156
|
+
channel_id=channel_id,
|
|
1157
|
+
message_id=message_ts,
|
|
1158
|
+
thread_id=thread_ts,
|
|
1159
|
+
)
|
|
1160
|
+
reply_text = message_text
|
|
1161
|
+
|
|
1162
|
+
handled = await dispatch_command(
|
|
1163
|
+
cfg,
|
|
1164
|
+
command_id=command_id,
|
|
1165
|
+
args_text=args_text,
|
|
1166
|
+
full_text=f"/{command_id} {args_text}".strip(),
|
|
1167
|
+
channel_id=channel_id,
|
|
1168
|
+
message_id=message_ts if isinstance(message_ts, str) else "0",
|
|
1169
|
+
thread_id=thread_ts,
|
|
1170
|
+
reply_ref=reply_ref,
|
|
1171
|
+
reply_text=reply_text,
|
|
1172
|
+
running_tasks=running_tasks,
|
|
1173
|
+
on_thread_known=command_context.on_thread_known,
|
|
1174
|
+
default_engine_override=command_context.default_engine_override,
|
|
1175
|
+
default_context=command_context.default_context,
|
|
1176
|
+
engine_overrides_resolver=command_context.engine_overrides_resolver,
|
|
1177
|
+
)
|
|
1178
|
+
if not handled:
|
|
1179
|
+
await _respond_ephemeral(
|
|
1180
|
+
cfg,
|
|
1181
|
+
response_url=response_url if isinstance(response_url, str) else None,
|
|
1182
|
+
channel_id=channel_id,
|
|
1183
|
+
text=f"unknown command `{command_id}`.",
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _request_cancel(
|
|
1188
|
+
running_tasks: RunningTasks,
|
|
1189
|
+
channel_id: str,
|
|
1190
|
+
message_ts: str,
|
|
1191
|
+
) -> bool:
|
|
1192
|
+
for ref, task in list(running_tasks.items()):
|
|
1193
|
+
if str(ref.channel_id) == channel_id and str(ref.message_id) == message_ts:
|
|
1194
|
+
task.cancel_requested.set()
|
|
1195
|
+
return True
|
|
1196
|
+
return False
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _slash_usage() -> str:
|
|
1200
|
+
return (
|
|
1201
|
+
"usage:\n"
|
|
1202
|
+
"/takopi <command> [args]\n\n"
|
|
1203
|
+
"built-ins:\n"
|
|
1204
|
+
"/takopi status\n"
|
|
1205
|
+
"/takopi engine <engine|clear>\n"
|
|
1206
|
+
"/takopi model <engine> <model|clear>\n"
|
|
1207
|
+
"/takopi reasoning <engine> <level|clear>\n"
|
|
1208
|
+
"/takopi session clear\n"
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _format_status(state: dict[str, object] | None) -> str:
|
|
1213
|
+
if not state:
|
|
1214
|
+
return "no thread state found."
|
|
1215
|
+
lines = []
|
|
1216
|
+
context = state.get("context")
|
|
1217
|
+
if isinstance(context, dict):
|
|
1218
|
+
project = context.get("project")
|
|
1219
|
+
branch = context.get("branch")
|
|
1220
|
+
if project:
|
|
1221
|
+
if branch:
|
|
1222
|
+
lines.append(f"context: `{project}` `@{branch}`")
|
|
1223
|
+
else:
|
|
1224
|
+
lines.append(f"context: `{project}`")
|
|
1225
|
+
default_engine = state.get("default_engine")
|
|
1226
|
+
if isinstance(default_engine, str):
|
|
1227
|
+
lines.append(f"default engine: `{default_engine}`")
|
|
1228
|
+
model_overrides = state.get("model_overrides")
|
|
1229
|
+
if isinstance(model_overrides, dict) and model_overrides:
|
|
1230
|
+
models = ", ".join(
|
|
1231
|
+
f"{engine}={value}"
|
|
1232
|
+
for engine, value in sorted(model_overrides.items())
|
|
1233
|
+
)
|
|
1234
|
+
lines.append(f"model overrides: `{models}`")
|
|
1235
|
+
reasoning_overrides = state.get("reasoning_overrides")
|
|
1236
|
+
if isinstance(reasoning_overrides, dict) and reasoning_overrides:
|
|
1237
|
+
levels = ", ".join(
|
|
1238
|
+
f"{engine}={value}"
|
|
1239
|
+
for engine, value in sorted(reasoning_overrides.items())
|
|
1240
|
+
)
|
|
1241
|
+
lines.append(f"reasoning overrides: `{levels}`")
|
|
1242
|
+
resumes = state.get("resumes")
|
|
1243
|
+
if isinstance(resumes, dict) and resumes:
|
|
1244
|
+
lines.append("resume tokens stored: yes")
|
|
1245
|
+
if not lines:
|
|
1246
|
+
return "thread state is empty."
|
|
1247
|
+
return "\n".join(lines)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
async def _run_socket_loop(
|
|
1251
|
+
cfg: SlackBridgeConfig,
|
|
1252
|
+
*,
|
|
1253
|
+
bot_user_id: str | None,
|
|
1254
|
+
) -> None:
|
|
1255
|
+
if not cfg.app_token:
|
|
1256
|
+
raise ConfigError(
|
|
1257
|
+
"Missing transports.slack.app_token."
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
running_tasks: RunningTasks = {}
|
|
1261
|
+
backoff_s = 1.0
|
|
1262
|
+
|
|
1263
|
+
async with anyio.create_task_group() as tg:
|
|
1264
|
+
while True:
|
|
1265
|
+
try:
|
|
1266
|
+
socket_url = await open_socket_url(cfg.app_token)
|
|
1267
|
+
except SlackApiError as exc:
|
|
1268
|
+
logger.warning("slack.socket.open_failed", error=str(exc))
|
|
1269
|
+
await anyio.sleep(backoff_s)
|
|
1270
|
+
continue
|
|
1271
|
+
|
|
1272
|
+
try:
|
|
1273
|
+
async with websockets.connect(
|
|
1274
|
+
socket_url,
|
|
1275
|
+
ping_interval=10,
|
|
1276
|
+
ping_timeout=10,
|
|
1277
|
+
) as ws:
|
|
1278
|
+
while True:
|
|
1279
|
+
raw = await ws.recv()
|
|
1280
|
+
if isinstance(raw, bytes):
|
|
1281
|
+
raw = raw.decode("utf-8", "ignore")
|
|
1282
|
+
try:
|
|
1283
|
+
envelope = json.loads(raw)
|
|
1284
|
+
except json.JSONDecodeError:
|
|
1285
|
+
logger.warning("slack.socket.bad_payload")
|
|
1286
|
+
continue
|
|
1287
|
+
|
|
1288
|
+
envelope_id = envelope.get("envelope_id")
|
|
1289
|
+
if isinstance(envelope_id, str) and envelope_id:
|
|
1290
|
+
await ws.send(
|
|
1291
|
+
json.dumps({"envelope_id": envelope_id})
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
msg_type = envelope.get("type")
|
|
1295
|
+
if msg_type == "disconnect":
|
|
1296
|
+
logger.info("slack.socket.disconnect")
|
|
1297
|
+
break
|
|
1298
|
+
if msg_type == "slash_commands":
|
|
1299
|
+
payload = _coerce_socket_payload(
|
|
1300
|
+
envelope.get("payload")
|
|
1301
|
+
)
|
|
1302
|
+
if payload is not None:
|
|
1303
|
+
tg.start_soon(
|
|
1304
|
+
_handle_slash_command,
|
|
1305
|
+
cfg,
|
|
1306
|
+
payload,
|
|
1307
|
+
running_tasks,
|
|
1308
|
+
)
|
|
1309
|
+
continue
|
|
1310
|
+
if msg_type == "interactive":
|
|
1311
|
+
payload = _coerce_socket_payload(
|
|
1312
|
+
envelope.get("payload")
|
|
1313
|
+
)
|
|
1314
|
+
if payload is not None:
|
|
1315
|
+
tg.start_soon(
|
|
1316
|
+
_handle_interactive,
|
|
1317
|
+
cfg,
|
|
1318
|
+
payload,
|
|
1319
|
+
running_tasks,
|
|
1320
|
+
)
|
|
1321
|
+
continue
|
|
1322
|
+
if msg_type != "events_api":
|
|
1323
|
+
continue
|
|
1324
|
+
|
|
1325
|
+
payload = envelope.get("payload")
|
|
1326
|
+
if not isinstance(payload, dict):
|
|
1327
|
+
continue
|
|
1328
|
+
event = payload.get("event")
|
|
1329
|
+
if not isinstance(event, dict):
|
|
1330
|
+
continue
|
|
1331
|
+
|
|
1332
|
+
event_type = event.get("type")
|
|
1333
|
+
if event_type not in {"message", "app_mention"}:
|
|
1334
|
+
continue
|
|
1335
|
+
channel = event.get("channel")
|
|
1336
|
+
if channel != cfg.channel_id:
|
|
1337
|
+
continue
|
|
1338
|
+
|
|
1339
|
+
msg = SlackMessage.from_api(event)
|
|
1340
|
+
if _should_skip_message(msg, bot_user_id):
|
|
1341
|
+
continue
|
|
1342
|
+
cleaned = _strip_bot_mention(
|
|
1343
|
+
msg.text or "",
|
|
1344
|
+
bot_user_id=bot_user_id,
|
|
1345
|
+
)
|
|
1346
|
+
if not cleaned.strip():
|
|
1347
|
+
continue
|
|
1348
|
+
tg.start_soon(
|
|
1349
|
+
_safe_handle_slack_message,
|
|
1350
|
+
cfg,
|
|
1351
|
+
msg,
|
|
1352
|
+
cleaned,
|
|
1353
|
+
running_tasks,
|
|
1354
|
+
)
|
|
1355
|
+
except WebSocketException as exc:
|
|
1356
|
+
logger.warning("slack.socket_failed", error=str(exc))
|
|
1357
|
+
except OSError as exc:
|
|
1358
|
+
logger.warning("slack.socket_failed", error=str(exc))
|
|
1359
|
+
|
|
1360
|
+
await anyio.sleep(backoff_s)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
async def run_main_loop(
|
|
1364
|
+
cfg: SlackBridgeConfig,
|
|
1365
|
+
*,
|
|
1366
|
+
watch_config: bool | None = None,
|
|
1367
|
+
default_engine_override: str | None = None,
|
|
1368
|
+
transport_id: str | None = None,
|
|
1369
|
+
transport_config: object | None = None,
|
|
1370
|
+
) -> None:
|
|
1371
|
+
_ = watch_config, default_engine_override, transport_id, transport_config
|
|
1372
|
+
await _send_startup(cfg)
|
|
1373
|
+
bot_user_id: str | None = None
|
|
1374
|
+
try:
|
|
1375
|
+
auth = await cfg.client.auth_test()
|
|
1376
|
+
bot_user_id = auth.user_id
|
|
1377
|
+
except SlackApiError as exc:
|
|
1378
|
+
logger.warning("slack.auth_test_failed", error=str(exc))
|
|
1379
|
+
|
|
1380
|
+
await _run_socket_loop(cfg, bot_user_id=bot_user_id)
|