yee88 0.3.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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/telegram/loop.py
ADDED
|
@@ -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()
|