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,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
|