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.
Files changed (103) hide show
  1. yee88/__init__.py +1 -0
  2. yee88/api.py +116 -0
  3. yee88/backends.py +25 -0
  4. yee88/backends_helpers.py +14 -0
  5. yee88/cli/__init__.py +228 -0
  6. yee88/cli/config.py +320 -0
  7. yee88/cli/doctor.py +173 -0
  8. yee88/cli/init.py +113 -0
  9. yee88/cli/onboarding_cmd.py +126 -0
  10. yee88/cli/plugins.py +196 -0
  11. yee88/cli/run.py +419 -0
  12. yee88/cli/topic.py +355 -0
  13. yee88/commands.py +134 -0
  14. yee88/config.py +142 -0
  15. yee88/config_migrations.py +124 -0
  16. yee88/config_watch.py +146 -0
  17. yee88/context.py +9 -0
  18. yee88/directives.py +146 -0
  19. yee88/engines.py +53 -0
  20. yee88/events.py +170 -0
  21. yee88/ids.py +17 -0
  22. yee88/lockfile.py +158 -0
  23. yee88/logging.py +283 -0
  24. yee88/markdown.py +298 -0
  25. yee88/model.py +77 -0
  26. yee88/plugins.py +312 -0
  27. yee88/presenter.py +25 -0
  28. yee88/progress.py +99 -0
  29. yee88/router.py +113 -0
  30. yee88/runner.py +712 -0
  31. yee88/runner_bridge.py +619 -0
  32. yee88/runners/__init__.py +1 -0
  33. yee88/runners/claude.py +483 -0
  34. yee88/runners/codex.py +656 -0
  35. yee88/runners/mock.py +221 -0
  36. yee88/runners/opencode.py +505 -0
  37. yee88/runners/pi.py +523 -0
  38. yee88/runners/run_options.py +39 -0
  39. yee88/runners/tool_actions.py +90 -0
  40. yee88/runtime_loader.py +207 -0
  41. yee88/scheduler.py +159 -0
  42. yee88/schemas/__init__.py +1 -0
  43. yee88/schemas/claude.py +238 -0
  44. yee88/schemas/codex.py +169 -0
  45. yee88/schemas/opencode.py +51 -0
  46. yee88/schemas/pi.py +117 -0
  47. yee88/settings.py +360 -0
  48. yee88/telegram/__init__.py +20 -0
  49. yee88/telegram/api_models.py +37 -0
  50. yee88/telegram/api_schemas.py +152 -0
  51. yee88/telegram/backend.py +163 -0
  52. yee88/telegram/bridge.py +425 -0
  53. yee88/telegram/chat_prefs.py +242 -0
  54. yee88/telegram/chat_sessions.py +112 -0
  55. yee88/telegram/client.py +409 -0
  56. yee88/telegram/client_api.py +539 -0
  57. yee88/telegram/commands/__init__.py +12 -0
  58. yee88/telegram/commands/agent.py +196 -0
  59. yee88/telegram/commands/cancel.py +116 -0
  60. yee88/telegram/commands/dispatch.py +111 -0
  61. yee88/telegram/commands/executor.py +449 -0
  62. yee88/telegram/commands/file_transfer.py +586 -0
  63. yee88/telegram/commands/handlers.py +45 -0
  64. yee88/telegram/commands/media.py +143 -0
  65. yee88/telegram/commands/menu.py +139 -0
  66. yee88/telegram/commands/model.py +215 -0
  67. yee88/telegram/commands/overrides.py +159 -0
  68. yee88/telegram/commands/parse.py +30 -0
  69. yee88/telegram/commands/plan.py +16 -0
  70. yee88/telegram/commands/reasoning.py +234 -0
  71. yee88/telegram/commands/reply.py +23 -0
  72. yee88/telegram/commands/topics.py +332 -0
  73. yee88/telegram/commands/trigger.py +143 -0
  74. yee88/telegram/context.py +140 -0
  75. yee88/telegram/engine_defaults.py +86 -0
  76. yee88/telegram/engine_overrides.py +105 -0
  77. yee88/telegram/files.py +178 -0
  78. yee88/telegram/loop.py +1822 -0
  79. yee88/telegram/onboarding.py +1088 -0
  80. yee88/telegram/outbox.py +177 -0
  81. yee88/telegram/parsing.py +239 -0
  82. yee88/telegram/render.py +198 -0
  83. yee88/telegram/state_store.py +88 -0
  84. yee88/telegram/topic_state.py +334 -0
  85. yee88/telegram/topics.py +256 -0
  86. yee88/telegram/trigger_mode.py +68 -0
  87. yee88/telegram/types.py +63 -0
  88. yee88/telegram/voice.py +110 -0
  89. yee88/transport.py +53 -0
  90. yee88/transport_runtime.py +323 -0
  91. yee88/transports.py +76 -0
  92. yee88/utils/__init__.py +1 -0
  93. yee88/utils/git.py +87 -0
  94. yee88/utils/json_state.py +21 -0
  95. yee88/utils/paths.py +47 -0
  96. yee88/utils/streams.py +44 -0
  97. yee88/utils/subprocess.py +86 -0
  98. yee88/worktrees.py +135 -0
  99. yee88-0.3.0.dist-info/METADATA +116 -0
  100. yee88-0.3.0.dist-info/RECORD +103 -0
  101. yee88-0.3.0.dist-info/WHEEL +4 -0
  102. yee88-0.3.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import msgspec
