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.
- takopi/__init__.py +1 -0
- takopi/api.py +116 -0
- takopi/backends.py +25 -0
- takopi/backends_helpers.py +14 -0
- takopi/cli/__init__.py +228 -0
- takopi/cli/config.py +320 -0
- takopi/cli/doctor.py +173 -0
- takopi/cli/init.py +113 -0
- takopi/cli/onboarding_cmd.py +126 -0
- takopi/cli/plugins.py +196 -0
- takopi/cli/run.py +419 -0
- takopi/cli/topic.py +355 -0
- takopi/commands.py +134 -0
- takopi/config.py +142 -0
- takopi/config_migrations.py +124 -0
- takopi/config_watch.py +146 -0
- takopi/context.py +9 -0
- takopi/directives.py +146 -0
- takopi/engines.py +53 -0
- takopi/events.py +170 -0
- takopi/ids.py +17 -0
- takopi/lockfile.py +158 -0
- takopi/logging.py +283 -0
- takopi/markdown.py +298 -0
- takopi/model.py +77 -0
- takopi/plugins.py +312 -0
- takopi/presenter.py +25 -0
- takopi/progress.py +99 -0
- takopi/router.py +113 -0
- takopi/runner.py +712 -0
- takopi/runner_bridge.py +619 -0
- takopi/runners/__init__.py +1 -0
- takopi/runners/claude.py +483 -0
- takopi/runners/codex.py +656 -0
- takopi/runners/mock.py +221 -0
- takopi/runners/opencode.py +505 -0
- takopi/runners/pi.py +523 -0
- takopi/runners/run_options.py +39 -0
- takopi/runners/tool_actions.py +90 -0
- takopi/runtime_loader.py +207 -0
- takopi/scheduler.py +159 -0
- takopi/schemas/__init__.py +1 -0
- takopi/schemas/claude.py +238 -0
- takopi/schemas/codex.py +169 -0
- takopi/schemas/opencode.py +51 -0
- takopi/schemas/pi.py +117 -0
- takopi/settings.py +360 -0
- takopi/telegram/__init__.py +20 -0
- takopi/telegram/api_models.py +37 -0
- takopi/telegram/api_schemas.py +152 -0
- takopi/telegram/backend.py +163 -0
- takopi/telegram/bridge.py +425 -0
- takopi/telegram/chat_prefs.py +242 -0
- takopi/telegram/chat_sessions.py +112 -0
- takopi/telegram/client.py +409 -0
- takopi/telegram/client_api.py +539 -0
- takopi/telegram/commands/__init__.py +12 -0
- takopi/telegram/commands/agent.py +196 -0
- takopi/telegram/commands/cancel.py +116 -0
- takopi/telegram/commands/dispatch.py +111 -0
- takopi/telegram/commands/executor.py +449 -0
- takopi/telegram/commands/file_transfer.py +586 -0
- takopi/telegram/commands/handlers.py +45 -0
- takopi/telegram/commands/media.py +143 -0
- takopi/telegram/commands/menu.py +139 -0
- takopi/telegram/commands/model.py +215 -0
- takopi/telegram/commands/overrides.py +159 -0
- takopi/telegram/commands/parse.py +30 -0
- takopi/telegram/commands/plan.py +16 -0
- takopi/telegram/commands/reasoning.py +234 -0
- takopi/telegram/commands/reply.py +23 -0
- takopi/telegram/commands/topics.py +332 -0
- takopi/telegram/commands/trigger.py +143 -0
- takopi/telegram/context.py +140 -0
- takopi/telegram/engine_defaults.py +86 -0
- takopi/telegram/engine_overrides.py +105 -0
- takopi/telegram/files.py +178 -0
- takopi/telegram/loop.py +1822 -0
- takopi/telegram/onboarding.py +1088 -0
- takopi/telegram/outbox.py +177 -0
- takopi/telegram/parsing.py +239 -0
- takopi/telegram/render.py +198 -0
- takopi/telegram/state_store.py +88 -0
- takopi/telegram/topic_state.py +334 -0
- takopi/telegram/topics.py +256 -0
- takopi/telegram/trigger_mode.py +68 -0
- takopi/telegram/types.py +63 -0
- takopi/telegram/voice.py +110 -0
- takopi/transport.py +53 -0
- takopi/transport_runtime.py +323 -0
- takopi/transports.py +76 -0
- takopi/utils/__init__.py +1 -0
- takopi/utils/git.py +87 -0
- takopi/utils/json_state.py +21 -0
- takopi/utils/paths.py +47 -0
- takopi/utils/streams.py +44 -0
- takopi/utils/subprocess.py +86 -0
- takopi/worktrees.py +135 -0
- yee88-0.1.0.dist-info/METADATA +116 -0
- yee88-0.1.0.dist-info/RECORD +103 -0
- yee88-0.1.0.dist-info/WHEEL +4 -0
- yee88-0.1.0.dist-info/entry_points.txt +11 -0
- yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal, cast
|
|
6
|
+
|
|
7
|
+
from ..logging import get_logger
|
|
8
|
+
from ..markdown import MarkdownFormatter, MarkdownParts
|
|
9
|
+
from ..progress import ProgressState
|
|
10
|
+
from ..runner_bridge import ExecBridgeConfig, RunningTask, RunningTasks
|
|
11
|
+
from ..transport import MessageRef, RenderedMessage, SendOptions, Transport
|
|
12
|
+
from ..transport_runtime import TransportRuntime
|
|
13
|
+
from ..context import RunContext
|
|
14
|
+
from ..model import ResumeToken
|
|
15
|
+
from ..scheduler import ThreadScheduler
|
|
16
|
+
from ..settings import (
|
|
17
|
+
TelegramFilesSettings,
|
|
18
|
+
TelegramTopicsSettings,
|
|
19
|
+
TelegramTransportSettings,
|
|
20
|
+
)
|
|
21
|
+
from .client import BotClient
|
|
22
|
+
from .render import MAX_BODY_CHARS, prepare_telegram, prepare_telegram_multi
|
|
23
|
+
from .types import TelegramCallbackQuery, TelegramIncomingMessage
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"TelegramBridgeConfig",
|
|
29
|
+
"TelegramPresenter",
|
|
30
|
+
"TelegramTransport",
|
|
31
|
+
"build_bot_commands",
|
|
32
|
+
"handle_callback_cancel",
|
|
33
|
+
"handle_cancel",
|
|
34
|
+
"is_cancel_command",
|
|
35
|
+
"run_main_loop",
|
|
36
|
+
"send_with_resume",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
CANCEL_CALLBACK_DATA = "takopi:cancel"
|
|
40
|
+
CANCEL_MARKUP = {
|
|
41
|
+
"inline_keyboard": [[{"text": "cancel", "callback_data": CANCEL_CALLBACK_DATA}]]
|
|
42
|
+
}
|
|
43
|
+
CLEAR_MARKUP = {"inline_keyboard": []}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TelegramPresenter:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
formatter: MarkdownFormatter | None = None,
|
|
51
|
+
message_overflow: str = "trim",
|
|
52
|
+
) -> None:
|
|
53
|
+
self._formatter = formatter or MarkdownFormatter()
|
|
54
|
+
self._message_overflow = message_overflow
|
|
55
|
+
|
|
56
|
+
def render_progress(
|
|
57
|
+
self,
|
|
58
|
+
state: ProgressState,
|
|
59
|
+
*,
|
|
60
|
+
elapsed_s: float,
|
|
61
|
+
label: str = "working",
|
|
62
|
+
) -> RenderedMessage:
|
|
63
|
+
parts = self._formatter.render_progress_parts(
|
|
64
|
+
state, elapsed_s=elapsed_s, label=label
|
|
65
|
+
)
|
|
66
|
+
text, entities = prepare_telegram(parts)
|
|
67
|
+
reply_markup = CLEAR_MARKUP if _is_cancelled_label(label) else CANCEL_MARKUP
|
|
68
|
+
return RenderedMessage(
|
|
69
|
+
text=text,
|
|
70
|
+
extra={"entities": entities, "reply_markup": reply_markup},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def render_final(
|
|
74
|
+
self,
|
|
75
|
+
state: ProgressState,
|
|
76
|
+
*,
|
|
77
|
+
elapsed_s: float,
|
|
78
|
+
status: str,
|
|
79
|
+
answer: str,
|
|
80
|
+
) -> RenderedMessage:
|
|
81
|
+
parts = self._formatter.render_final_parts(
|
|
82
|
+
state, elapsed_s=elapsed_s, status=status, answer=answer
|
|
83
|
+
)
|
|
84
|
+
if self._message_overflow == "split":
|
|
85
|
+
payloads = prepare_telegram_multi(parts, max_body_chars=MAX_BODY_CHARS)
|
|
86
|
+
text, entities = payloads[0]
|
|
87
|
+
extra = {"entities": entities, "reply_markup": CLEAR_MARKUP}
|
|
88
|
+
if len(payloads) > 1:
|
|
89
|
+
followups = [
|
|
90
|
+
RenderedMessage(
|
|
91
|
+
text=followup_text,
|
|
92
|
+
extra={
|
|
93
|
+
"entities": followup_entities,
|
|
94
|
+
"reply_markup": CLEAR_MARKUP,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
for followup_text, followup_entities in payloads[1:]
|
|
98
|
+
]
|
|
99
|
+
extra["followups"] = followups
|
|
100
|
+
return RenderedMessage(text=text, extra=extra)
|
|
101
|
+
text, entities = prepare_telegram(parts)
|
|
102
|
+
return RenderedMessage(
|
|
103
|
+
text=text,
|
|
104
|
+
extra={"entities": entities, "reply_markup": CLEAR_MARKUP},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_cancelled_label(label: str) -> bool:
|
|
109
|
+
stripped = label.strip()
|
|
110
|
+
if stripped.startswith("`") and stripped.endswith("`") and len(stripped) >= 2:
|
|
111
|
+
stripped = stripped[1:-1]
|
|
112
|
+
return stripped.lower() == "cancelled"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class TelegramBridgeConfig:
|
|
117
|
+
bot: BotClient
|
|
118
|
+
runtime: TransportRuntime
|
|
119
|
+
chat_id: int
|
|
120
|
+
startup_msg: str
|
|
121
|
+
exec_cfg: ExecBridgeConfig
|
|
122
|
+
session_mode: Literal["stateless", "chat"] = "stateless"
|
|
123
|
+
show_resume_line: bool = True
|
|
124
|
+
voice_transcription: bool = False
|
|
125
|
+
voice_max_bytes: int = 10 * 1024 * 1024
|
|
126
|
+
voice_transcription_model: str = "gpt-4o-mini-transcribe"
|
|
127
|
+
voice_transcription_base_url: str | None = None
|
|
128
|
+
voice_transcription_api_key: str | None = None
|
|
129
|
+
forward_coalesce_s: float = 1.0
|
|
130
|
+
media_group_debounce_s: float = 1.0
|
|
131
|
+
allowed_user_ids: tuple[int, ...] = ()
|
|
132
|
+
files: TelegramFilesSettings = field(default_factory=TelegramFilesSettings)
|
|
133
|
+
chat_ids: tuple[int, ...] | None = None
|
|
134
|
+
topics: TelegramTopicsSettings = field(default_factory=TelegramTopicsSettings)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TelegramTransport:
|
|
138
|
+
def __init__(self, bot: BotClient) -> None:
|
|
139
|
+
self._bot = bot
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _extract_followups(message: RenderedMessage) -> list[RenderedMessage]:
|
|
143
|
+
followups = message.extra.get("followups")
|
|
144
|
+
if not isinstance(followups, list):
|
|
145
|
+
return []
|
|
146
|
+
return [item for item in followups if isinstance(item, RenderedMessage)]
|
|
147
|
+
|
|
148
|
+
async def _send_followups(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
chat_id: int,
|
|
152
|
+
followups: list[RenderedMessage],
|
|
153
|
+
reply_to_message_id: int | None,
|
|
154
|
+
message_thread_id: int | None,
|
|
155
|
+
notify: bool,
|
|
156
|
+
) -> None:
|
|
157
|
+
for followup in followups:
|
|
158
|
+
await self._bot.send_message(
|
|
159
|
+
chat_id=chat_id,
|
|
160
|
+
text=followup.text,
|
|
161
|
+
entities=followup.extra.get("entities"),
|
|
162
|
+
parse_mode=followup.extra.get("parse_mode"),
|
|
163
|
+
reply_markup=followup.extra.get("reply_markup"),
|
|
164
|
+
reply_to_message_id=reply_to_message_id,
|
|
165
|
+
message_thread_id=message_thread_id,
|
|
166
|
+
disable_notification=not notify,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def close(self) -> None:
|
|
170
|
+
await self._bot.close()
|
|
171
|
+
|
|
172
|
+
async def send(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
channel_id: int | str,
|
|
176
|
+
message: RenderedMessage,
|
|
177
|
+
options: SendOptions | None = None,
|
|
178
|
+
) -> MessageRef | None:
|
|
179
|
+
chat_id = cast(int, channel_id)
|
|
180
|
+
reply_to_message_id: int | None = None
|
|
181
|
+
replace_message_id: int | None = None
|
|
182
|
+
message_thread_id: int | None = None
|
|
183
|
+
notify = True
|
|
184
|
+
if options is not None:
|
|
185
|
+
reply_to_message_id = (
|
|
186
|
+
cast(int, options.reply_to.message_id)
|
|
187
|
+
if options.reply_to is not None
|
|
188
|
+
else None
|
|
189
|
+
)
|
|
190
|
+
replace_message_id = (
|
|
191
|
+
cast(int, options.replace.message_id)
|
|
192
|
+
if options.replace is not None
|
|
193
|
+
else None
|
|
194
|
+
)
|
|
195
|
+
notify = options.notify
|
|
196
|
+
message_thread_id = (
|
|
197
|
+
cast(int | None, options.thread_id)
|
|
198
|
+
if options.thread_id is not None
|
|
199
|
+
else None
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
reply_to_message_id = cast(
|
|
203
|
+
int | None,
|
|
204
|
+
message.extra.get("followup_reply_to_message_id"),
|
|
205
|
+
)
|
|
206
|
+
message_thread_id = cast(
|
|
207
|
+
int | None,
|
|
208
|
+
message.extra.get("followup_thread_id"),
|
|
209
|
+
)
|
|
210
|
+
notify = bool(message.extra.get("followup_notify", True))
|
|
211
|
+
followups = self._extract_followups(message)
|
|
212
|
+
sent = await self._bot.send_message(
|
|
213
|
+
chat_id=chat_id,
|
|
214
|
+
text=message.text,
|
|
215
|
+
entities=message.extra.get("entities"),
|
|
216
|
+
parse_mode=message.extra.get("parse_mode"),
|
|
217
|
+
reply_markup=message.extra.get("reply_markup"),
|
|
218
|
+
reply_to_message_id=reply_to_message_id,
|
|
219
|
+
message_thread_id=message_thread_id,
|
|
220
|
+
replace_message_id=replace_message_id,
|
|
221
|
+
disable_notification=not notify,
|
|
222
|
+
)
|
|
223
|
+
if sent is None:
|
|
224
|
+
return None
|
|
225
|
+
if followups:
|
|
226
|
+
await self._send_followups(
|
|
227
|
+
chat_id=chat_id,
|
|
228
|
+
followups=followups,
|
|
229
|
+
reply_to_message_id=reply_to_message_id,
|
|
230
|
+
message_thread_id=message_thread_id,
|
|
231
|
+
notify=notify,
|
|
232
|
+
)
|
|
233
|
+
message_id = sent.message_id
|
|
234
|
+
thread_id = (
|
|
235
|
+
sent.message_thread_id
|
|
236
|
+
if sent.message_thread_id is not None
|
|
237
|
+
else message_thread_id
|
|
238
|
+
)
|
|
239
|
+
return MessageRef(
|
|
240
|
+
channel_id=chat_id,
|
|
241
|
+
message_id=message_id,
|
|
242
|
+
raw=sent,
|
|
243
|
+
thread_id=thread_id,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def edit(
|
|
247
|
+
self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True
|
|
248
|
+
) -> MessageRef | None:
|
|
249
|
+
chat_id = cast(int, ref.channel_id)
|
|
250
|
+
message_id = cast(int, ref.message_id)
|
|
251
|
+
entities = message.extra.get("entities")
|
|
252
|
+
parse_mode = message.extra.get("parse_mode")
|
|
253
|
+
reply_markup = message.extra.get("reply_markup")
|
|
254
|
+
followups = self._extract_followups(message)
|
|
255
|
+
edited = await self._bot.edit_message_text(
|
|
256
|
+
chat_id=chat_id,
|
|
257
|
+
message_id=message_id,
|
|
258
|
+
text=message.text,
|
|
259
|
+
entities=entities,
|
|
260
|
+
parse_mode=parse_mode,
|
|
261
|
+
reply_markup=reply_markup,
|
|
262
|
+
wait=wait,
|
|
263
|
+
)
|
|
264
|
+
if edited is None:
|
|
265
|
+
return ref if not wait else None
|
|
266
|
+
if followups:
|
|
267
|
+
reply_to_message_id = cast(
|
|
268
|
+
int | None, message.extra.get("followup_reply_to_message_id")
|
|
269
|
+
)
|
|
270
|
+
message_thread_id = cast(
|
|
271
|
+
int | None, message.extra.get("followup_thread_id")
|
|
272
|
+
)
|
|
273
|
+
notify = bool(message.extra.get("followup_notify", True))
|
|
274
|
+
await self._send_followups(
|
|
275
|
+
chat_id=chat_id,
|
|
276
|
+
followups=followups,
|
|
277
|
+
reply_to_message_id=reply_to_message_id,
|
|
278
|
+
message_thread_id=message_thread_id,
|
|
279
|
+
notify=notify,
|
|
280
|
+
)
|
|
281
|
+
message_id = edited.message_id
|
|
282
|
+
thread_id = (
|
|
283
|
+
edited.message_thread_id
|
|
284
|
+
if edited.message_thread_id is not None
|
|
285
|
+
else ref.thread_id
|
|
286
|
+
)
|
|
287
|
+
return MessageRef(
|
|
288
|
+
channel_id=chat_id,
|
|
289
|
+
message_id=message_id,
|
|
290
|
+
raw=edited,
|
|
291
|
+
thread_id=thread_id,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def delete(self, *, ref: MessageRef) -> bool:
|
|
295
|
+
return await self._bot.delete_message(
|
|
296
|
+
chat_id=cast(int, ref.channel_id),
|
|
297
|
+
message_id=cast(int, ref.message_id),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def send_plain(
|
|
302
|
+
transport: Transport,
|
|
303
|
+
*,
|
|
304
|
+
chat_id: int,
|
|
305
|
+
user_msg_id: int,
|
|
306
|
+
text: str,
|
|
307
|
+
notify: bool = True,
|
|
308
|
+
thread_id: int | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
reply_to = MessageRef(channel_id=chat_id, message_id=user_msg_id)
|
|
311
|
+
rendered_text, entities = prepare_telegram(MarkdownParts(header=text))
|
|
312
|
+
await transport.send(
|
|
313
|
+
channel_id=chat_id,
|
|
314
|
+
message=RenderedMessage(text=rendered_text, extra={"entities": entities}),
|
|
315
|
+
options=SendOptions(reply_to=reply_to, notify=notify, thread_id=thread_id),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def build_bot_commands(
|
|
320
|
+
runtime: TransportRuntime,
|
|
321
|
+
*,
|
|
322
|
+
include_file: bool = True,
|
|
323
|
+
include_topics: bool = False,
|
|
324
|
+
):
|
|
325
|
+
from .commands import build_bot_commands as _build
|
|
326
|
+
|
|
327
|
+
return _build(
|
|
328
|
+
runtime,
|
|
329
|
+
include_file=include_file,
|
|
330
|
+
include_topics=include_topics,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def is_cancel_command(text: str) -> bool:
|
|
335
|
+
from .commands import is_cancel_command as _is_cancel_command
|
|
336
|
+
|
|
337
|
+
return _is_cancel_command(text)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def handle_cancel(
|
|
341
|
+
cfg: TelegramBridgeConfig,
|
|
342
|
+
msg: TelegramIncomingMessage,
|
|
343
|
+
running_tasks: RunningTasks,
|
|
344
|
+
scheduler: ThreadScheduler | None = None,
|
|
345
|
+
) -> None:
|
|
346
|
+
from .commands import handle_cancel as _handle_cancel
|
|
347
|
+
|
|
348
|
+
await _handle_cancel(cfg, msg, running_tasks, scheduler)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def handle_callback_cancel(
|
|
352
|
+
cfg: TelegramBridgeConfig,
|
|
353
|
+
query: TelegramCallbackQuery,
|
|
354
|
+
running_tasks: RunningTasks,
|
|
355
|
+
scheduler: ThreadScheduler | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
from .commands import handle_callback_cancel as _handle_callback_cancel
|
|
358
|
+
|
|
359
|
+
await _handle_callback_cancel(cfg, query, running_tasks, scheduler)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def send_with_resume(
|
|
363
|
+
cfg: TelegramBridgeConfig,
|
|
364
|
+
enqueue: Callable[
|
|
365
|
+
[
|
|
366
|
+
int,
|
|
367
|
+
int,
|
|
368
|
+
str,
|
|
369
|
+
ResumeToken,
|
|
370
|
+
RunContext | None,
|
|
371
|
+
int | None,
|
|
372
|
+
tuple[int, int | None] | None,
|
|
373
|
+
MessageRef | None,
|
|
374
|
+
],
|
|
375
|
+
Awaitable[None],
|
|
376
|
+
],
|
|
377
|
+
running_task: RunningTask,
|
|
378
|
+
chat_id: int,
|
|
379
|
+
user_msg_id: int,
|
|
380
|
+
thread_id: int | None,
|
|
381
|
+
session_key: tuple[int, int | None] | None,
|
|
382
|
+
text: str,
|
|
383
|
+
) -> None:
|
|
384
|
+
from .loop import send_with_resume as _send_with_resume
|
|
385
|
+
|
|
386
|
+
await _send_with_resume(
|
|
387
|
+
cfg,
|
|
388
|
+
enqueue,
|
|
389
|
+
running_task,
|
|
390
|
+
chat_id,
|
|
391
|
+
user_msg_id,
|
|
392
|
+
thread_id,
|
|
393
|
+
session_key,
|
|
394
|
+
text,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def run_main_loop(
|
|
399
|
+
cfg: TelegramBridgeConfig,
|
|
400
|
+
poller=None,
|
|
401
|
+
*,
|
|
402
|
+
watch_config: bool | None = None,
|
|
403
|
+
default_engine_override: str | None = None,
|
|
404
|
+
transport_id: str | None = None,
|
|
405
|
+
transport_config: TelegramTransportSettings | None = None,
|
|
406
|
+
) -> None:
|
|
407
|
+
from .loop import run_main_loop as _run_main_loop
|
|
408
|
+
|
|
409
|
+
if poller is None:
|
|
410
|
+
await _run_main_loop(
|
|
411
|
+
cfg,
|
|
412
|
+
watch_config=watch_config,
|
|
413
|
+
default_engine_override=default_engine_override,
|
|
414
|
+
transport_id=transport_id,
|
|
415
|
+
transport_config=transport_config,
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
await _run_main_loop(
|
|
419
|
+
cfg,
|
|
420
|
+
poller=poller,
|
|
421
|
+
watch_config=watch_config,
|
|
422
|
+
default_engine_override=default_engine_override,
|
|
423
|
+
transport_id=transport_id,
|
|
424
|
+
transport_config=transport_config,
|
|
425
|
+
)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import msgspec
|
|
6
|
+
|
|
7
|
+
from ..context import RunContext
|
|
8
|
+
from ..logging import get_logger
|
|
9
|
+
from .engine_overrides import EngineOverrides, normalize_overrides
|
|
10
|
+
from .state_store import JsonStateStore
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
STATE_VERSION = 1
|
|
15
|
+
STATE_FILENAME = "telegram_chat_prefs_state.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False):
|
|
19
|
+
default_engine: str | None = None
|
|
20
|
+
trigger_mode: str | None = None
|
|
21
|
+
context_project: str | None = None
|
|
22
|
+
context_branch: str | None = None
|
|
23
|
+
engine_overrides: dict[str, EngineOverrides] = msgspec.field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _ChatPrefsState(msgspec.Struct, forbid_unknown_fields=False):
|
|
27
|
+
version: int
|
|
28
|
+
chats: dict[str, _ChatPrefs] = msgspec.field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_prefs_path(config_path: Path) -> Path:
|
|
32
|
+
return config_path.with_name(STATE_FILENAME)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _chat_key(chat_id: int) -> str:
|
|
36
|
+
return str(chat_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_text(value: str | None) -> str | None:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
value = value.strip()
|
|
43
|
+
return value or None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_trigger_mode(value: str | None) -> str | None:
|
|
47
|
+
if value is None:
|
|
48
|
+
return None
|
|
49
|
+
value = value.strip().lower()
|
|
50
|
+
if value == "mentions":
|
|
51
|
+
return "mentions"
|
|
52
|
+
if value == "all":
|
|
53
|
+
return None
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_engine_id(value: str | None) -> str | None:
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
value = value.strip().lower()
|
|
61
|
+
return value or None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _new_state() -> _ChatPrefsState:
|
|
65
|
+
return _ChatPrefsState(version=STATE_VERSION, chats={})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
|
69
|
+
def __init__(self, path: Path) -> None:
|
|
70
|
+
super().__init__(
|
|
71
|
+
path,
|
|
72
|
+
version=STATE_VERSION,
|
|
73
|
+
state_type=_ChatPrefsState,
|
|
74
|
+
state_factory=_new_state,
|
|
75
|
+
log_prefix="telegram.chat_prefs",
|
|
76
|
+
logger=logger,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def get_default_engine(self, chat_id: int) -> str | None:
|
|
80
|
+
async with self._lock:
|
|
81
|
+
self._reload_locked_if_needed()
|
|
82
|
+
chat = self._get_chat_locked(chat_id)
|
|
83
|
+
if chat is None:
|
|
84
|
+
return None
|
|
85
|
+
return _normalize_text(chat.default_engine)
|
|
86
|
+
|
|
87
|
+
async def set_default_engine(self, chat_id: int, engine: str | None) -> None:
|
|
88
|
+
normalized = _normalize_text(engine)
|
|
89
|
+
async with self._lock:
|
|
90
|
+
self._reload_locked_if_needed()
|
|
91
|
+
chat = self._get_chat_locked(chat_id)
|
|
92
|
+
if normalized is None:
|
|
93
|
+
if chat is None:
|
|
94
|
+
return
|
|
95
|
+
chat.default_engine = None
|
|
96
|
+
if self._chat_is_empty(chat):
|
|
97
|
+
self._remove_chat_locked(chat_id)
|
|
98
|
+
self._save_locked()
|
|
99
|
+
return
|
|
100
|
+
chat = self._ensure_chat_locked(chat_id)
|
|
101
|
+
chat.default_engine = normalized
|
|
102
|
+
self._save_locked()
|
|
103
|
+
|
|
104
|
+
async def clear_default_engine(self, chat_id: int) -> None:
|
|
105
|
+
await self.set_default_engine(chat_id, None)
|
|
106
|
+
|
|
107
|
+
async def get_trigger_mode(self, chat_id: int) -> str | None:
|
|
108
|
+
async with self._lock:
|
|
109
|
+
self._reload_locked_if_needed()
|
|
110
|
+
chat = self._get_chat_locked(chat_id)
|
|
111
|
+
if chat is None:
|
|
112
|
+
return None
|
|
113
|
+
return _normalize_trigger_mode(chat.trigger_mode)
|
|
114
|
+
|
|
115
|
+
async def set_trigger_mode(self, chat_id: int, mode: str | None) -> None:
|
|
116
|
+
normalized = _normalize_trigger_mode(mode)
|
|
117
|
+
async with self._lock:
|
|
118
|
+
self._reload_locked_if_needed()
|
|
119
|
+
chat = self._get_chat_locked(chat_id)
|
|
120
|
+
if normalized is None:
|
|
121
|
+
if chat is None:
|
|
122
|
+
return
|
|
123
|
+
chat.trigger_mode = None
|
|
124
|
+
if self._chat_is_empty(chat):
|
|
125
|
+
self._remove_chat_locked(chat_id)
|
|
126
|
+
self._save_locked()
|
|
127
|
+
return
|
|
128
|
+
chat = self._ensure_chat_locked(chat_id)
|
|
129
|
+
chat.trigger_mode = normalized
|
|
130
|
+
self._save_locked()
|
|
131
|
+
|
|
132
|
+
async def clear_trigger_mode(self, chat_id: int) -> None:
|
|
133
|
+
await self.set_trigger_mode(chat_id, None)
|
|
134
|
+
|
|
135
|
+
async def get_context(self, chat_id: int) -> RunContext | None:
|
|
136
|
+
async with self._lock:
|
|
137
|
+
self._reload_locked_if_needed()
|
|
138
|
+
chat = self._get_chat_locked(chat_id)
|
|
139
|
+
if chat is None:
|
|
140
|
+
return None
|
|
141
|
+
project = _normalize_text(chat.context_project)
|
|
142
|
+
if project is None:
|
|
143
|
+
return None
|
|
144
|
+
branch = _normalize_text(chat.context_branch)
|
|
145
|
+
return RunContext(project=project, branch=branch)
|
|
146
|
+
|
|
147
|
+
async def set_context(self, chat_id: int, context: RunContext | None) -> None:
|
|
148
|
+
project = _normalize_text(context.project) if context is not None else None
|
|
149
|
+
branch = _normalize_text(context.branch) if context is not None else None
|
|
150
|
+
async with self._lock:
|
|
151
|
+
self._reload_locked_if_needed()
|
|
152
|
+
chat = self._get_chat_locked(chat_id)
|
|
153
|
+
if project is None:
|
|
154
|
+
if chat is None:
|
|
155
|
+
return
|
|
156
|
+
chat.context_project = None
|
|
157
|
+
chat.context_branch = None
|
|
158
|
+
if self._chat_is_empty(chat):
|
|
159
|
+
self._remove_chat_locked(chat_id)
|
|
160
|
+
self._save_locked()
|
|
161
|
+
return
|
|
162
|
+
chat = self._ensure_chat_locked(chat_id)
|
|
163
|
+
chat.context_project = project
|
|
164
|
+
chat.context_branch = branch
|
|
165
|
+
self._save_locked()
|
|
166
|
+
|
|
167
|
+
async def clear_context(self, chat_id: int) -> None:
|
|
168
|
+
await self.set_context(chat_id, None)
|
|
169
|
+
|
|
170
|
+
async def get_engine_override(
|
|
171
|
+
self, chat_id: int, engine: str
|
|
172
|
+
) -> EngineOverrides | None:
|
|
173
|
+
engine_key = _normalize_engine_id(engine)
|
|
174
|
+
if engine_key is None:
|
|
175
|
+
return None
|
|
176
|
+
async with self._lock:
|
|
177
|
+
self._reload_locked_if_needed()
|
|
178
|
+
chat = self._get_chat_locked(chat_id)
|
|
179
|
+
if chat is None:
|
|
180
|
+
return None
|
|
181
|
+
override = chat.engine_overrides.get(engine_key)
|
|
182
|
+
return normalize_overrides(override)
|
|
183
|
+
|
|
184
|
+
async def set_engine_override(
|
|
185
|
+
self, chat_id: int, engine: str, override: EngineOverrides | None
|
|
186
|
+
) -> None:
|
|
187
|
+
engine_key = _normalize_engine_id(engine)
|
|
188
|
+
if engine_key is None:
|
|
189
|
+
return
|
|
190
|
+
normalized = normalize_overrides(override)
|
|
191
|
+
async with self._lock:
|
|
192
|
+
self._reload_locked_if_needed()
|
|
193
|
+
chat = self._get_chat_locked(chat_id)
|
|
194
|
+
if normalized is None:
|
|
195
|
+
if chat is None:
|
|
196
|
+
return
|
|
197
|
+
chat.engine_overrides.pop(engine_key, None)
|
|
198
|
+
if self._chat_is_empty(chat):
|
|
199
|
+
self._remove_chat_locked(chat_id)
|
|
200
|
+
self._save_locked()
|
|
201
|
+
return
|
|
202
|
+
chat = self._ensure_chat_locked(chat_id)
|
|
203
|
+
chat.engine_overrides[engine_key] = normalized
|
|
204
|
+
self._save_locked()
|
|
205
|
+
|
|
206
|
+
async def clear_engine_override(self, chat_id: int, engine: str) -> None:
|
|
207
|
+
await self.set_engine_override(chat_id, engine, None)
|
|
208
|
+
|
|
209
|
+
def _get_chat_locked(self, chat_id: int) -> _ChatPrefs | None:
|
|
210
|
+
return self._state.chats.get(_chat_key(chat_id))
|
|
211
|
+
|
|
212
|
+
def _ensure_chat_locked(self, chat_id: int) -> _ChatPrefs:
|
|
213
|
+
key = _chat_key(chat_id)
|
|
214
|
+
entry = self._state.chats.get(key)
|
|
215
|
+
if entry is not None:
|
|
216
|
+
return entry
|
|
217
|
+
entry = _ChatPrefs()
|
|
218
|
+
self._state.chats[key] = entry
|
|
219
|
+
return entry
|
|
220
|
+
|
|
221
|
+
def _chat_is_empty(self, chat: _ChatPrefs) -> bool:
|
|
222
|
+
return (
|
|
223
|
+
_normalize_text(chat.default_engine) is None
|
|
224
|
+
and _normalize_trigger_mode(chat.trigger_mode) is None
|
|
225
|
+
and _normalize_text(chat.context_project) is None
|
|
226
|
+
and _normalize_text(chat.context_branch) is None
|
|
227
|
+
and not self._has_engine_overrides(chat.engine_overrides)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _has_engine_overrides(overrides: dict[str, EngineOverrides]) -> bool:
|
|
232
|
+
for override in overrides.values():
|
|
233
|
+
if normalize_overrides(override) is not None:
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
def _remove_chat_locked(self, chat_id: int) -> bool:
|
|
238
|
+
key = _chat_key(chat_id)
|
|
239
|
+
if key not in self._state.chats:
|
|
240
|
+
return False
|
|
241
|
+
del self._state.chats[key]
|
|
242
|
+
return True
|