yee88 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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