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.
@@ -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)