7
+
8
+ from ..context import RunContext
9
+ from ..logging import get_logger
10
+ from ..model import ResumeToken
11
+ from .engine_overrides import EngineOverrides, normalize_overrides
12
+ from .state_store import JsonStateStore
13
+
14
+ logger = get_logger(__name__)
15
+
16
+ STATE_VERSION = 1
17
+ STATE_FILENAME = "telegram_topics_state.json"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class TopicThreadSnapshot:
22
+ chat_id: int
23
+ thread_id: int
24
+ context: RunContext | None
25
+ sessions: dict[str, str]
26
+ topic_title: str | None
27
+ default_engine: str | None
28
+
29
+
30
+ class _ContextState(msgspec.Struct, forbid_unknown_fields=False):
31
+ project: str | None = None
32
+ branch: str | None = None
33
+
34
+
35
+ class _SessionState(msgspec.Struct, forbid_unknown_fields=False):
36
+ resume: str
37
+
38
+
39
+ class _ThreadState(msgspec.Struct, forbid_unknown_fields=False):
40
+ context: _ContextState | None = None
41
+ sessions: dict[str, _SessionState] = msgspec.field(default_factory=dict)
42
+ topic_title: str | None = None
43
+ default_engine: str | None = None
44
+ trigger_mode: str | None = None
45
+ engine_overrides: dict[str, EngineOverrides] = msgspec.field(default_factory=dict)
46
+
47
+
48
+ class _TopicState(msgspec.Struct, forbid_unknown_fields=False):
49
+ version: int
50
+ threads: dict[str, _ThreadState] = msgspec.field(default_factory=dict)
51
+
52
+
53
+ def resolve_state_path(config_path: Path) -> Path:
54
+ return config_path.with_name(STATE_FILENAME)
55
+
56
+
57
+ def _thread_key(chat_id: int, thread_id: int) -> str:
58
+ return f"{chat_id}:{thread_id}"
59
+
60
+
61
+ def _normalize_text(value: str | None) -> str | None:
62
+ if value is None:
63
+ return None
64
+ value = value.strip()
65
+ return value or None
66
+
67
+
68
+ def _normalize_trigger_mode(value: str | None) -> str | None:
69
+ if value is None:
70
+ return None
71
+ value = value.strip().lower()
72
+ if value == "mentions":
73
+ return "mentions"
74
+ if value == "all":
75
+ return None
76
+ return None
77
+
78
+
79
+ def _normalize_engine_id(value: str | None) -> str | None:
80
+ if value is None:
81
+ return None
82
+ value = value.strip().lower()
83
+ return value or None
84
+
85
+
86
+ def _context_from_state(state: _ContextState | None) -> RunContext | None:
87
+ if state is None:
88
+ return None
89
+ project = _normalize_text(state.project)
90
+ branch = _normalize_text(state.branch)
91
+ if project is None and branch is None:
92
+ return None
93
+ return RunContext(project=project, branch=branch)
94
+
95
+
96
+ def _context_to_state(context: RunContext | None) -> _ContextState | None:
97
+ if context is None:
98
+ return None
99
+ project = _normalize_text(context.project)
100
+ branch = _normalize_text(context.branch)
101
+ if project is None and branch is None:
102
+ return None
103
+ return _ContextState(project=project, branch=branch)
104
+
105
+
106
+ def _new_state() -> _TopicState:
107
+ return _TopicState(version=STATE_VERSION, threads={})
108
+
109
+
110
+ class TopicStateStore(JsonStateStore[_TopicState]):
111
+ def __init__(self, path: Path) -> None:
112
+ super().__init__(
113
+ path,
114
+ version=STATE_VERSION,
115
+ state_type=_TopicState,
116
+ state_factory=_new_state,
117
+ log_prefix="telegram.topic_state",
118
+ logger=logger,
119
+ )
120
+
121
+ async def get_thread(
122
+ self, chat_id: int, thread_id: int
123
+ ) -> TopicThreadSnapshot | None:
124
+ async with self._lock:
125
+ self._reload_locked_if_needed()
126
+ thread = self._get_thread_locked(chat_id, thread_id)
127
+ if thread is None:
128
+ return None
129
+ return self._snapshot_locked(thread, chat_id, thread_id)
130
+
131
+ async def get_context(self, chat_id: int, thread_id: int) -> RunContext | None:
132
+ async with self._lock:
133
+ self._reload_locked_if_needed()
134
+ thread = self._get_thread_locked(chat_id, thread_id)
135
+ if thread is None:
136
+ return None
137
+ return _context_from_state(thread.context)
138
+
139
+ async def set_context(
140
+ self,
141
+ chat_id: int,
142
+ thread_id: int,
143
+ context: RunContext,
144
+ *,
145
+ topic_title: str | None = None,
146
+ ) -> None:
147
+ async with self._lock:
148
+ self._reload_locked_if_needed()
149
+ thread = self._ensure_thread_locked(chat_id, thread_id)
150
+ thread.context = _context_to_state(context)
151
+ if topic_title is not None:
152
+ thread.topic_title = topic_title
153
+ self._save_locked()
154
+
155
+ async def clear_context(self, chat_id: int, thread_id: int) -> None:
156
+ async with self._lock:
157
+ self._reload_locked_if_needed()
158
+ thread = self._get_thread_locked(chat_id, thread_id)
159
+ if thread is None:
160
+ return
161
+ thread.context = None
162
+ self._save_locked()
163
+
164
+ async def get_session_resume(
165
+ self, chat_id: int, thread_id: int, engine: str
166
+ ) -> ResumeToken | None:
167
+ async with self._lock:
168
+ self._reload_locked_if_needed()
169
+ thread = self._get_thread_locked(chat_id, thread_id)
170
+ if thread is None:
171
+ return None
172
+ entry = thread.sessions.get(engine)
173
+ if entry is None or not entry.resume:
174
+ return None
175
+ return ResumeToken(engine=engine, value=entry.resume)
176
+
177
+ async def get_default_engine(self, chat_id: int, thread_id: int) -> str | None:
178
+ async with self._lock:
179
+ self._reload_locked_if_needed()
180
+ thread = self._get_thread_locked(chat_id, thread_id)
181
+ if thread is None:
182
+ return None
183
+ return _normalize_text(thread.default_engine)
184
+
185
+ async def get_trigger_mode(self, chat_id: int, thread_id: int) -> str | None:
186
+ async with self._lock:
187
+ self._reload_locked_if_needed()
188
+ thread = self._get_thread_locked(chat_id, thread_id)
189
+ if thread is None:
190
+ return None
191
+ return _normalize_trigger_mode(thread.trigger_mode)
192
+
193
+ async def get_engine_override(
194
+ self, chat_id: int, thread_id: int, engine: str
195
+ ) -> EngineOverrides | None:
196
+ engine_key = _normalize_engine_id(engine)
197
+ if engine_key is None:
198
+ return None
199
+ async with self._lock:
200
+ self._reload_locked_if_needed()
201
+ thread = self._get_thread_locked(chat_id, thread_id)
202
+ if thread is None:
203
+ return None
204
+ override = thread.engine_overrides.get(engine_key)
205
+ return normalize_overrides(override)
206
+
207
+ async def set_default_engine(
208
+ self, chat_id: int, thread_id: int, engine: str | None
209
+ ) -> None:
210
+ normalized = _normalize_text(engine)
211
+ async with self._lock:
212
+ self._reload_locked_if_needed()
213
+ thread = self._ensure_thread_locked(chat_id, thread_id)
214
+ thread.default_engine = normalized
215
+ self._save_locked()
216
+
217
+ async def clear_default_engine(self, chat_id: int, thread_id: int) -> None:
218
+ await self.set_default_engine(chat_id, thread_id, None)
219
+
220
+ async def set_trigger_mode(
221
+ self, chat_id: int, thread_id: int, mode: str | None
222
+ ) -> None:
223
+ normalized = _normalize_trigger_mode(mode)
224
+ async with self._lock:
225
+ self._reload_locked_if_needed()
226
+ thread = self._ensure_thread_locked(chat_id, thread_id)
227
+ thread.trigger_mode = normalized
228
+ self._save_locked()
229
+
230
+ async def clear_trigger_mode(self, chat_id: int, thread_id: int) -> None:
231
+ await self.set_trigger_mode(chat_id, thread_id, None)
232
+
233
+ async def set_engine_override(
234
+ self,
235
+ chat_id: int,
236
+ thread_id: int,
237
+ engine: str,
238
+ override: EngineOverrides | None,
239
+ ) -> None:
240
+ engine_key = _normalize_engine_id(engine)
241
+ if engine_key is None:
242
+ return
243
+ normalized = normalize_overrides(override)
244
+ async with self._lock:
245
+ self._reload_locked_if_needed()
246
+ thread = self._ensure_thread_locked(chat_id, thread_id)
247
+ if normalized is None:
248
+ thread.engine_overrides.pop(engine_key, None)
249
+ else:
250
+ thread.engine_overrides[engine_key] = normalized
251
+ self._save_locked()
252
+
253
+ async def clear_engine_override(
254
+ self, chat_id: int, thread_id: int, engine: str
255
+ ) -> None:
256
+ await self.set_engine_override(chat_id, thread_id, engine, None)
257
+
258
+ async def set_session_resume(
259
+ self, chat_id: int, thread_id: int, token: ResumeToken
260
+ ) -> None:
261
+ async with self._lock:
262
+ self._reload_locked_if_needed()
263
+ thread = self._ensure_thread_locked(chat_id, thread_id)
264
+ thread.sessions[token.engine] = _SessionState(resume=token.value)
265
+ self._save_locked()
266
+
267
+ async def clear_sessions(self, chat_id: int, thread_id: int) -> None:
268
+ async with self._lock:
269
+ self._reload_locked_if_needed()
270
+ thread = self._get_thread_locked(chat_id, thread_id)
271
+ if thread is None:
272
+ return
273
+ thread.sessions = {}
274
+ self._save_locked()
275
+
276
+ async def delete_thread(self, chat_id: int, thread_id: int) -> None:
277
+ async with self._lock:
278
+ self._reload_locked_if_needed()
279
+ key = _thread_key(chat_id, thread_id)
280
+ if key not in self._state.threads:
281
+ return
282
+ self._state.threads.pop(key, None)
283
+ self._save_locked()
284
+
285
+ async def find_thread_for_context(
286
+ self, chat_id: int, context: RunContext
287
+ ) -> int | None:
288
+ async with self._lock:
289
+ self._reload_locked_if_needed()
290
+ target_project = _normalize_text(context.project)
291
+ target_branch = _normalize_text(context.branch)
292
+ for raw_key, thread in self._state.threads.items():
293
+ if not raw_key.startswith(f"{chat_id}:"):
294
+ continue
295
+ parsed = _context_from_state(thread.context)
296
+ if parsed is None:
297
+ continue
298
+ if parsed.project != target_project or parsed.branch != target_branch:
299
+ continue
300
+ try:
301
+ _, thread_str = raw_key.split(":", 1)
302
+ return int(thread_str)
303
+ except ValueError:
304
+ continue
305
+ return None
306
+
307
+ def _snapshot_locked(
308
+ self, thread: _ThreadState, chat_id: int, thread_id: int
309
+ ) -> TopicThreadSnapshot:
310
+ sessions = {
311
+ engine: entry.resume
312
+ for engine, entry in thread.sessions.items()
313
+ if entry.resume
314
+ }
315
+ return TopicThreadSnapshot(
316
+ chat_id=chat_id,
317
+ thread_id=thread_id,
318
+ context=_context_from_state(thread.context),
319
+ sessions=sessions,
320
+ topic_title=thread.topic_title,
321
+ default_engine=_normalize_text(thread.default_engine),
322
+ )
323
+
324
+ def _get_thread_locked(self, chat_id: int, thread_id: int) -> _ThreadState | None:
325
+ return self._state.threads.get(_thread_key(chat_id, thread_id))
326
+
327
+ def _ensure_thread_locked(self, chat_id: int, thread_id: int) -> _ThreadState:
328
+ key = _thread_key(chat_id, thread_id)
329
+ entry = self._state.threads.get(key)
330
+ if entry is not None:
331
+ return entry
332
+ entry = _ThreadState()
333
+ self._state.threads[key] = entry
334
+ return entry
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ..config import ConfigError
7
+ from ..context import RunContext
8
+ from ..settings import TelegramTopicsSettings
9
+ from ..transport_runtime import TransportRuntime
10
+ from .client import BotClient
11
+ from .topic_state import TopicStateStore, TopicThreadSnapshot
12
+ from .types import TelegramIncomingMessage
13
+
14
+ if TYPE_CHECKING:
15
+ from .bridge import TelegramBridgeConfig
16
+
17
+ __all__ = [
18
+ "_TOPICS_COMMANDS",
19
+ "_maybe_rename_topic",
20
+ "_maybe_update_topic_context",
21
+ "_resolve_topics_scope",
22
+ "_topic_key",
23
+ "_topic_title",
24
+ "_topics_chat_allowed",
25
+ "_topics_chat_project",
26
+ "_topics_command_error",
27
+ "_topics_scope_label",
28
+ "_validate_topics_setup",
29
+ ]
30
+
31
+ _TOPICS_COMMANDS = {"ctx", "new", "topic"}
32
+
33
+
34
+ def _resolve_topics_scope_raw(
35
+ scope: str, chat_id: int, project_chat_ids: Iterable[int]
36
+ ) -> tuple[str, frozenset[int]]:
37
+ project_ids = set(project_chat_ids)
38
+ if scope == "auto":
39
+ scope = "projects" if project_ids else "main"
40
+ if scope == "main":
41
+ return scope, frozenset({chat_id})
42
+ if scope == "projects":
43
+ return scope, frozenset(project_ids)
44
+ if scope == "all":
45
+ return scope, frozenset({chat_id, *project_ids})
46
+ raise ValueError(f"Invalid topics.scope: {scope!r}")
47
+
48
+
49
+ def _resolve_topics_scope(cfg: TelegramBridgeConfig) -> tuple[str, frozenset[int]]:
50
+ return _resolve_topics_scope_raw(
51
+ cfg.topics.scope, cfg.chat_id, cfg.runtime.project_chat_ids()
52
+ )
53
+
54
+
55
+ def _topics_scope_label(cfg: TelegramBridgeConfig) -> str:
56
+ resolved, _ = _resolve_topics_scope(cfg)
57
+ if cfg.topics.scope == "auto":
58
+ return f"auto ({resolved})"
59
+ return resolved
60
+
61
+
62
+ def _topics_chat_project(cfg: TelegramBridgeConfig, chat_id: int) -> str | None:
63
+ context = cfg.runtime.default_context_for_chat(chat_id)
64
+ return context.project if context is not None else None
65
+
66
+
67
+ def _topics_chat_allowed(
68
+ cfg: TelegramBridgeConfig,
69
+ chat_id: int,
70
+ *,
71
+ scope_chat_ids: frozenset[int] | None = None,
72
+ ) -> bool:
73
+ if not cfg.topics.enabled:
74
+ return False
75
+ if scope_chat_ids is None:
76
+ _, scope_chat_ids = _resolve_topics_scope(cfg)
77
+ return chat_id in scope_chat_ids
78
+
79
+
80
+ def _topics_command_error(
81
+ cfg: TelegramBridgeConfig,
82
+ chat_id: int,
83
+ *,
84
+ resolved_scope: str | None = None,
85
+ scope_chat_ids: frozenset[int] | None = None,
86
+ ) -> str | None:
87
+ if resolved_scope is None or scope_chat_ids is None:
88
+ resolved_scope, scope_chat_ids = _resolve_topics_scope(cfg)
89
+ if cfg.topics.enabled and chat_id in scope_chat_ids:
90
+ return None
91
+ if resolved_scope == "main":
92
+ if cfg.topics.scope == "auto":
93
+ return (
94
+ "topics commands are only available in the main chat (auto scope). "
95
+ 'to use topics in project chats, set `topics.scope = "projects"`.'
96
+ )
97
+ return "topics commands are only available in the main chat."
98
+ if resolved_scope == "projects":
99
+ if cfg.topics.scope == "auto":
100
+ return (
101
+ "topics commands are only available in project chats (auto scope). "
102
+ 'to use topics in the main chat, set `topics.scope = "main"`.'
103
+ )
104
+ return "topics commands are only available in project chats."
105
+ return "topics commands are only available in the main or project chats."
106
+
107
+
108
+ def _topic_key(
109
+ msg: TelegramIncomingMessage,
110
+ cfg: TelegramBridgeConfig,
111
+ *,
112
+ scope_chat_ids: frozenset[int] | None = None,
113
+ ) -> tuple[int, int] | None:
114
+ if not cfg.topics.enabled:
115
+ return None
116
+ if not _topics_chat_allowed(cfg, msg.chat_id, scope_chat_ids=scope_chat_ids):
117
+ return None
118
+ if msg.thread_id is None:
119
+ return None
120
+ return (msg.chat_id, msg.thread_id)
121
+
122
+
123
+ def _topic_title(*, runtime: TransportRuntime, context: RunContext) -> str:
124
+ project = (
125
+ runtime.project_alias_for_key(context.project)
126
+ if context.project is not None
127
+ else ""
128
+ )
129
+ if context.branch:
130
+ if project:
131
+ return f"{project} @{context.branch}"
132
+ return f"@{context.branch}"
133
+ return project or "topic"
134
+
135
+
136
+ async def _maybe_rename_topic(
137
+ cfg: TelegramBridgeConfig,
138
+ store: TopicStateStore,
139
+ *,
140
+ chat_id: int,
141
+ thread_id: int,
142
+ context: RunContext,
143
+ snapshot: TopicThreadSnapshot | None = None,
144
+ ) -> None:
145
+ title = _topic_title(runtime=cfg.runtime, context=context)
146
+ if snapshot is None:
147
+ snapshot = await store.get_thread(chat_id, thread_id)
148
+ if snapshot is not None and snapshot.topic_title == title:
149
+ return
150
+ updated = await cfg.bot.edit_forum_topic(
151
+ chat_id=chat_id,
152
+ message_thread_id=thread_id,
153
+ name=title,
154
+ )
155
+ if not updated:
156
+ from ..logging import get_logger
157
+
158
+ logger = get_logger(__name__)
159
+ logger.warning(
160
+ "topics.rename.failed",
161
+ chat_id=chat_id,
162
+ thread_id=thread_id,
163
+ title=title,
164
+ )
165
+ return
166
+ await store.set_context(chat_id, thread_id, context, topic_title=title)
167
+
168
+
169
+ async def _maybe_update_topic_context(
170
+ *,
171
+ cfg: TelegramBridgeConfig,
172
+ topic_store: TopicStateStore | None,
173
+ topic_key: tuple[int, int] | None,
174
+ context: RunContext | None,
175
+ context_source: str,
176
+ ) -> None:
177
+ if (
178
+ topic_store is None
179
+ or topic_key is None
180
+ or context is None
181
+ or context_source != "directives"
182
+ ):
183
+ return
184
+ await topic_store.set_context(topic_key[0], topic_key[1], context)
185
+ await _maybe_rename_topic(
186
+ cfg,
187
+ topic_store,
188
+ chat_id=topic_key[0],
189
+ thread_id=topic_key[1],
190
+ context=context,
191
+ )
192
+
193
+
194
+ async def _validate_topics_setup(cfg: TelegramBridgeConfig) -> None:
195
+ await _validate_topics_setup_for(
196
+ bot=cfg.bot,
197
+ topics=cfg.topics,
198
+ chat_id=cfg.chat_id,
199
+ project_chat_ids=cfg.runtime.project_chat_ids(),
200
+ )
201
+
202
+
203
+ async def _validate_topics_setup_for(
204
+ *,
205
+ bot: BotClient,
206
+ topics: TelegramTopicsSettings,
207
+ chat_id: int,
208
+ project_chat_ids: Iterable[int],
209
+ ) -> None:
210
+ if not topics.enabled:
211
+ return
212
+ me = await bot.get_me()
213
+ if me is None:
214
+ raise ConfigError("failed to fetch bot id for topics validation.")
215
+ bot_id = me.id
216
+ scope, chat_ids = _resolve_topics_scope_raw(topics.scope, chat_id, project_chat_ids)
217
+ if scope == "projects" and not chat_ids:
218
+ raise ConfigError(
219
+ "topics enabled but no project chats are configured; "
220
+ 'set projects.<alias>.chat_id for forum chats or use scope="main".'
221
+ )
222
+
223
+ for chat_id in chat_ids:
224
+ chat = await bot.get_chat(chat_id)
225
+ if chat is None:
226
+ raise ConfigError(
227
+ f"failed to fetch chat info for topics validation ({chat_id})."
228
+ )
229
+ if chat.type != "supergroup":
230
+ raise ConfigError(
231
+ "topics enabled but chat is not a supergroup "
232
+ f"(chat_id={chat_id}); convert the group and enable topics."
233
+ )
234
+ if chat.is_forum is not True:
235
+ raise ConfigError(
236
+ "topics enabled but chat does not have topics enabled "
237
+ f"(chat_id={chat_id}); turn on topics in group settings."
238
+ )
239
+ member = await bot.get_chat_member(chat_id, bot_id)
240
+ if member is None:
241
+ raise ConfigError(
242
+ "failed to fetch bot permissions "
243
+ f"(chat_id={chat_id}); promote the bot to admin with manage topics."
244
+ )
245
+ if member.status == "creator":
246
+ continue
247
+ if member.status != "administrator":
248
+ raise ConfigError(
249
+ "topics enabled but bot is not an admin "
250
+ f"(chat_id={chat_id}); promote it and grant manage topics."
251
+ )
252
+ if member.can_manage_topics is not True:
253
+ raise ConfigError(
254
+ "topics enabled but bot lacks manage topics permission "
255
+ f"(chat_id={chat_id}); grant can_manage_topics."
256
+ )
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from ..transport_runtime import TransportRuntime
6
+ from .chat_prefs import ChatPrefsStore
7
+ from .commands.parse import _parse_slash_command
8
+ from .topic_state import TopicStateStore
9
+ from .types import TelegramIncomingMessage
10
+
11
+ TriggerMode = Literal["all", "mentions"]
12
+
13
+
14
+ async def resolve_trigger_mode(
15
+ *,
16
+ chat_id: int,
17
+ thread_id: int | None,
18
+ chat_prefs: ChatPrefsStore | None,
19
+ topic_store: TopicStateStore | None,
20
+ ) -> TriggerMode:
21
+ if topic_store is not None and thread_id is not None:
22
+ topic_mode = await topic_store.get_trigger_mode(chat_id, thread_id)
23
+ if topic_mode == "mentions":
24
+ return "mentions"
25
+ if chat_prefs is not None:
26
+ chat_mode = await chat_prefs.get_trigger_mode(chat_id)
27
+ if chat_mode == "mentions":
28
+ return "mentions"
29
+ return "all"
30
+
31
+
32
+ def should_trigger_run(
33
+ msg: TelegramIncomingMessage,
34
+ *,
35
+ bot_username: str | None,
36
+ runtime: TransportRuntime,
37
+ command_ids: set[str],
38
+ reserved_chat_commands: set[str],
39
+ ) -> bool:
40
+ text = msg.text or ""
41
+ lowered = text.lower()
42
+ if bot_username:
43
+ needle = f"@{bot_username}"
44
+ if needle in lowered:
45
+ return True
46
+ implicit_topic_reply = (
47
+ msg.thread_id is not None and msg.reply_to_message_id == msg.thread_id
48
+ )
49
+
50
+ if msg.reply_to_is_bot and not implicit_topic_reply:
51
+ return True
52
+ if (
53
+ bot_username
54
+ and msg.reply_to_username
55
+ and msg.reply_to_username.lower() == bot_username
56
+ and not implicit_topic_reply
57
+ ):
58
+ return True
59
+ command_id, _ = _parse_slash_command(text)
60
+ if not command_id:
61
+ return False
62
+ if command_id in reserved_chat_commands or command_id in command_ids:
63
+ return True
64
+ engine_ids = {engine.lower() for engine in runtime.available_engine_ids()}
65
+ if command_id in engine_ids:
66
+ return True
67
+ project_aliases = {alias.lower() for alias in runtime.project_aliases()}
68
+ return command_id in project_aliases