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,234 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...context import RunContext
6
+ from ..chat_prefs import ChatPrefsStore
7
+ from ..engine_overrides import (
8
+ EngineOverrides,
9
+ allowed_reasoning_levels,
10
+ resolve_override_value,
11
+ )
12
+ from ..files import split_command_args
13
+ from ..topic_state import TopicStateStore
14
+ from ..topics import _topic_key
15
+ from ..types import TelegramIncomingMessage
16
+ from .overrides import (
17
+ ENGINE_SOURCE_LABELS,
18
+ OVERRIDE_SOURCE_LABELS,
19
+ apply_engine_override,
20
+ parse_set_args,
21
+ require_admin_or_private,
22
+ resolve_engine_selection,
23
+ )
24
+ from .reply import make_reply
25
+
26
+ if TYPE_CHECKING:
27
+ from ..bridge import TelegramBridgeConfig
28
+
29
+ REASONING_USAGE = (
30
+ "usage: `/reasoning`, `/reasoning set <level>`, "
31
+ "`/reasoning set <engine> <level>`, or `/reasoning clear [engine]`"
32
+ )
33
+
34
+
35
+ async def _handle_reasoning_command(
36
+ cfg: TelegramBridgeConfig,
37
+ msg: TelegramIncomingMessage,
38
+ args_text: str,
39
+ ambient_context: RunContext | None,
40
+ topic_store: TopicStateStore | None,
41
+ chat_prefs: ChatPrefsStore | None,
42
+ *,
43
+ resolved_scope: str | None = None,
44
+ scope_chat_ids: frozenset[int] | None = None,
45
+ ) -> None:
46
+ reply = make_reply(cfg, msg)
47
+ tkey = (
48
+ _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
49
+ if topic_store is not None
50
+ else None
51
+ )
52
+ tokens = split_command_args(args_text)
53
+ action = tokens[0].lower() if tokens else "show"
54
+ engine_ids = {engine.lower() for engine in cfg.runtime.engine_ids}
55
+
56
+ if action in {"show", ""}:
57
+ selection = await resolve_engine_selection(
58
+ cfg,
59
+ msg,
60
+ ambient_context=ambient_context,
61
+ topic_store=topic_store,
62
+ chat_prefs=chat_prefs,
63
+ topic_key=tkey,
64
+ )
65
+ if selection is None:
66
+ return
67
+ engine, engine_source = selection
68
+ topic_override = None
69
+ if tkey is not None and topic_store is not None:
70
+ topic_override = await topic_store.get_engine_override(
71
+ tkey[0], tkey[1], engine
72
+ )
73
+ chat_override = None
74
+ if chat_prefs is not None:
75
+ chat_override = await chat_prefs.get_engine_override(msg.chat_id, engine)
76
+ resolution = resolve_override_value(
77
+ topic_override=topic_override,
78
+ chat_override=chat_override,
79
+ field="reasoning",
80
+ )
81
+ engine_line = f"engine: {engine} ({ENGINE_SOURCE_LABELS[engine_source]})"
82
+ reasoning_value = resolution.value or "default"
83
+ reasoning_line = (
84
+ f"reasoning: {reasoning_value} "
85
+ f"({OVERRIDE_SOURCE_LABELS[resolution.source]})"
86
+ )
87
+ topic_label = resolution.topic_value or "none"
88
+ if tkey is None:
89
+ topic_label = "none"
90
+ chat_label = (
91
+ "unavailable" if chat_prefs is None else resolution.chat_value or "none"
92
+ )
93
+ defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}"
94
+ available_levels = ", ".join(allowed_reasoning_levels(engine))
95
+ available_line = f"available levels: {available_levels}"
96
+ await reply(
97
+ text="\n\n".join(
98
+ [engine_line, reasoning_line, defaults_line, available_line]
99
+ )
100
+ )
101
+ return
102
+
103
+ if action == "set":
104
+ engine_arg, level = parse_set_args(tokens, engine_ids=engine_ids)
105
+ if level is None:
106
+ await reply(text=REASONING_USAGE)
107
+ return
108
+ if not await require_admin_or_private(
109
+ cfg,
110
+ msg,
111
+ missing_sender="cannot verify sender for reasoning overrides.",
112
+ failed_member="failed to verify reasoning override permissions.",
113
+ denied="changing reasoning overrides is restricted to group admins.",
114
+ ):
115
+ return
116
+ if engine_arg is None:
117
+ selection = await resolve_engine_selection(
118
+ cfg,
119
+ msg,
120
+ ambient_context=ambient_context,
121
+ topic_store=topic_store,
122
+ chat_prefs=chat_prefs,
123
+ topic_key=tkey,
124
+ )
125
+ if selection is None:
126
+ return
127
+ engine, _ = selection
128
+ else:
129
+ engine = engine_arg
130
+ if engine not in engine_ids:
131
+ available = ", ".join(cfg.runtime.engine_ids)
132
+ await reply(
133
+ text=f"unknown engine `{engine}`.\navailable engines: `{available}`"
134
+ )
135
+ return
136
+ normalized_level = level.strip().lower()
137
+ allowed = allowed_reasoning_levels(engine)
138
+ if normalized_level not in allowed:
139
+ await reply(
140
+ text=(
141
+ f"unknown reasoning level `{level}`.\n"
142
+ f"available levels: {', '.join(allowed)}"
143
+ )
144
+ )
145
+ return
146
+ scope = await apply_engine_override(
147
+ reply=reply,
148
+ tkey=tkey,
149
+ topic_store=topic_store,
150
+ chat_prefs=chat_prefs,
151
+ chat_id=msg.chat_id,
152
+ engine=engine,
153
+ update=lambda current: EngineOverrides(
154
+ model=current.model if current is not None else None,
155
+ reasoning=normalized_level,
156
+ ),
157
+ topic_unavailable="topic reasoning overrides are unavailable.",
158
+ chat_unavailable="chat reasoning overrides are unavailable (no config path).",
159
+ )
160
+ if scope is None:
161
+ return
162
+ if scope == "topic":
163
+ await reply(
164
+ text=(
165
+ f"topic reasoning override set to `{normalized_level}` "
166
+ f"for `{engine}`.\n"
167
+ "If you want a clean start on the new setting, run `/new`."
168
+ )
169
+ )
170
+ return
171
+ await reply(
172
+ text=(
173
+ f"chat reasoning override set to `{normalized_level}` for `{engine}`.\n"
174
+ "If you want a clean start on the new setting, run `/new`."
175
+ )
176
+ )
177
+ return
178
+
179
+ if action == "clear":
180
+ engine = None
181
+ if len(tokens) > 2:
182
+ await reply(text=REASONING_USAGE)
183
+ return
184
+ if len(tokens) == 2:
185
+ engine = tokens[1].strip().lower() or None
186
+ if not await require_admin_or_private(
187
+ cfg,
188
+ msg,
189
+ missing_sender="cannot verify sender for reasoning overrides.",
190
+ failed_member="failed to verify reasoning override permissions.",
191
+ denied="changing reasoning overrides is restricted to group admins.",
192
+ ):
193
+ return
194
+ if engine is None:
195
+ selection = await resolve_engine_selection(
196
+ cfg,
197
+ msg,
198
+ ambient_context=ambient_context,
199
+ topic_store=topic_store,
200
+ chat_prefs=chat_prefs,
201
+ topic_key=tkey,
202
+ )
203
+ if selection is None:
204
+ return
205
+ engine, _ = selection
206
+ if engine not in engine_ids:
207
+ available = ", ".join(cfg.runtime.engine_ids)
208
+ await reply(
209
+ text=f"unknown engine `{engine}`.\navailable engines: `{available}`"
210
+ )
211
+ return
212
+ scope = await apply_engine_override(
213
+ reply=reply,
214
+ tkey=tkey,
215
+ topic_store=topic_store,
216
+ chat_prefs=chat_prefs,
217
+ chat_id=msg.chat_id,
218
+ engine=engine,
219
+ update=lambda current: EngineOverrides(
220
+ model=current.model if current is not None else None,
221
+ reasoning=None,
222
+ ),
223
+ topic_unavailable="topic reasoning overrides are unavailable.",
224
+ chat_unavailable="chat reasoning overrides are unavailable (no config path).",
225
+ )
226
+ if scope is None:
227
+ return
228
+ if scope == "topic":
229
+ await reply(text="topic reasoning override cleared (using chat default).")
230
+ return
231
+ await reply(text="chat reasoning override cleared.")
232
+ return
233
+
234
+ await reply(text=REASONING_USAGE)
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from functools import partial
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ..bridge import send_plain
8
+ from ..types import TelegramIncomingMessage
9
+
10
+ if TYPE_CHECKING:
11
+ from ..bridge import TelegramBridgeConfig
12
+
13
+
14
+ def make_reply(
15
+ cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage
16
+ ) -> Callable[..., Awaitable[None]]:
17
+ return partial(
18
+ send_plain,
19
+ cfg.exec_cfg.transport,
20
+ chat_id=msg.chat_id,
21
+ user_msg_id=msg.message_id,
22
+ thread_id=msg.thread_id,
23
+ )
@@ -0,0 +1,332 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...context import RunContext
6
+ from ...markdown import MarkdownParts
7
+ from ...transport_runtime import TransportRuntime
8
+ from ...transport import RenderedMessage, SendOptions
9
+ from ..chat_prefs import ChatPrefsStore
10
+ from ..chat_sessions import ChatSessionStore
11
+ from ..context import (
12
+ _format_context,
13
+ _format_ctx_status,
14
+ _merge_topic_context,
15
+ _parse_project_branch_args,
16
+ _usage_ctx_set,
17
+ _usage_topic,
18
+ )
19
+ from ..files import split_command_args
20
+ from ..render import prepare_telegram
21
+ from ..topic_state import TopicStateStore
22
+ from ..topics import (
23
+ _maybe_rename_topic,
24
+ _topic_key,
25
+ _topic_title,
26
+ _topics_chat_project,
27
+ _topics_command_error,
28
+ )
29
+ from ..types import TelegramIncomingMessage
30
+ from .reply import make_reply
31
+
32
+ if TYPE_CHECKING:
33
+ from ..bridge import TelegramBridgeConfig
34
+
35
+
36
+ async def _handle_ctx_command(
37
+ cfg: TelegramBridgeConfig,
38
+ msg: TelegramIncomingMessage,
39
+ args_text: str,
40
+ store: TopicStateStore,
41
+ *,
42
+ resolved_scope: str | None = None,
43
+ scope_chat_ids: frozenset[int] | None = None,
44
+ ) -> None:
45
+ reply = make_reply(cfg, msg)
46
+ error = _topics_command_error(
47
+ cfg,
48
+ msg.chat_id,
49
+ resolved_scope=resolved_scope,
50
+ scope_chat_ids=scope_chat_ids,
51
+ )
52
+ if error is not None:
53
+ await reply(text=error)
54
+ return
55
+ chat_project = _topics_chat_project(cfg, msg.chat_id)
56
+ tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
57
+ if tkey is None:
58
+ await reply(text="this command only works inside a topic.")
59
+ return
60
+ tokens = split_command_args(args_text)
61
+ action = tokens[0].lower() if tokens else "show"
62
+ if action in {"show", ""}:
63
+ snapshot = await store.get_thread(*tkey)
64
+ bound = snapshot.context if snapshot is not None else None
65
+ ambient = _merge_topic_context(chat_project=chat_project, bound=bound)
66
+ resolved = cfg.runtime.resolve_message(
67
+ text="",
68
+ reply_text=msg.reply_to_text,
69
+ chat_id=msg.chat_id,
70
+ ambient_context=ambient,
71
+ )
72
+ text = _format_ctx_status(
73
+ cfg=cfg,
74
+ runtime=cfg.runtime,
75
+ bound=bound,
76
+ resolved=resolved.context,
77
+ context_source=resolved.context_source,
78
+ snapshot=snapshot,
79
+ chat_project=chat_project,
80
+ )
81
+ await reply(text=text)
82
+ return
83
+ if action == "set":
84
+ rest = " ".join(tokens[1:])
85
+ context, error = _parse_project_branch_args(
86
+ rest,
87
+ runtime=cfg.runtime,
88
+ require_branch=False,
89
+ chat_project=chat_project,
90
+ )
91
+ if error is not None:
92
+ await reply(
93
+ text=f"error:\n{error}\n{_usage_ctx_set(chat_project=chat_project)}",
94
+ )
95
+ return
96
+ if context is None:
97
+ await reply(
98
+ text=f"error:\n{_usage_ctx_set(chat_project=chat_project)}",
99
+ )
100
+ return
101
+ await store.set_context(*tkey, context)
102
+ await _maybe_rename_topic(
103
+ cfg,
104
+ store,
105
+ chat_id=tkey[0],
106
+ thread_id=tkey[1],
107
+ context=context,
108
+ )
109
+ await reply(
110
+ text=f"topic bound to `{_format_context(cfg.runtime, context)}`",
111
+ )
112
+ return
113
+ if action == "clear":
114
+ await store.clear_context(*tkey)
115
+ await reply(text="topic binding cleared.")
116
+ return
117
+ await reply(
118
+ text="unknown `/ctx` command. use `/ctx`, `/ctx set`, or `/ctx clear`.",
119
+ )
120
+
121
+
122
+ def _parse_chat_ctx_args(
123
+ args_text: str,
124
+ *,
125
+ runtime: TransportRuntime,
126
+ default_project: str | None,
127
+ ) -> tuple[RunContext | None, str | None]:
128
+ tokens = split_command_args(args_text)
129
+ if not tokens:
130
+ return None, _usage_ctx_set(chat_project=None)
131
+ if len(tokens) > 2:
132
+ return None, "too many arguments"
133
+ project_token: str | None = None
134
+ branch: str | None = None
135
+ first = tokens[0]
136
+ if first.startswith("@"):
137
+ branch = first[1:] or None
138
+ else:
139
+ project_token = first
140
+ if len(tokens) == 2:
141
+ second = tokens[1]
142
+ if not second.startswith("@"):
143
+ return None, "branch must be prefixed with @"
144
+ branch = second[1:] or None
145
+ project_key: str | None = None
146
+ if project_token is None:
147
+ if default_project is None:
148
+ return None, "project is required"
149
+ project_key = default_project
150
+ else:
151
+ project_key = runtime.normalize_project_key(project_token)
152
+ if project_key is None:
153
+ return None, f"unknown project {project_token!r}"
154
+ return RunContext(project=project_key, branch=branch), None
155
+
156
+
157
+ async def _handle_chat_ctx_command(
158
+ cfg: TelegramBridgeConfig,
159
+ msg: TelegramIncomingMessage,
160
+ args_text: str,
161
+ chat_prefs: ChatPrefsStore | None,
162
+ ) -> None:
163
+ reply = make_reply(cfg, msg)
164
+ if chat_prefs is None:
165
+ await reply(text="chat context unavailable; config path is not set.")
166
+ return
167
+
168
+ tokens = split_command_args(args_text)
169
+ action = tokens[0].lower() if tokens else "show"
170
+ if action in {"show", ""}:
171
+ bound = await chat_prefs.get_context(msg.chat_id)
172
+ resolved = cfg.runtime.resolve_message(
173
+ text="",
174
+ reply_text=msg.reply_to_text,
175
+ chat_id=msg.chat_id,
176
+ ambient_context=bound,
177
+ )
178
+ source = resolved.context_source
179
+ if bound is not None and resolved.context_source == "ambient":
180
+ source = "bound"
181
+ lines = [
182
+ f"bound ctx: {_format_context(cfg.runtime, bound)}",
183
+ f"resolved ctx: {_format_context(cfg.runtime, resolved.context)} (source: {source})",
184
+ ]
185
+ if bound is None:
186
+ ctx_usage = (
187
+ _usage_ctx_set(chat_project=None).removeprefix("usage: ").strip()
188
+ )
189
+ lines.append(f"note: no bound context — bind with {ctx_usage}")
190
+ await reply(text="\n".join(lines))
191
+ return
192
+ if action == "set":
193
+ rest = " ".join(tokens[1:])
194
+ context, error = _parse_chat_ctx_args(
195
+ rest,
196
+ runtime=cfg.runtime,
197
+ default_project=cfg.runtime.default_project,
198
+ )
199
+ if error is not None:
200
+ await reply(
201
+ text=f"error:\n{error}\n{_usage_ctx_set(chat_project=None)}",
202
+ )
203
+ return
204
+ if context is None:
205
+ await reply(text=f"error:\n{_usage_ctx_set(chat_project=None)}")
206
+ return
207
+ await chat_prefs.set_context(msg.chat_id, context)
208
+ await reply(
209
+ text=f"chat bound to `{_format_context(cfg.runtime, context)}`",
210
+ )
211
+ return
212
+ if action == "clear":
213
+ await chat_prefs.clear_context(msg.chat_id)
214
+ await reply(text="chat context cleared.")
215
+ return
216
+ await reply(
217
+ text="unknown `/ctx` command. use `/ctx`, `/ctx set`, or `/ctx clear`.",
218
+ )
219
+
220
+
221
+ async def _handle_new_command(
222
+ cfg: TelegramBridgeConfig,
223
+ msg: TelegramIncomingMessage,
224
+ store: TopicStateStore,
225
+ *,
226
+ resolved_scope: str | None = None,
227
+ scope_chat_ids: frozenset[int] | None = None,
228
+ ) -> None:
229
+ reply = make_reply(cfg, msg)
230
+ error = _topics_command_error(
231
+ cfg,
232
+ msg.chat_id,
233
+ resolved_scope=resolved_scope,
234
+ scope_chat_ids=scope_chat_ids,
235
+ )
236
+ if error is not None:
237
+ await reply(text=error)
238
+ return
239
+ tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
240
+ if tkey is None:
241
+ await reply(text="this command only works inside a topic.")
242
+ return
243
+ await store.clear_sessions(*tkey)
244
+ await reply(text="cleared stored sessions for this topic.")
245
+
246
+
247
+ async def _handle_chat_new_command(
248
+ cfg: TelegramBridgeConfig,
249
+ msg: TelegramIncomingMessage,
250
+ store: ChatSessionStore,
251
+ session_key: tuple[int, int | None] | None,
252
+ ) -> None:
253
+ reply = make_reply(cfg, msg)
254
+ if session_key is None:
255
+ await reply(text="no stored sessions to clear for this chat.")
256
+ return
257
+ await store.clear_sessions(session_key[0], session_key[1])
258
+ if msg.chat_type == "private":
259
+ text = "cleared stored sessions for this chat."
260
+ else:
261
+ text = "cleared stored sessions for you in this chat."
262
+ await reply(text=text)
263
+
264
+
265
+ async def _handle_topic_command(
266
+ cfg: TelegramBridgeConfig,
267
+ msg: TelegramIncomingMessage,
268
+ args_text: str,
269
+ store: TopicStateStore,
270
+ *,
271
+ resolved_scope: str | None = None,
272
+ scope_chat_ids: frozenset[int] | None = None,
273
+ ) -> None:
274
+ reply = make_reply(cfg, msg)
275
+ error = _topics_command_error(
276
+ cfg,
277
+ msg.chat_id,
278
+ resolved_scope=resolved_scope,
279
+ scope_chat_ids=scope_chat_ids,
280
+ )
281
+ if error is not None:
282
+ await reply(text=error)
283
+ return
284
+ chat_project = _topics_chat_project(cfg, msg.chat_id)
285
+ context, error = _parse_project_branch_args(
286
+ args_text,
287
+ runtime=cfg.runtime,
288
+ require_branch=True,
289
+ chat_project=chat_project,
290
+ )
291
+ if error is not None or context is None:
292
+ usage = _usage_topic(chat_project=chat_project)
293
+ text = f"error:\n{error}\n{usage}" if error else usage
294
+ await reply(text=text)
295
+ return
296
+ title = _topic_title(runtime=cfg.runtime, context=context)
297
+ existing = await store.find_thread_for_context(msg.chat_id, context)
298
+ stale_thread_id: int | None = None
299
+ if existing is not None:
300
+ updated = await cfg.bot.edit_forum_topic(
301
+ chat_id=msg.chat_id,
302
+ message_thread_id=existing,
303
+ name=title,
304
+ )
305
+ if updated:
306
+ await reply(
307
+ text=f"topic already exists for {_format_context(cfg.runtime, context)} "
308
+ "in this chat.",
309
+ )
310
+ return
311
+ stale_thread_id = existing
312
+ created = await cfg.bot.create_forum_topic(msg.chat_id, title)
313
+ if created is None:
314
+ await reply(text="failed to create topic.")
315
+ return
316
+ thread_id = created.message_thread_id
317
+ if stale_thread_id is not None:
318
+ await store.delete_thread(msg.chat_id, stale_thread_id)
319
+ await store.set_context(
320
+ msg.chat_id,
321
+ thread_id,
322
+ context,
323
+ topic_title=title,
324
+ )
325
+ await reply(text=f"created topic `{title}`.")
326
+ bound_text = f"topic bound to `{_format_context(cfg.runtime, context)}`"
327
+ rendered_text, entities = prepare_telegram(MarkdownParts(header=bound_text))
328
+ await cfg.exec_cfg.transport.send(
329
+ channel_id=msg.chat_id,
330
+ message=RenderedMessage(text=rendered_text, extra={"entities": entities}),
331
+ options=SendOptions(thread_id=thread_id),
332
+ )