yee88 0.1.0__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.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1822 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from functools import partial
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ import anyio
10
+ from anyio.abc import TaskGroup
11
+
12
+ from ..config import ConfigError
13
+ from ..config_watch import ConfigReload, watch_config as watch_config_changes
14
+ from ..commands import list_command_ids
15
+ from ..directives import DirectiveError
16
+ from ..logging import get_logger
17
+ from ..model import EngineId, ResumeToken
18
+ from ..runners.run_options import EngineRunOptions
19
+ from ..scheduler import ThreadJob, ThreadScheduler
20
+ from ..progress import ProgressTracker
21
+ from ..settings import TelegramTransportSettings
22
+ from ..transport import MessageRef, SendOptions
23
+ from ..transport_runtime import ResolvedMessage
24
+ from ..context import RunContext
25
+ from ..ids import RESERVED_CHAT_COMMANDS
26
+ from .bridge import CANCEL_CALLBACK_DATA, TelegramBridgeConfig, send_plain
27
+ from .commands.cancel import handle_callback_cancel, handle_cancel
28
+ from .commands.file_transfer import FILE_PUT_USAGE
29
+ from .commands.handlers import (
30
+ dispatch_command,
31
+ handle_agent_command,
32
+ handle_chat_ctx_command,
33
+ handle_chat_new_command,
34
+ handle_ctx_command,
35
+ handle_file_command,
36
+ handle_file_put_default,
37
+ handle_media_group,
38
+ handle_model_command,
39
+ handle_new_command,
40
+ handle_reasoning_command,
41
+ handle_topic_command,
42
+ handle_trigger_command,
43
+ parse_slash_command,
44
+ get_reserved_commands,
45
+ run_engine,
46
+ save_file_put,
47
+ set_command_menu,
48
+ should_show_resume_line,
49
+ )
50
+ from .commands.parse import is_cancel_command
51
+ from .commands.reply import make_reply
52
+ from .context import _merge_topic_context, _usage_ctx_set, _usage_topic
53
+ from .topics import (
54
+ _maybe_rename_topic,
55
+ _resolve_topics_scope,
56
+ _topic_key,
57
+ _topics_chat_allowed,
58
+ _topics_chat_project,
59
+ _validate_topics_setup,
60
+ )
61
+ from .client import poll_incoming
62
+ from .chat_prefs import ChatPrefsStore, resolve_prefs_path
63
+ from .chat_sessions import ChatSessionStore, resolve_sessions_path
64
+ from .engine_overrides import merge_overrides
65
+ from .engine_defaults import resolve_engine_for_message
66
+ from .topic_state import TopicStateStore, resolve_state_path
67
+ from .trigger_mode import resolve_trigger_mode, should_trigger_run
68
+ from .types import (
69
+ TelegramCallbackQuery,
70
+ TelegramIncomingMessage,
71
+ TelegramIncomingUpdate,
72
+ )
73
+ from .voice import transcribe_voice
74
+
75
+ logger = get_logger(__name__)
76
+
77
+ __all__ = ["poll_updates", "run_main_loop", "send_with_resume"]
78
+
79
+ ForwardKey = tuple[int, int, int]
80
+
81
+ _handle_file_put_default = handle_file_put_default
82
+
83
+
84
+ def _chat_session_key(
85
+ msg: TelegramIncomingMessage, *, store: ChatSessionStore | None
86
+ ) -> tuple[int, int | None] | None:
87
+ if store is None or msg.thread_id is not None:
88
+ return None
89
+ if msg.chat_type == "private":
90
+ return (msg.chat_id, None)
91
+ if msg.sender_id is None:
92
+ return None
93
+ return (msg.chat_id, msg.sender_id)
94
+
95
+
96
+ async def _resolve_engine_run_options(
97
+ chat_id: int,
98
+ thread_id: int | None,
99
+ engine: EngineId,
100
+ chat_prefs: ChatPrefsStore | None,
101
+ topic_store: TopicStateStore | None,
102
+ system_prompt: str | None = None,
103
+ ) -> EngineRunOptions | None:
104
+ topic_override = None
105
+ if topic_store is not None and thread_id is not None:
106
+ topic_override = await topic_store.get_engine_override(
107
+ chat_id, thread_id, engine
108
+ )
109
+ chat_override = None
110
+ if chat_prefs is not None:
111
+ chat_override = await chat_prefs.get_engine_override(chat_id, engine)
112
+ merged = merge_overrides(topic_override, chat_override)
113
+ if merged is None and system_prompt is None:
114
+ return None
115
+ return EngineRunOptions(
116
+ model=merged.model if merged else None,
117
+ reasoning=merged.reasoning if merged else None,
118
+ system=system_prompt,
119
+ )
120
+
121
+
122
+ def _allowed_chat_ids(cfg: TelegramBridgeConfig) -> set[int]:
123
+ allowed = set(cfg.chat_ids or ())
124
+ allowed.add(cfg.chat_id)
125
+ allowed.update(cfg.runtime.project_chat_ids())
126
+ allowed.update(cfg.allowed_user_ids)
127
+ return allowed
128
+
129
+
130
+ async def _send_startup(cfg: TelegramBridgeConfig) -> None:
131
+ from ..markdown import MarkdownParts
132
+ from ..transport import RenderedMessage
133
+ from .render import prepare_telegram
134
+
135
+ logger.debug("startup.message", text=cfg.startup_msg)
136
+ parts = MarkdownParts(header=cfg.startup_msg)
137
+ text, entities = prepare_telegram(parts)
138
+ message = RenderedMessage(text=text, extra={"entities": entities})
139
+ sent = await cfg.exec_cfg.transport.send(
140
+ channel_id=cfg.chat_id,
141
+ message=message,
142
+ )
143
+ if sent is not None:
144
+ logger.info("startup.sent", chat_id=cfg.chat_id)
145
+
146
+
147
+ def _dispatch_builtin_command(
148
+ *,
149
+ ctx: TelegramCommandContext,
150
+ command_id: str,
151
+ ) -> bool:
152
+ cfg = ctx.cfg
153
+ msg = ctx.msg
154
+ args_text = ctx.args_text
155
+ ambient_context = ctx.ambient_context
156
+ topic_store = ctx.topic_store
157
+ chat_prefs = ctx.chat_prefs
158
+ resolved_scope = ctx.resolved_scope
159
+ scope_chat_ids = ctx.scope_chat_ids
160
+ reply = ctx.reply
161
+ task_group = ctx.task_group
162
+ if command_id == "file":
163
+ if not cfg.files.enabled:
164
+ handler = partial(
165
+ reply,
166
+ text="file transfer disabled; enable `[transports.telegram.files]`.",
167
+ )
168
+ else:
169
+ handler = partial(
170
+ handle_file_command,
171
+ cfg,
172
+ msg,
173
+ args_text,
174
+ ambient_context,
175
+ topic_store,
176
+ )
177
+ task_group.start_soon(handler)
178
+ return True
179
+
180
+ if command_id == "ctx":
181
+ topic_key = (
182
+ _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
183
+ if cfg.topics.enabled and topic_store is not None
184
+ else None
185
+ )
186
+ if topic_key is not None:
187
+ handler = partial(
188
+ handle_ctx_command,
189
+ cfg,
190
+ msg,
191
+ args_text,
192
+ topic_store,
193
+ resolved_scope=resolved_scope,
194
+ scope_chat_ids=scope_chat_ids,
195
+ )
196
+ else:
197
+ handler = partial(
198
+ handle_chat_ctx_command,
199
+ cfg,
200
+ msg,
201
+ args_text,
202
+ chat_prefs,
203
+ )
204
+ task_group.start_soon(handler)
205
+ return True
206
+
207
+ if cfg.topics.enabled and topic_store is not None:
208
+ if command_id == "new":
209
+ handler = partial(
210
+ handle_new_command,
211
+ cfg,
212
+ msg,
213
+ topic_store,
214
+ resolved_scope=resolved_scope,
215
+ scope_chat_ids=scope_chat_ids,
216
+ )
217
+ elif command_id == "topic":
218
+ handler = partial(
219
+ handle_topic_command,
220
+ cfg,
221
+ msg,
222
+ args_text,
223
+ topic_store,
224
+ resolved_scope=resolved_scope,
225
+ scope_chat_ids=scope_chat_ids,
226
+ )
227
+ else:
228
+ handler = None
229
+ if handler is not None:
230
+ task_group.start_soon(handler)
231
+ return True
232
+
233
+ if command_id == "model":
234
+ handler = partial(
235
+ handle_model_command,
236
+ cfg,
237
+ msg,
238
+ args_text,
239
+ ambient_context,
240
+ topic_store,
241
+ chat_prefs,
242
+ resolved_scope=resolved_scope,
243
+ scope_chat_ids=scope_chat_ids,
244
+ )
245
+ task_group.start_soon(handler)
246
+ return True
247
+
248
+ if command_id == "agent":
249
+ handler = partial(
250
+ handle_agent_command,
251
+ cfg,
252
+ msg,
253
+ args_text,
254
+ ambient_context,
255
+ topic_store,
256
+ chat_prefs,
257
+ resolved_scope=resolved_scope,
258
+ scope_chat_ids=scope_chat_ids,
259
+ )
260
+ task_group.start_soon(handler)
261
+ return True
262
+
263
+ if command_id == "reasoning":
264
+ handler = partial(
265
+ handle_reasoning_command,
266
+ cfg,
267
+ msg,
268
+ args_text,
269
+ ambient_context,
270
+ topic_store,
271
+ chat_prefs,
272
+ resolved_scope=resolved_scope,
273
+ scope_chat_ids=scope_chat_ids,
274
+ )
275
+ task_group.start_soon(handler)
276
+ return True
277
+
278
+ if command_id == "trigger":
279
+ handler = partial(
280
+ handle_trigger_command,
281
+ cfg,
282
+ msg,
283
+ args_text,
284
+ ambient_context,
285
+ topic_store,
286
+ chat_prefs,
287
+ resolved_scope=resolved_scope,
288
+ scope_chat_ids=scope_chat_ids,
289
+ )
290
+ task_group.start_soon(handler)
291
+ return True
292
+
293
+ return False
294
+
295
+
296
+ async def _drain_backlog(cfg: TelegramBridgeConfig, offset: int | None) -> int | None:
297
+ drained = 0
298
+ while True:
299
+ updates = await cfg.bot.get_updates(
300
+ offset=offset,
301
+ timeout_s=0,
302
+ allowed_updates=["message", "callback_query"],
303
+ )
304
+ if updates is None:
305
+ logger.info("startup.backlog.failed")
306
+ return offset
307
+ logger.debug("startup.backlog.updates", updates=updates)
308
+ if not updates:
309
+ if drained:
310
+ logger.info("startup.backlog.drained", count=drained)
311
+ return offset
312
+ offset = updates[-1].update_id + 1
313
+ drained += len(updates)
314
+
315
+
316
+ async def poll_updates(
317
+ cfg: TelegramBridgeConfig,
318
+ *,
319
+ sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
320
+ ) -> AsyncIterator[TelegramIncomingUpdate]:
321
+ offset: int | None = None
322
+ offset = await _drain_backlog(cfg, offset)
323
+ await _send_startup(cfg)
324
+
325
+ async for msg in poll_incoming(
326
+ cfg.bot,
327
+ chat_ids=lambda: _allowed_chat_ids(cfg),
328
+ offset=offset,
329
+ sleep=sleep,
330
+ ):
331
+ yield msg
332
+
333
+
334
+ @dataclass(slots=True)
335
+ class _MediaGroupState:
336
+ messages: list[TelegramIncomingMessage]
337
+ token: int = 0
338
+
339
+
340
+ @dataclass(slots=True)
341
+ class _PendingPrompt:
342
+ msg: TelegramIncomingMessage
343
+ text: str
344
+ ambient_context: RunContext | None
345
+ chat_project: str | None
346
+ topic_key: tuple[int, int] | None
347
+ chat_session_key: tuple[int, int | None] | None
348
+ reply_ref: MessageRef | None
349
+ reply_id: int | None
350
+ is_voice_transcribed: bool
351
+ forwards: list[tuple[int, str]]
352
+ cancel_scope: anyio.CancelScope | None = None
353
+
354
+
355
+ @dataclass(frozen=True, slots=True)
356
+ class TelegramMsgContext:
357
+ chat_id: int
358
+ thread_id: int | None
359
+ reply_id: int | None
360
+ reply_ref: MessageRef | None
361
+ topic_key: tuple[int, int] | None
362
+ chat_session_key: tuple[int, int | None] | None
363
+ stateful_mode: bool
364
+ chat_project: str | None
365
+ ambient_context: RunContext | None
366
+
367
+
368
+ @dataclass(frozen=True, slots=True)
369
+ class TelegramCommandContext:
370
+ cfg: TelegramBridgeConfig
371
+ msg: TelegramIncomingMessage
372
+ args_text: str
373
+ ambient_context: RunContext | None
374
+ topic_store: TopicStateStore | None
375
+ chat_prefs: ChatPrefsStore | None
376
+ resolved_scope: str | None
377
+ scope_chat_ids: frozenset[int]
378
+ reply: Callable[..., Awaitable[None]]
379
+ task_group: TaskGroup
380
+
381
+
382
+ @dataclass(slots=True)
383
+ class TelegramLoopState:
384
+ running_tasks: RunningTasks
385
+ pending_prompts: dict[ForwardKey, _PendingPrompt]
386
+ media_groups: dict[tuple[int, str], _MediaGroupState]
387
+ command_ids: set[str]
388
+ reserved_commands: set[str]
389
+ reserved_chat_commands: set[str]
390
+ transport_snapshot: dict[str, object] | None
391
+ topic_store: TopicStateStore | None
392
+ chat_session_store: ChatSessionStore | None
393
+ chat_prefs: ChatPrefsStore | None
394
+ resolved_topics_scope: str | None
395
+ topics_chat_ids: frozenset[int]
396
+ bot_username: str | None
397
+ forward_coalesce_s: float
398
+ media_group_debounce_s: float
399
+ transport_id: str | None
400
+
401
+
402
+ if TYPE_CHECKING:
403
+ from ..runner_bridge import RunningTasks
404
+
405
+
406
+ _FORWARD_FIELDS = (
407
+ "forward_origin",
408
+ "forward_from",
409
+ "forward_from_chat",
410
+ "forward_from_message_id",
411
+ "forward_sender_name",
412
+ "forward_signature",
413
+ "forward_date",
414
+ "is_automatic_forward",
415
+ )
416
+
417
+
418
+ def _forward_key(msg: TelegramIncomingMessage) -> ForwardKey:
419
+ return (msg.chat_id, msg.thread_id or 0, msg.sender_id or 0)
420
+
421
+
422
+ def _is_forwarded(raw: dict[str, object] | None) -> bool:
423
+ if not isinstance(raw, dict):
424
+ return False
425
+ return any(raw.get(field) is not None for field in _FORWARD_FIELDS)
426
+
427
+
428
+ def _forward_fields_present(raw: dict[str, object] | None) -> list[str]:
429
+ if not isinstance(raw, dict):
430
+ return []
431
+ return [field for field in _FORWARD_FIELDS if raw.get(field) is not None]
432
+
433
+
434
+ def _format_forwarded_prompt(forwarded: list[str], prompt: str) -> str:
435
+ if not forwarded:
436
+ return prompt
437
+ separator = "\n\n"
438
+ forward_block = separator.join(forwarded)
439
+ if prompt.strip():
440
+ return f"{prompt}{separator}{forward_block}"
441
+ return forward_block
442
+
443
+
444
+ class ForwardCoalescer:
445
+ def __init__(
446
+ self,
447
+ *,
448
+ task_group: TaskGroup,
449
+ debounce_s: float,
450
+ sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
451
+ dispatch: Callable[[_PendingPrompt], Awaitable[None]],
452
+ pending: dict[ForwardKey, _PendingPrompt],
453
+ ) -> None:
454
+ self._task_group = task_group
455
+ self._debounce_s = debounce_s
456
+ self._sleep = sleep
457
+ self._dispatch = dispatch
458
+ self._pending = pending
459
+
460
+ def cancel(self, key: ForwardKey) -> None:
461
+ pending = self._pending.pop(key, None)
462
+ if pending is None:
463
+ return
464
+ if pending.cancel_scope is not None:
465
+ pending.cancel_scope.cancel()
466
+ logger.debug(
467
+ "forward.prompt.cancelled",
468
+ chat_id=pending.msg.chat_id,
469
+ thread_id=pending.msg.thread_id,
470
+ sender_id=pending.msg.sender_id,
471
+ message_id=pending.msg.message_id,
472
+ forward_count=len(pending.forwards),
473
+ )
474
+
475
+ def schedule(self, pending: _PendingPrompt) -> None:
476
+ if pending.msg.sender_id is None:
477
+ logger.debug(
478
+ "forward.prompt.bypass",
479
+ chat_id=pending.msg.chat_id,
480
+ thread_id=pending.msg.thread_id,
481
+ sender_id=pending.msg.sender_id,
482
+ message_id=pending.msg.message_id,
483
+ reason="missing_sender",
484
+ )
485
+ self._task_group.start_soon(self._dispatch, pending)
486
+ return
487
+ if self._debounce_s <= 0:
488
+ logger.debug(
489
+ "forward.prompt.bypass",
490
+ chat_id=pending.msg.chat_id,
491
+ thread_id=pending.msg.thread_id,
492
+ sender_id=pending.msg.sender_id,
493
+ message_id=pending.msg.message_id,
494
+ reason="disabled",
495
+ )
496
+ self._task_group.start_soon(self._dispatch, pending)
497
+ return
498
+ key = _forward_key(pending.msg)
499
+ existing = self._pending.get(key)
500
+ if existing is not None:
501
+ if existing.cancel_scope is not None:
502
+ existing.cancel_scope.cancel()
503
+ if existing.forwards:
504
+ pending.forwards = list(existing.forwards)
505
+ logger.debug(
506
+ "forward.prompt.replace",
507
+ chat_id=pending.msg.chat_id,
508
+ thread_id=pending.msg.thread_id,
509
+ sender_id=pending.msg.sender_id,
510
+ old_message_id=existing.msg.message_id,
511
+ new_message_id=pending.msg.message_id,
512
+ forward_count=len(pending.forwards),
513
+ )
514
+ self._pending[key] = pending
515
+ logger.debug(
516
+ "forward.prompt.schedule",
517
+ chat_id=pending.msg.chat_id,
518
+ thread_id=pending.msg.thread_id,
519
+ sender_id=pending.msg.sender_id,
520
+ message_id=pending.msg.message_id,
521
+ debounce_s=self._debounce_s,
522
+ )
523
+ self._reschedule(key, pending)
524
+
525
+ def attach_forward(self, msg: TelegramIncomingMessage) -> None:
526
+ if msg.sender_id is None:
527
+ logger.debug(
528
+ "forward.message.ignored",
529
+ chat_id=msg.chat_id,
530
+ thread_id=msg.thread_id,
531
+ sender_id=msg.sender_id,
532
+ message_id=msg.message_id,
533
+ reason="missing_sender",
534
+ )
535
+ return
536
+ key = _forward_key(msg)
537
+ pending = self._pending.get(key)
538
+ if pending is None:
539
+ logger.debug(
540
+ "forward.message.ignored",
541
+ chat_id=msg.chat_id,
542
+ thread_id=msg.thread_id,
543
+ sender_id=msg.sender_id,
544
+ message_id=msg.message_id,
545
+ reason="no_pending_prompt",
546
+ )
547
+ return
548
+ text = msg.text
549
+ if not text.strip():
550
+ logger.debug(
551
+ "forward.message.ignored",
552
+ chat_id=msg.chat_id,
553
+ thread_id=msg.thread_id,
554
+ sender_id=msg.sender_id,
555
+ message_id=msg.message_id,
556
+ reason="empty_text",
557
+ )
558
+ return
559
+ pending.forwards.append((msg.message_id, text))
560
+ logger.debug(
561
+ "forward.message.attached",
562
+ chat_id=msg.chat_id,
563
+ thread_id=msg.thread_id,
564
+ sender_id=msg.sender_id,
565
+ message_id=msg.message_id,
566
+ prompt_message_id=pending.msg.message_id,
567
+ forward_count=len(pending.forwards),
568
+ forward_fields=_forward_fields_present(msg.raw),
569
+ forward_date=msg.raw.get("forward_date") if msg.raw else None,
570
+ message_date=msg.raw.get("date") if msg.raw else None,
571
+ text_len=len(text),
572
+ )
573
+ self._reschedule(key, pending)
574
+
575
+ def _reschedule(self, key: ForwardKey, pending: _PendingPrompt) -> None:
576
+ if pending.cancel_scope is not None:
577
+ pending.cancel_scope.cancel()
578
+ pending.cancel_scope = None
579
+ self._task_group.start_soon(self._debounce_prompt_run, key, pending)
580
+
581
+ async def _debounce_prompt_run(
582
+ self,
583
+ key: ForwardKey,
584
+ pending: _PendingPrompt,
585
+ ) -> None:
586
+ try:
587
+ with anyio.CancelScope() as scope:
588
+ pending.cancel_scope = scope
589
+ await self._sleep(self._debounce_s)
590
+ except anyio.get_cancelled_exc_class():
591
+ return
592
+ if self._pending.get(key) is not pending:
593
+ return
594
+ self._pending.pop(key, None)
595
+ logger.debug(
596
+ "forward.prompt.run",
597
+ chat_id=pending.msg.chat_id,
598
+ thread_id=pending.msg.thread_id,
599
+ sender_id=pending.msg.sender_id,
600
+ message_id=pending.msg.message_id,
601
+ forward_count=len(pending.forwards),
602
+ debounce_s=self._debounce_s,
603
+ )
604
+ await self._dispatch(pending)
605
+
606
+
607
+ @dataclass(frozen=True, slots=True)
608
+ class ResumeDecision:
609
+ resume_token: ResumeToken | None
610
+ handled_by_running_task: bool
611
+
612
+
613
+ class ResumeResolver:
614
+ def __init__(
615
+ self,
616
+ *,
617
+ cfg: TelegramBridgeConfig,
618
+ task_group: TaskGroup,
619
+ running_tasks: Mapping[MessageRef, object],
620
+ enqueue_resume: Callable[
621
+ [
622
+ int,
623
+ int,
624
+ str,
625
+ ResumeToken,
626
+ RunContext | None,
627
+ int | None,
628
+ tuple[int, int | None] | None,
629
+ MessageRef | None,
630
+ ],
631
+ Awaitable[None],
632
+ ],
633
+ topic_store: TopicStateStore | None,
634
+ chat_session_store: ChatSessionStore | None,
635
+ ) -> None:
636
+ self._cfg = cfg
637
+ self._task_group = task_group
638
+ self._running_tasks = running_tasks
639
+ self._enqueue_resume = enqueue_resume
640
+ self._topic_store = topic_store
641
+ self._chat_session_store = chat_session_store
642
+
643
+ async def resolve(
644
+ self,
645
+ *,
646
+ resume_token: ResumeToken | None,
647
+ reply_id: int | None,
648
+ chat_id: int,
649
+ user_msg_id: int,
650
+ thread_id: int | None,
651
+ chat_session_key: tuple[int, int | None] | None,
652
+ topic_key: tuple[int, int] | None,
653
+ engine_for_session: EngineId,
654
+ prompt_text: str,
655
+ ) -> ResumeDecision:
656
+ if resume_token is not None:
657
+ return ResumeDecision(
658
+ resume_token=resume_token, handled_by_running_task=False
659
+ )
660
+ if reply_id is not None:
661
+ running_task = self._running_tasks.get(
662
+ MessageRef(channel_id=chat_id, message_id=reply_id)
663
+ )
664
+ if running_task is not None:
665
+ self._task_group.start_soon(
666
+ send_with_resume,
667
+ self._cfg,
668
+ self._enqueue_resume,
669
+ running_task,
670
+ chat_id,
671
+ user_msg_id,
672
+ thread_id,
673
+ chat_session_key,
674
+ prompt_text,
675
+ )
676
+ return ResumeDecision(resume_token=None, handled_by_running_task=True)
677
+ if self._topic_store is not None and topic_key is not None:
678
+ stored = await self._topic_store.get_session_resume(
679
+ topic_key[0],
680
+ topic_key[1],
681
+ engine_for_session,
682
+ )
683
+ if stored is not None:
684
+ resume_token = stored
685
+ if (
686
+ resume_token is None
687
+ and self._chat_session_store is not None
688
+ and chat_session_key is not None
689
+ ):
690
+ stored = await self._chat_session_store.get_session_resume(
691
+ chat_session_key[0],
692
+ chat_session_key[1],
693
+ engine_for_session,
694
+ )
695
+ if stored is not None:
696
+ resume_token = stored
697
+ return ResumeDecision(resume_token=resume_token, handled_by_running_task=False)
698
+
699
+
700
+ class MediaGroupBuffer:
701
+ def __init__(
702
+ self,
703
+ *,
704
+ task_group: TaskGroup,
705
+ debounce_s: float,
706
+ sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
707
+ cfg: TelegramBridgeConfig,
708
+ chat_prefs: ChatPrefsStore | None,
709
+ topic_store: TopicStateStore | None,
710
+ bot_username: str | None,
711
+ command_ids: Callable[[], set[str]],
712
+ reserved_chat_commands: set[str],
713
+ groups: dict[tuple[int, str], _MediaGroupState],
714
+ run_prompt_from_upload: Callable[
715
+ [TelegramIncomingMessage, str, ResolvedMessage], Awaitable[None]
716
+ ],
717
+ resolve_prompt_message: Callable[
718
+ [TelegramIncomingMessage, str, RunContext | None],
719
+ Awaitable[ResolvedMessage | None],
720
+ ],
721
+ ) -> None:
722
+ self._task_group = task_group
723
+ self._debounce_s = debounce_s
724
+ self._sleep = sleep
725
+ self._cfg = cfg
726
+ self._chat_prefs = chat_prefs
727
+ self._topic_store = topic_store
728
+ self._bot_username = bot_username
729
+ self._command_ids = command_ids
730
+ self._reserved_chat_commands = reserved_chat_commands
731
+ self._groups = groups
732
+ self._run_prompt_from_upload = run_prompt_from_upload
733
+ self._resolve_prompt_message = resolve_prompt_message
734
+
735
+ def add(self, msg: TelegramIncomingMessage) -> None:
736
+ if msg.media_group_id is None:
737
+ return
738
+ key = (msg.chat_id, msg.media_group_id)
739
+ state = self._groups.get(key)
740
+ if state is None:
741
+ state = _MediaGroupState(messages=[])
742
+ self._groups[key] = state
743
+ self._task_group.start_soon(self._flush_media_group, key)
744
+ state.messages.append(msg)
745
+ state.token += 1
746
+
747
+ async def _flush_media_group(self, key: tuple[int, str]) -> None:
748
+ while True:
749
+ state = self._groups.get(key)
750
+ if state is None:
751
+ return
752
+ token = state.token
753
+ await self._sleep(self._debounce_s)
754
+ state = self._groups.get(key)
755
+ if state is None:
756
+ return
757
+ if state.token != token:
758
+ continue
759
+ messages = list(state.messages)
760
+ del self._groups[key]
761
+ if not messages:
762
+ return
763
+ trigger_mode = await resolve_trigger_mode(
764
+ chat_id=messages[0].chat_id,
765
+ thread_id=messages[0].thread_id,
766
+ chat_prefs=self._chat_prefs,
767
+ topic_store=self._topic_store,
768
+ )
769
+ command_ids = self._command_ids()
770
+ if trigger_mode == "mentions" and not any(
771
+ should_trigger_run(
772
+ msg,
773
+ bot_username=self._bot_username,
774
+ runtime=self._cfg.runtime,
775
+ command_ids=command_ids,
776
+ reserved_chat_commands=self._reserved_chat_commands,
777
+ )
778
+ for msg in messages
779
+ ):
780
+ return
781
+ await handle_media_group(
782
+ self._cfg,
783
+ messages,
784
+ self._topic_store,
785
+ self._run_prompt_from_upload,
786
+ self._resolve_prompt_message,
787
+ )
788
+ return
789
+
790
+
791
+ def _diff_keys(old: dict[str, object], new: dict[str, object]) -> list[str]:
792
+ keys = set(old) | set(new)
793
+ return sorted(key for key in keys if old.get(key) != new.get(key))
794
+
795
+
796
+ async def _wait_for_resume(running_task) -> ResumeToken | None:
797
+ if running_task.resume is not None:
798
+ return running_task.resume
799
+ resume: ResumeToken | None = None
800
+
801
+ async with anyio.create_task_group() as tg:
802
+
803
+ async def wait_resume() -> None:
804
+ nonlocal resume
805
+ await running_task.resume_ready.wait()
806
+ resume = running_task.resume
807
+ tg.cancel_scope.cancel()
808
+
809
+ async def wait_done() -> None:
810
+ await running_task.done.wait()
811
+ tg.cancel_scope.cancel()
812
+
813
+ tg.start_soon(wait_resume)
814
+ tg.start_soon(wait_done)
815
+
816
+ return resume
817
+
818
+
819
+ async def _send_queued_progress(
820
+ cfg: TelegramBridgeConfig,
821
+ *,
822
+ chat_id: int,
823
+ user_msg_id: int,
824
+ thread_id: int | None,
825
+ resume_token: ResumeToken,
826
+ context: RunContext | None,
827
+ ) -> MessageRef | None:
828
+ tracker = ProgressTracker(engine=resume_token.engine)
829
+ tracker.set_resume(resume_token)
830
+ context_line = cfg.runtime.format_context_line(context)
831
+ state = tracker.snapshot(context_line=context_line)
832
+ message = cfg.exec_cfg.presenter.render_progress(
833
+ state,
834
+ elapsed_s=0.0,
835
+ label="queued",
836
+ )
837
+ reply_ref = MessageRef(
838
+ channel_id=chat_id,
839
+ message_id=user_msg_id,
840
+ thread_id=thread_id,
841
+ )
842
+ return await cfg.exec_cfg.transport.send(
843
+ channel_id=chat_id,
844
+ message=message,
845
+ options=SendOptions(reply_to=reply_ref, notify=False, thread_id=thread_id),
846
+ )
847
+
848
+
849
+ async def send_with_resume(
850
+ cfg: TelegramBridgeConfig,
851
+ enqueue: Callable[
852
+ [
853
+ int,
854
+ int,
855
+ str,
856
+ ResumeToken,
857
+ RunContext | None,
858
+ int | None,
859
+ tuple[int, int | None] | None,
860
+ MessageRef | None,
861
+ ],
862
+ Awaitable[None],
863
+ ],
864
+ running_task,
865
+ chat_id: int,
866
+ user_msg_id: int,
867
+ thread_id: int | None,
868
+ session_key: tuple[int, int | None] | None,
869
+ text: str,
870
+ ) -> None:
871
+ reply = partial(
872
+ send_plain,
873
+ cfg.exec_cfg.transport,
874
+ chat_id=chat_id,
875
+ user_msg_id=user_msg_id,
876
+ thread_id=thread_id,
877
+ )
878
+ resume = await _wait_for_resume(running_task)
879
+ if resume is None:
880
+ await reply(
881
+ text="resume token not ready yet; try replying to the final message.",
882
+ notify=False,
883
+ )
884
+ return
885
+ progress_ref = await _send_queued_progress(
886
+ cfg,
887
+ chat_id=chat_id,
888
+ user_msg_id=user_msg_id,
889
+ thread_id=thread_id,
890
+ resume_token=resume,
891
+ context=running_task.context,
892
+ )
893
+ await enqueue(
894
+ chat_id,
895
+ user_msg_id,
896
+ text,
897
+ resume,
898
+ running_task.context,
899
+ thread_id,
900
+ session_key,
901
+ progress_ref,
902
+ )
903
+
904
+
905
+ async def run_main_loop(
906
+ cfg: TelegramBridgeConfig,
907
+ poller: Callable[
908
+ [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate]
909
+ ] = poll_updates,
910
+ *,
911
+ watch_config: bool | None = None,
912
+ default_engine_override: str | None = None,
913
+ transport_id: str | None = None,
914
+ transport_config: TelegramTransportSettings | None = None,
915
+ sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
916
+ ) -> None:
917
+ state = TelegramLoopState(
918
+ running_tasks={},
919
+ pending_prompts={},
920
+ media_groups={},
921
+ command_ids={
922
+ command_id.lower()
923
+ for command_id in list_command_ids(allowlist=cfg.runtime.allowlist)
924
+ },
925
+ reserved_commands=get_reserved_commands(cfg.runtime),
926
+ reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
927
+ transport_snapshot=(
928
+ transport_config.model_dump() if transport_config is not None else None
929
+ ),
930
+ topic_store=None,
931
+ chat_session_store=None,
932
+ chat_prefs=None,
933
+ resolved_topics_scope=None,
934
+ topics_chat_ids=frozenset(),
935
+ bot_username=None,
936
+ forward_coalesce_s=max(0.0, float(cfg.forward_coalesce_s)),
937
+ media_group_debounce_s=max(0.0, float(cfg.media_group_debounce_s)),
938
+ transport_id=transport_id,
939
+ )
940
+
941
+ def refresh_topics_scope() -> None:
942
+ if cfg.topics.enabled:
943
+ (
944
+ state.resolved_topics_scope,
945
+ state.topics_chat_ids,
946
+ ) = _resolve_topics_scope(cfg)
947
+ else:
948
+ state.resolved_topics_scope = None
949
+ state.topics_chat_ids = frozenset()
950
+
951
+ def refresh_commands() -> None:
952
+ allowlist = cfg.runtime.allowlist
953
+ state.command_ids = {
954
+ command_id.lower() for command_id in list_command_ids(allowlist=allowlist)
955
+ }
956
+ state.reserved_commands = get_reserved_commands(cfg.runtime)
957
+
958
+ try:
959
+ config_path = cfg.runtime.config_path
960
+ if config_path is not None:
961
+ state.chat_prefs = ChatPrefsStore(resolve_prefs_path(config_path))
962
+ logger.info(
963
+ "chat_prefs.enabled",
964
+ state_path=str(resolve_prefs_path(config_path)),
965
+ )
966
+ if cfg.session_mode == "chat":
967
+ if config_path is None:
968
+ raise ConfigError(
969
+ "session_mode=chat but config path is not set; cannot locate state file."
970
+ )
971
+ state.chat_session_store = ChatSessionStore(
972
+ resolve_sessions_path(config_path)
973
+ )
974
+ cleared = await state.chat_session_store.sync_startup_cwd(Path.cwd())
975
+ if cleared:
976
+ logger.info(
977
+ "chat_sessions.cleared",
978
+ reason="startup_cwd_changed",
979
+ cwd=str(Path.cwd()),
980
+ state_path=str(resolve_sessions_path(config_path)),
981
+ )
982
+ logger.info(
983
+ "chat_sessions.enabled",
984
+ state_path=str(resolve_sessions_path(config_path)),
985
+ )
986
+ if cfg.topics.enabled:
987
+ if config_path is None:
988
+ raise ConfigError(
989
+ "topics enabled but config path is not set; cannot locate state file."
990
+ )
991
+ state.topic_store = TopicStateStore(resolve_state_path(config_path))
992
+ await _validate_topics_setup(cfg)
993
+ refresh_topics_scope()
994
+ logger.info(
995
+ "topics.enabled",
996
+ scope=cfg.topics.scope,
997
+ resolved_scope=state.resolved_topics_scope,
998
+ state_path=str(resolve_state_path(config_path)),
999
+ )
1000
+ await set_command_menu(cfg)
1001
+ try:
1002
+ me = await cfg.bot.get_me()
1003
+ except Exception as exc: # noqa: BLE001
1004
+ logger.info(
1005
+ "trigger_mode.bot_username.failed",
1006
+ error=str(exc),
1007
+ error_type=exc.__class__.__name__,
1008
+ )
1009
+ me = None
1010
+ if me is not None and me.username:
1011
+ state.bot_username = me.username.lower()
1012
+ else:
1013
+ logger.info("trigger_mode.bot_username.unavailable")
1014
+ async with anyio.create_task_group() as tg:
1015
+ poller_fn: Callable[
1016
+ [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate]
1017
+ ]
1018
+ if poller is poll_updates:
1019
+ poller_fn = cast(
1020
+ Callable[
1021
+ [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate]
1022
+ ],
1023
+ partial(poll_updates, sleep=sleep),
1024
+ )
1025
+ else:
1026
+ poller_fn = poller
1027
+ config_path = cfg.runtime.config_path
1028
+ watch_enabled = bool(watch_config) and config_path is not None
1029
+
1030
+ async def handle_reload(reload: ConfigReload) -> None:
1031
+ refresh_commands()
1032
+ refresh_topics_scope()
1033
+ await set_command_menu(cfg)
1034
+ if state.transport_snapshot is not None:
1035
+ new_snapshot = reload.settings.transports.telegram.model_dump()
1036
+ changed = _diff_keys(state.transport_snapshot, new_snapshot)
1037
+ if changed:
1038
+ logger.warning(
1039
+ "config.reload.transport_config_changed",
1040
+ transport="telegram",
1041
+ keys=changed,
1042
+ restart_required=True,
1043
+ )
1044
+ state.transport_snapshot = new_snapshot
1045
+ if (
1046
+ state.transport_id is not None
1047
+ and reload.settings.transport != state.transport_id
1048
+ ):
1049
+ logger.warning(
1050
+ "config.reload.transport_changed",
1051
+ old=state.transport_id,
1052
+ new=reload.settings.transport,
1053
+ restart_required=True,
1054
+ )
1055
+ state.transport_id = reload.settings.transport
1056
+
1057
+ if watch_enabled and config_path is not None:
1058
+
1059
+ async def run_config_watch() -> None:
1060
+ await watch_config_changes(
1061
+ config_path=config_path,
1062
+ runtime=cfg.runtime,
1063
+ default_engine_override=default_engine_override,
1064
+ on_reload=handle_reload,
1065
+ )
1066
+
1067
+ tg.start_soon(run_config_watch)
1068
+
1069
+ def wrap_on_thread_known(
1070
+ base_cb: Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None,
1071
+ topic_key: tuple[int, int] | None,
1072
+ chat_session_key: tuple[int, int | None] | None,
1073
+ ) -> Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None:
1074
+ if base_cb is None and topic_key is None and chat_session_key is None:
1075
+ return None
1076
+
1077
+ async def _wrapped(token: ResumeToken, done: anyio.Event) -> None:
1078
+ if base_cb is not None:
1079
+ await base_cb(token, done)
1080
+ if state.topic_store is not None and topic_key is not None:
1081
+ await state.topic_store.set_session_resume(
1082
+ topic_key[0], topic_key[1], token
1083
+ )
1084
+ if (
1085
+ state.chat_session_store is not None
1086
+ and chat_session_key is not None
1087
+ ):
1088
+ await state.chat_session_store.set_session_resume(
1089
+ chat_session_key[0], chat_session_key[1], token
1090
+ )
1091
+
1092
+ return _wrapped
1093
+
1094
+ async def run_job(
1095
+ chat_id: int,
1096
+ user_msg_id: int,
1097
+ text: str,
1098
+ resume_token: ResumeToken | None,
1099
+ context: RunContext | None,
1100
+ thread_id: int | None = None,
1101
+ chat_session_key: tuple[int, int | None] | None = None,
1102
+ reply_ref: MessageRef | None = None,
1103
+ on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]]
1104
+ | None = None,
1105
+ engine_override: EngineId | None = None,
1106
+ progress_ref: MessageRef | None = None,
1107
+ ) -> None:
1108
+ topic_key = (
1109
+ (chat_id, thread_id)
1110
+ if state.topic_store is not None
1111
+ and thread_id is not None
1112
+ and _topics_chat_allowed(
1113
+ cfg, chat_id, scope_chat_ids=state.topics_chat_ids
1114
+ )
1115
+ else None
1116
+ )
1117
+ stateful_mode = topic_key is not None or chat_session_key is not None
1118
+ show_resume_line = should_show_resume_line(
1119
+ show_resume_line=cfg.show_resume_line,
1120
+ stateful_mode=stateful_mode,
1121
+ context=context,
1122
+ )
1123
+ engine_for_overrides = (
1124
+ resume_token.engine
1125
+ if resume_token is not None
1126
+ else engine_override
1127
+ if engine_override is not None
1128
+ else cfg.runtime.resolve_engine(
1129
+ engine_override=None,
1130
+ context=context,
1131
+ )
1132
+ )
1133
+ overrides_thread_id = topic_key[1] if topic_key is not None else None
1134
+ run_options = await _resolve_engine_run_options(
1135
+ chat_id,
1136
+ overrides_thread_id,
1137
+ engine_for_overrides,
1138
+ chat_prefs=state.chat_prefs,
1139
+ topic_store=state.topic_store,
1140
+ system_prompt=cfg.runtime.resolve_system_prompt(context),
1141
+ )
1142
+ await run_engine(
1143
+ exec_cfg=cfg.exec_cfg,
1144
+ runtime=cfg.runtime,
1145
+ running_tasks=state.running_tasks,
1146
+ chat_id=chat_id,
1147
+ user_msg_id=user_msg_id,
1148
+ text=text,
1149
+ resume_token=resume_token,
1150
+ context=context,
1151
+ reply_ref=reply_ref,
1152
+ on_thread_known=wrap_on_thread_known(
1153
+ on_thread_known, topic_key, chat_session_key
1154
+ ),
1155
+ engine_override=engine_override,
1156
+ thread_id=thread_id,
1157
+ show_resume_line=show_resume_line,
1158
+ progress_ref=progress_ref,
1159
+ run_options=run_options,
1160
+ )
1161
+
1162
+ async def run_thread_job(job: ThreadJob) -> None:
1163
+ await run_job(
1164
+ cast(int, job.chat_id),
1165
+ cast(int, job.user_msg_id),
1166
+ job.text,
1167
+ job.resume_token,
1168
+ job.context,
1169
+ cast(int | None, job.thread_id),
1170
+ job.session_key,
1171
+ None,
1172
+ scheduler.note_thread_known,
1173
+ None,
1174
+ job.progress_ref,
1175
+ )
1176
+
1177
+ scheduler = ThreadScheduler(task_group=tg, run_job=run_thread_job)
1178
+
1179
+ def resolve_topic_key(
1180
+ msg: TelegramIncomingMessage,
1181
+ ) -> tuple[int, int] | None:
1182
+ if state.topic_store is None:
1183
+ return None
1184
+ return _topic_key(msg, cfg, scope_chat_ids=state.topics_chat_ids)
1185
+
1186
+ def _build_upload_prompt(base: str, annotation: str) -> str:
1187
+ if base and base.strip():
1188
+ return f"{base}\n\n{annotation}"
1189
+ return annotation
1190
+
1191
+ async def resolve_prompt_message(
1192
+ msg: TelegramIncomingMessage,
1193
+ text: str,
1194
+ ambient_context: RunContext | None,
1195
+ ) -> ResolvedMessage | None:
1196
+ reply = make_reply(cfg, msg)
1197
+ try:
1198
+ resolved = cfg.runtime.resolve_message(
1199
+ text=text,
1200
+ reply_text=msg.reply_to_text,
1201
+ ambient_context=ambient_context,
1202
+ chat_id=msg.chat_id,
1203
+ )
1204
+ except DirectiveError as exc:
1205
+ await reply(text=f"error:\n{exc}")
1206
+ return None
1207
+ topic_key = resolve_topic_key(msg)
1208
+ effective_context = ambient_context
1209
+ if (
1210
+ state.topic_store is not None
1211
+ and topic_key is not None
1212
+ and resolved.context is not None
1213
+ and resolved.context_source == "directives"
1214
+ ):
1215
+ await state.topic_store.set_context(*topic_key, resolved.context)
1216
+ await _maybe_rename_topic(
1217
+ cfg,
1218
+ state.topic_store,
1219
+ chat_id=topic_key[0],
1220
+ thread_id=topic_key[1],
1221
+ context=resolved.context,
1222
+ )
1223
+ effective_context = resolved.context
1224
+ if (
1225
+ state.topic_store is not None
1226
+ and topic_key is not None
1227
+ and effective_context is None
1228
+ and resolved.context_source not in {"directives", "reply_ctx"}
1229
+ ):
1230
+ chat_project = (
1231
+ _topics_chat_project(cfg, msg.chat_id)
1232
+ if cfg.topics.enabled
1233
+ else None
1234
+ )
1235
+ await reply(
1236
+ text="this topic isn't bound to a project yet.\n"
1237
+ f"{_usage_ctx_set(chat_project=chat_project)} or "
1238
+ f"{_usage_topic(chat_project=chat_project)}",
1239
+ )
1240
+ return None
1241
+ return resolved
1242
+
1243
+ async def resolve_engine_defaults(
1244
+ *,
1245
+ explicit_engine: EngineId | None,
1246
+ context: RunContext | None,
1247
+ chat_id: int,
1248
+ topic_key: tuple[int, int] | None,
1249
+ ):
1250
+ return await resolve_engine_for_message(
1251
+ runtime=cfg.runtime,
1252
+ context=context,
1253
+ explicit_engine=explicit_engine,
1254
+ chat_id=chat_id,
1255
+ topic_key=topic_key,
1256
+ topic_store=state.topic_store,
1257
+ chat_prefs=state.chat_prefs,
1258
+ )
1259
+
1260
+ resume_resolver = ResumeResolver(
1261
+ cfg=cfg,
1262
+ task_group=tg,
1263
+ running_tasks=state.running_tasks,
1264
+ enqueue_resume=scheduler.enqueue_resume,
1265
+ topic_store=state.topic_store,
1266
+ chat_session_store=state.chat_session_store,
1267
+ )
1268
+
1269
+ async def run_prompt_from_upload(
1270
+ msg: TelegramIncomingMessage,
1271
+ prompt_text: str,
1272
+ resolved: ResolvedMessage,
1273
+ ) -> None:
1274
+ chat_id = msg.chat_id
1275
+ user_msg_id = msg.message_id
1276
+ reply_id = msg.reply_to_message_id
1277
+ reply_ref = (
1278
+ MessageRef(
1279
+ channel_id=msg.chat_id,
1280
+ message_id=msg.reply_to_message_id,
1281
+ thread_id=msg.thread_id,
1282
+ )
1283
+ if msg.reply_to_message_id is not None
1284
+ else None
1285
+ )
1286
+ resume_token = resolved.resume_token
1287
+ context = resolved.context
1288
+ chat_session_key = _chat_session_key(
1289
+ msg, store=state.chat_session_store
1290
+ )
1291
+ topic_key = resolve_topic_key(msg)
1292
+ engine_resolution = await resolve_engine_defaults(
1293
+ explicit_engine=resolved.engine_override,
1294
+ context=context,
1295
+ chat_id=chat_id,
1296
+ topic_key=topic_key,
1297
+ )
1298
+ engine_override = engine_resolution.engine
1299
+ resume_decision = await resume_resolver.resolve(
1300
+ resume_token=resume_token,
1301
+ reply_id=reply_id,
1302
+ chat_id=chat_id,
1303
+ user_msg_id=user_msg_id,
1304
+ thread_id=msg.thread_id,
1305
+ chat_session_key=chat_session_key,
1306
+ topic_key=topic_key,
1307
+ engine_for_session=engine_resolution.engine,
1308
+ prompt_text=prompt_text,
1309
+ )
1310
+ if resume_decision.handled_by_running_task:
1311
+ return
1312
+ resume_token = resume_decision.resume_token
1313
+ if resume_token is None:
1314
+ await run_job(
1315
+ chat_id,
1316
+ user_msg_id,
1317
+ prompt_text,
1318
+ None,
1319
+ context,
1320
+ msg.thread_id,
1321
+ chat_session_key,
1322
+ reply_ref,
1323
+ scheduler.note_thread_known,
1324
+ engine_override,
1325
+ )
1326
+ return
1327
+ progress_ref = await _send_queued_progress(
1328
+ cfg,
1329
+ chat_id=chat_id,
1330
+ user_msg_id=user_msg_id,
1331
+ thread_id=msg.thread_id,
1332
+ resume_token=resume_token,
1333
+ context=context,
1334
+ )
1335
+ await scheduler.enqueue_resume(
1336
+ chat_id,
1337
+ user_msg_id,
1338
+ prompt_text,
1339
+ resume_token,
1340
+ context,
1341
+ msg.thread_id,
1342
+ chat_session_key,
1343
+ progress_ref,
1344
+ )
1345
+
1346
+ async def _dispatch_pending_prompt(pending: _PendingPrompt) -> None:
1347
+ msg = pending.msg
1348
+ chat_id = msg.chat_id
1349
+ user_msg_id = msg.message_id
1350
+ reply = make_reply(cfg, msg)
1351
+ try:
1352
+ resolved = cfg.runtime.resolve_message(
1353
+ text=pending.text,
1354
+ reply_text=msg.reply_to_text,
1355
+ ambient_context=pending.ambient_context,
1356
+ chat_id=chat_id,
1357
+ )
1358
+ except DirectiveError as exc:
1359
+ await reply(text=f"error:\n{exc}")
1360
+ return
1361
+ if pending.is_voice_transcribed:
1362
+ resolved = ResolvedMessage(
1363
+ prompt=f"(voice transcribed) {resolved.prompt}",
1364
+ resume_token=resolved.resume_token,
1365
+ engine_override=resolved.engine_override,
1366
+ context=resolved.context,
1367
+ context_source=resolved.context_source,
1368
+ )
1369
+
1370
+ prompt_text = resolved.prompt
1371
+ if pending.forwards:
1372
+ forwarded = [
1373
+ text
1374
+ for _, text in sorted(
1375
+ pending.forwards,
1376
+ key=lambda item: item[0],
1377
+ )
1378
+ ]
1379
+ prompt_text = _format_forwarded_prompt(
1380
+ forwarded,
1381
+ prompt_text,
1382
+ )
1383
+
1384
+ resume_token = resolved.resume_token
1385
+ context = resolved.context
1386
+ engine_resolution = await resolve_engine_defaults(
1387
+ explicit_engine=resolved.engine_override,
1388
+ context=context,
1389
+ chat_id=chat_id,
1390
+ topic_key=pending.topic_key,
1391
+ )
1392
+ engine_override = engine_resolution.engine
1393
+ effective_context = pending.ambient_context
1394
+ if (
1395
+ state.topic_store is not None
1396
+ and pending.topic_key is not None
1397
+ and resolved.context is not None
1398
+ and resolved.context_source == "directives"
1399
+ ):
1400
+ await state.topic_store.set_context(
1401
+ *pending.topic_key, resolved.context
1402
+ )
1403
+ await _maybe_rename_topic(
1404
+ cfg,
1405
+ state.topic_store,
1406
+ chat_id=pending.topic_key[0],
1407
+ thread_id=pending.topic_key[1],
1408
+ context=resolved.context,
1409
+ )
1410
+ effective_context = resolved.context
1411
+ if (
1412
+ state.topic_store is not None
1413
+ and pending.topic_key is not None
1414
+ and effective_context is None
1415
+ and resolved.context_source not in {"directives", "reply_ctx"}
1416
+ ):
1417
+ await reply(
1418
+ text="this topic isn't bound to a project yet.\n"
1419
+ f"{_usage_ctx_set(chat_project=pending.chat_project)} or "
1420
+ f"{_usage_topic(chat_project=pending.chat_project)}",
1421
+ )
1422
+ return
1423
+ resume_decision = await resume_resolver.resolve(
1424
+ resume_token=resume_token,
1425
+ reply_id=pending.reply_id,
1426
+ chat_id=chat_id,
1427
+ user_msg_id=user_msg_id,
1428
+ thread_id=msg.thread_id,
1429
+ chat_session_key=pending.chat_session_key,
1430
+ topic_key=pending.topic_key,
1431
+ engine_for_session=engine_resolution.engine,
1432
+ prompt_text=prompt_text,
1433
+ )
1434
+ if resume_decision.handled_by_running_task:
1435
+ return
1436
+ resume_token = resume_decision.resume_token
1437
+
1438
+ if resume_token is None:
1439
+ tg.start_soon(
1440
+ run_job,
1441
+ chat_id,
1442
+ user_msg_id,
1443
+ prompt_text,
1444
+ None,
1445
+ context,
1446
+ msg.thread_id,
1447
+ pending.chat_session_key,
1448
+ pending.reply_ref,
1449
+ scheduler.note_thread_known,
1450
+ engine_override,
1451
+ )
1452
+ return
1453
+ progress_ref = await _send_queued_progress(
1454
+ cfg,
1455
+ chat_id=chat_id,
1456
+ user_msg_id=user_msg_id,
1457
+ thread_id=msg.thread_id,
1458
+ resume_token=resume_token,
1459
+ context=context,
1460
+ )
1461
+ await scheduler.enqueue_resume(
1462
+ chat_id,
1463
+ user_msg_id,
1464
+ prompt_text,
1465
+ resume_token,
1466
+ context,
1467
+ msg.thread_id,
1468
+ pending.chat_session_key,
1469
+ progress_ref,
1470
+ )
1471
+
1472
+ forward_coalescer = ForwardCoalescer(
1473
+ task_group=tg,
1474
+ debounce_s=state.forward_coalesce_s,
1475
+ sleep=sleep,
1476
+ dispatch=_dispatch_pending_prompt,
1477
+ pending=state.pending_prompts,
1478
+ )
1479
+
1480
+ async def handle_prompt_upload(
1481
+ msg: TelegramIncomingMessage,
1482
+ caption_text: str,
1483
+ ambient_context: RunContext | None,
1484
+ topic_store: TopicStateStore | None,
1485
+ ) -> None:
1486
+ resolved = await resolve_prompt_message(
1487
+ msg,
1488
+ caption_text,
1489
+ ambient_context,
1490
+ )
1491
+ if resolved is None:
1492
+ return
1493
+ saved = await save_file_put(
1494
+ cfg,
1495
+ msg,
1496
+ "",
1497
+ resolved.context,
1498
+ topic_store,
1499
+ )
1500
+ if saved is None:
1501
+ return
1502
+ annotation = f"[uploaded file: {saved.rel_path.as_posix()}]"
1503
+ prompt = _build_upload_prompt(resolved.prompt, annotation)
1504
+ await run_prompt_from_upload(msg, prompt, resolved)
1505
+
1506
+ media_group_buffer = MediaGroupBuffer(
1507
+ task_group=tg,
1508
+ debounce_s=state.media_group_debounce_s,
1509
+ sleep=sleep,
1510
+ cfg=cfg,
1511
+ chat_prefs=state.chat_prefs,
1512
+ topic_store=state.topic_store,
1513
+ bot_username=state.bot_username,
1514
+ command_ids=lambda: state.command_ids,
1515
+ reserved_chat_commands=state.reserved_chat_commands,
1516
+ groups=state.media_groups,
1517
+ run_prompt_from_upload=run_prompt_from_upload,
1518
+ resolve_prompt_message=resolve_prompt_message,
1519
+ )
1520
+
1521
+ async def build_message_context(
1522
+ msg: TelegramIncomingMessage,
1523
+ ) -> TelegramMsgContext:
1524
+ chat_id = msg.chat_id
1525
+ reply_id = msg.reply_to_message_id
1526
+ reply_ref = (
1527
+ MessageRef(channel_id=chat_id, message_id=reply_id)
1528
+ if reply_id is not None
1529
+ else None
1530
+ )
1531
+ topic_key = resolve_topic_key(msg)
1532
+ chat_session_key = _chat_session_key(
1533
+ msg, store=state.chat_session_store
1534
+ )
1535
+ stateful_mode = topic_key is not None or chat_session_key is not None
1536
+ chat_project = (
1537
+ _topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None
1538
+ )
1539
+ bound_context = (
1540
+ await state.topic_store.get_context(*topic_key)
1541
+ if state.topic_store is not None and topic_key is not None
1542
+ else None
1543
+ )
1544
+ chat_bound_context = None
1545
+ if state.chat_prefs is not None:
1546
+ chat_bound_context = await state.chat_prefs.get_context(chat_id)
1547
+ if bound_context is not None:
1548
+ ambient_context = _merge_topic_context(
1549
+ chat_project=chat_project, bound=bound_context
1550
+ )
1551
+ elif chat_bound_context is not None:
1552
+ ambient_context = chat_bound_context
1553
+ else:
1554
+ ambient_context = _merge_topic_context(
1555
+ chat_project=chat_project, bound=None
1556
+ )
1557
+ return TelegramMsgContext(
1558
+ chat_id=chat_id,
1559
+ thread_id=msg.thread_id,
1560
+ reply_id=reply_id,
1561
+ reply_ref=reply_ref,
1562
+ topic_key=topic_key,
1563
+ chat_session_key=chat_session_key,
1564
+ stateful_mode=stateful_mode,
1565
+ chat_project=chat_project,
1566
+ ambient_context=ambient_context,
1567
+ )
1568
+
1569
+ async def route_message(msg: TelegramIncomingMessage) -> None:
1570
+ reply = make_reply(cfg, msg)
1571
+ text = msg.text
1572
+ is_voice_transcribed = False
1573
+ is_forward_candidate = (
1574
+ _is_forwarded(msg.raw)
1575
+ and msg.document is None
1576
+ and msg.voice is None
1577
+ and msg.media_group_id is None
1578
+ )
1579
+ if is_forward_candidate:
1580
+ forward_coalescer.attach_forward(msg)
1581
+ return
1582
+ forward_key = _forward_key(msg)
1583
+ if (
1584
+ cfg.files.enabled
1585
+ and msg.document is not None
1586
+ and msg.media_group_id is not None
1587
+ ):
1588
+ media_group_buffer.add(msg)
1589
+ return
1590
+ ctx = await build_message_context(msg)
1591
+ chat_id = ctx.chat_id
1592
+ reply_id = ctx.reply_id
1593
+ reply_ref = ctx.reply_ref
1594
+ topic_key = ctx.topic_key
1595
+ chat_session_key = ctx.chat_session_key
1596
+ stateful_mode = ctx.stateful_mode
1597
+ chat_project = ctx.chat_project
1598
+ ambient_context = ctx.ambient_context
1599
+
1600
+ if is_cancel_command(text):
1601
+ tg.start_soon(
1602
+ handle_cancel, cfg, msg, state.running_tasks, scheduler
1603
+ )
1604
+ return
1605
+
1606
+ command_id, args_text = parse_slash_command(text)
1607
+ if command_id == "new":
1608
+ forward_coalescer.cancel(forward_key)
1609
+ if state.topic_store is not None and topic_key is not None:
1610
+ tg.start_soon(
1611
+ partial(
1612
+ handle_new_command,
1613
+ cfg,
1614
+ msg,
1615
+ state.topic_store,
1616
+ resolved_scope=state.resolved_topics_scope,
1617
+ scope_chat_ids=state.topics_chat_ids,
1618
+ )
1619
+ )
1620
+ return
1621
+ if state.chat_session_store is not None:
1622
+ tg.start_soon(
1623
+ handle_chat_new_command,
1624
+ cfg,
1625
+ msg,
1626
+ state.chat_session_store,
1627
+ chat_session_key,
1628
+ )
1629
+ return
1630
+ if state.topic_store is not None:
1631
+ tg.start_soon(
1632
+ partial(
1633
+ handle_new_command,
1634
+ cfg,
1635
+ msg,
1636
+ state.topic_store,
1637
+ resolved_scope=state.resolved_topics_scope,
1638
+ scope_chat_ids=state.topics_chat_ids,
1639
+ )
1640
+ )
1641
+ return
1642
+ if command_id is not None and _dispatch_builtin_command(
1643
+ ctx=TelegramCommandContext(
1644
+ cfg=cfg,
1645
+ msg=msg,
1646
+ args_text=args_text,
1647
+ ambient_context=ambient_context,
1648
+ topic_store=state.topic_store,
1649
+ chat_prefs=state.chat_prefs,
1650
+ resolved_scope=state.resolved_topics_scope,
1651
+ scope_chat_ids=state.topics_chat_ids,
1652
+ reply=reply,
1653
+ task_group=tg,
1654
+ ),
1655
+ command_id=command_id,
1656
+ ):
1657
+ return
1658
+
1659
+ trigger_mode = await resolve_trigger_mode(
1660
+ chat_id=chat_id,
1661
+ thread_id=msg.thread_id,
1662
+ chat_prefs=state.chat_prefs,
1663
+ topic_store=state.topic_store,
1664
+ )
1665
+ if trigger_mode == "mentions" and not should_trigger_run(
1666
+ msg,
1667
+ bot_username=state.bot_username,
1668
+ runtime=cfg.runtime,
1669
+ command_ids=state.command_ids,
1670
+ reserved_chat_commands=state.reserved_chat_commands,
1671
+ ):
1672
+ return
1673
+
1674
+ if msg.voice is not None:
1675
+ text = await transcribe_voice(
1676
+ bot=cfg.bot,
1677
+ msg=msg,
1678
+ enabled=cfg.voice_transcription,
1679
+ model=cfg.voice_transcription_model,
1680
+ max_bytes=cfg.voice_max_bytes,
1681
+ reply=reply,
1682
+ base_url=cfg.voice_transcription_base_url,
1683
+ api_key=cfg.voice_transcription_api_key,
1684
+ )
1685
+ if text is None:
1686
+ return
1687
+ is_voice_transcribed = True
1688
+ if msg.document is not None:
1689
+ if cfg.files.enabled and cfg.files.auto_put:
1690
+ caption_text = text.strip()
1691
+ if cfg.files.auto_put_mode == "prompt" and caption_text:
1692
+ tg.start_soon(
1693
+ handle_prompt_upload,
1694
+ msg,
1695
+ caption_text,
1696
+ ambient_context,
1697
+ state.topic_store,
1698
+ )
1699
+ elif not caption_text:
1700
+ tg.start_soon(
1701
+ handle_file_put_default,
1702
+ cfg,
1703
+ msg,
1704
+ ambient_context,
1705
+ state.topic_store,
1706
+ )
1707
+ else:
1708
+ tg.start_soon(
1709
+ partial(reply, text=FILE_PUT_USAGE),
1710
+ )
1711
+ elif cfg.files.enabled:
1712
+ tg.start_soon(
1713
+ partial(reply, text=FILE_PUT_USAGE),
1714
+ )
1715
+ return
1716
+ if command_id is not None and command_id not in state.reserved_commands:
1717
+ if command_id not in state.command_ids:
1718
+ refresh_commands()
1719
+ if command_id in state.command_ids:
1720
+ engine_resolution = await resolve_engine_defaults(
1721
+ explicit_engine=None,
1722
+ context=ambient_context,
1723
+ chat_id=chat_id,
1724
+ topic_key=topic_key,
1725
+ )
1726
+ default_engine_override = (
1727
+ engine_resolution.engine
1728
+ if engine_resolution.source
1729
+ in {"directive", "topic_default", "chat_default"}
1730
+ else None
1731
+ )
1732
+ overrides_thread_id = (
1733
+ topic_key[1] if topic_key is not None else None
1734
+ )
1735
+ engine_overrides_resolver = partial(
1736
+ _resolve_engine_run_options,
1737
+ chat_id,
1738
+ overrides_thread_id,
1739
+ chat_prefs=state.chat_prefs,
1740
+ topic_store=state.topic_store,
1741
+ )
1742
+ tg.start_soon(
1743
+ dispatch_command,
1744
+ cfg,
1745
+ msg,
1746
+ text,
1747
+ command_id,
1748
+ args_text,
1749
+ state.running_tasks,
1750
+ scheduler,
1751
+ wrap_on_thread_known(
1752
+ scheduler.note_thread_known,
1753
+ topic_key,
1754
+ chat_session_key,
1755
+ ),
1756
+ stateful_mode,
1757
+ default_engine_override,
1758
+ engine_overrides_resolver,
1759
+ )
1760
+ return
1761
+
1762
+ pending = _PendingPrompt(
1763
+ msg=msg,
1764
+ text=text,
1765
+ ambient_context=ambient_context,
1766
+ chat_project=chat_project,
1767
+ topic_key=topic_key,
1768
+ chat_session_key=chat_session_key,
1769
+ reply_ref=reply_ref,
1770
+ reply_id=reply_id,
1771
+ is_voice_transcribed=is_voice_transcribed,
1772
+ forwards=[],
1773
+ )
1774
+ if reply_id is not None and state.running_tasks.get(
1775
+ MessageRef(channel_id=chat_id, message_id=reply_id)
1776
+ ):
1777
+ logger.debug(
1778
+ "forward.prompt.bypass",
1779
+ chat_id=chat_id,
1780
+ thread_id=msg.thread_id,
1781
+ sender_id=msg.sender_id,
1782
+ message_id=msg.message_id,
1783
+ reason="reply_resume",
1784
+ )
1785
+ tg.start_soon(_dispatch_pending_prompt, pending)
1786
+ return
1787
+ forward_coalescer.schedule(pending)
1788
+
1789
+ allowed_user_ids = set(cfg.allowed_user_ids)
1790
+
1791
+ async def route_update(update: TelegramIncomingUpdate) -> None:
1792
+ if allowed_user_ids:
1793
+ sender_id = update.sender_id
1794
+ if sender_id is None or sender_id not in allowed_user_ids:
1795
+ logger.debug(
1796
+ "update.ignored",
1797
+ reason="sender_not_allowed",
1798
+ chat_id=update.chat_id,
1799
+ sender_id=sender_id,
1800
+ )
1801
+ return
1802
+ if isinstance(update, TelegramCallbackQuery):
1803
+ if update.data == CANCEL_CALLBACK_DATA:
1804
+ tg.start_soon(
1805
+ handle_callback_cancel,
1806
+ cfg,
1807
+ update,
1808
+ state.running_tasks,
1809
+ scheduler,
1810
+ )
1811
+ else:
1812
+ tg.start_soon(
1813
+ cfg.bot.answer_callback_query,
1814
+ update.callback_query_id,
1815
+ )
1816
+ return
1817
+ await route_message(update)
1818
+
1819
+ async for update in poller_fn(cfg):
1820
+ await route_update(update)
1821
+ finally:
1822
+ await cfg.exec_cfg.transport.close()