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,539 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, TypeVar
4
+
5
+ import httpx
6
+ import msgspec
7
+
8
+ from ..logging import get_logger
9
+ from .api_models import Chat, ChatMember, File, ForumTopic, Message, Update, User
10
+
11
+ logger = get_logger(__name__)
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ class RetryAfter(Exception):
17
+ def __init__(self, retry_after: float, description: str | None = None) -> None:
18
+ super().__init__(description or f"retry after {retry_after}")
19
+ self.retry_after = float(retry_after)
20
+ self.description = description
21
+
22
+
23
+ class TelegramRetryAfter(RetryAfter):
24
+ pass
25
+
26
+
27
+ def retry_after_from_payload(payload: dict[str, Any]) -> float | None:
28
+ params = payload.get("parameters")
29
+ if isinstance(params, dict):
30
+ retry_after = params.get("retry_after")
31
+ if isinstance(retry_after, (int, float)):
32
+ return float(retry_after)
33
+ return None
34
+
35
+
36
+ class BotClient(Protocol):
37
+ async def close(self) -> None: ...
38
+
39
+ async def get_updates(
40
+ self,
41
+ offset: int | None,
42
+ timeout_s: int = 50,
43
+ allowed_updates: list[str] | None = None,
44
+ ) -> list[Update] | None: ...
45
+
46
+ async def get_file(self, file_id: str) -> File | None: ...
47
+
48
+ async def download_file(self, file_path: str) -> bytes | None: ...
49
+
50
+ async def send_message(
51
+ self,
52
+ chat_id: int,
53
+ text: str,
54
+ reply_to_message_id: int | None = None,
55
+ disable_notification: bool | None = False,
56
+ message_thread_id: int | None = None,
57
+ entities: list[dict] | None = None,
58
+ parse_mode: str | None = None,
59
+ reply_markup: dict[str, Any] | None = None,
60
+ *,
61
+ replace_message_id: int | None = None,
62
+ ) -> Message | None: ...
63
+
64
+ async def send_document(
65
+ self,
66
+ chat_id: int,
67
+ filename: str,
68
+ content: bytes,
69
+ reply_to_message_id: int | None = None,
70
+ message_thread_id: int | None = None,
71
+ disable_notification: bool | None = False,
72
+ caption: str | None = None,
73
+ ) -> Message | None: ...
74
+
75
+ async def edit_message_text(
76
+ self,
77
+ chat_id: int,
78
+ message_id: int,
79
+ text: str,
80
+ entities: list[dict] | None = None,
81
+ parse_mode: str | None = None,
82
+ reply_markup: dict[str, Any] | None = None,
83
+ *,
84
+ wait: bool = True,
85
+ ) -> Message | None: ...
86
+
87
+ async def delete_message(
88
+ self,
89
+ chat_id: int,
90
+ message_id: int,
91
+ ) -> bool: ...
92
+
93
+ async def set_my_commands(
94
+ self,
95
+ commands: list[dict[str, Any]],
96
+ *,
97
+ scope: dict[str, Any] | None = None,
98
+ language_code: str | None = None,
99
+ ) -> bool: ...
100
+
101
+ async def get_me(self) -> User | None: ...
102
+
103
+ async def answer_callback_query(
104
+ self,
105
+ callback_query_id: str,
106
+ text: str | None = None,
107
+ show_alert: bool | None = None,
108
+ ) -> bool: ...
109
+
110
+ async def get_chat(self, chat_id: int) -> Chat | None: ...
111
+
112
+ async def get_chat_member(
113
+ self, chat_id: int, user_id: int
114
+ ) -> ChatMember | None: ...
115
+
116
+ async def create_forum_topic(
117
+ self,
118
+ chat_id: int,
119
+ name: str,
120
+ ) -> ForumTopic | None: ...
121
+
122
+ async def edit_forum_topic(
123
+ self,
124
+ chat_id: int,
125
+ message_thread_id: int,
126
+ name: str,
127
+ ) -> bool: ...
128
+
129
+
130
+ class HttpBotClient:
131
+ def __init__(
132
+ self,
133
+ token: str,
134
+ *,
135
+ timeout_s: float = 120,
136
+ http_client: httpx.AsyncClient | None = None,
137
+ ) -> None:
138
+ if not token:
139
+ raise ValueError("Telegram token is empty")
140
+ self._base = f"https://api.telegram.org/bot{token}"
141
+ self._file_base = f"https://api.telegram.org/file/bot{token}"
142
+ self._http_client = http_client or httpx.AsyncClient(timeout=timeout_s)
143
+ self._owns_http_client = http_client is None
144
+
145
+ async def close(self) -> None:
146
+ if self._owns_http_client:
147
+ await self._http_client.aclose()
148
+
149
+ def _parse_telegram_envelope(
150
+ self,
151
+ *,
152
+ method: str,
153
+ resp: httpx.Response,
154
+ payload: Any,
155
+ ) -> Any | None:
156
+ if not isinstance(payload, dict):
157
+ logger.error(
158
+ "telegram.invalid_payload",
159
+ method=method,
160
+ url=str(resp.request.url),
161
+ payload=payload,
162
+ )
163
+ return None
164
+
165
+ if not payload.get("ok"):
166
+ if payload.get("error_code") == 429:
167
+ retry_after = retry_after_from_payload(payload)
168
+ retry_after = 5.0 if retry_after is None else retry_after
169
+ logger.warning(
170
+ "telegram.rate_limited",
171
+ method=method,
172
+ url=str(resp.request.url),
173
+ retry_after=retry_after,
174
+ )
175
+ raise TelegramRetryAfter(retry_after)
176
+ logger.error(
177
+ "telegram.api_error",
178
+ method=method,
179
+ url=str(resp.request.url),
180
+ payload=payload,
181
+ )
182
+ return None
183
+
184
+ logger.debug("telegram.response", method=method, payload=payload)
185
+ return payload.get("result")
186
+
187
+ async def _request(
188
+ self,
189
+ method: str,
190
+ *,
191
+ json: dict[str, Any] | None = None,
192
+ data: dict[str, Any] | None = None,
193
+ files: dict[str, Any] | None = None,
194
+ ) -> Any | None:
195
+ request_payload = json if json is not None else data
196
+ logger.debug("telegram.request", method=method, payload=request_payload)
197
+ try:
198
+ if json is not None:
199
+ resp = await self._http_client.post(f"{self._base}/{method}", json=json)
200
+ else:
201
+ resp = await self._http_client.post(
202
+ f"{self._base}/{method}", data=data, files=files
203
+ )
204
+ except httpx.HTTPError as exc:
205
+ url = getattr(exc.request, "url", None)
206
+ logger.error(
207
+ "telegram.network_error",
208
+ method=method,
209
+ url=str(url) if url is not None else None,
210
+ error=str(exc),
211
+ error_type=exc.__class__.__name__,
212
+ )
213
+ return None
214
+
215
+ try:
216
+ resp.raise_for_status()
217
+ except httpx.HTTPStatusError as exc:
218
+ if resp.status_code == 429:
219
+ retry_after: float | None = None
220
+ try:
221
+ response_payload = resp.json()
222
+ except Exception: # noqa: BLE001
223
+ response_payload = None
224
+ if isinstance(response_payload, dict):
225
+ retry_after = retry_after_from_payload(response_payload)
226
+ retry_after = 5.0 if retry_after is None else retry_after
227
+ logger.warning(
228
+ "telegram.rate_limited",
229
+ method=method,
230
+ status=resp.status_code,
231
+ url=str(resp.request.url),
232
+ retry_after=retry_after,
233
+ )
234
+ raise TelegramRetryAfter(retry_after) from exc
235
+ body = resp.text
236
+ logger.error(
237
+ "telegram.http_error",
238
+ method=method,
239
+ status=resp.status_code,
240
+ url=str(resp.request.url),
241
+ error=str(exc),
242
+ body=body,
243
+ )
244
+ return None
245
+
246
+ try:
247
+ response_payload = resp.json()
248
+ except Exception as exc: # noqa: BLE001
249
+ body = resp.text
250
+ logger.error(
251
+ "telegram.bad_response",
252
+ method=method,
253
+ status=resp.status_code,
254
+ url=str(resp.request.url),
255
+ error=str(exc),
256
+ error_type=exc.__class__.__name__,
257
+ body=body,
258
+ )
259
+ return None
260
+
261
+ return self._parse_telegram_envelope(
262
+ method=method,
263
+ resp=resp,
264
+ payload=response_payload,
265
+ )
266
+
267
+ def _decode_result(
268
+ self,
269
+ *,
270
+ method: str,
271
+ payload: Any,
272
+ model: type[T],
273
+ ) -> T | None:
274
+ if payload is None:
275
+ return None
276
+ try:
277
+ return msgspec.convert(payload, type=model)
278
+ except Exception as exc: # noqa: BLE001
279
+ logger.error(
280
+ "telegram.decode_error",
281
+ method=method,
282
+ error=str(exc),
283
+ error_type=exc.__class__.__name__,
284
+ )
285
+ return None
286
+
287
+ async def _post(self, method: str, json_data: dict[str, Any]) -> Any | None:
288
+ return await self._request(method, json=json_data)
289
+
290
+ async def _post_form(
291
+ self,
292
+ method: str,
293
+ data: dict[str, Any],
294
+ files: dict[str, Any],
295
+ ) -> Any | None:
296
+ return await self._request(method, data=data, files=files)
297
+
298
+ async def get_updates(
299
+ self,
300
+ offset: int | None,
301
+ timeout_s: int = 50,
302
+ allowed_updates: list[str] | None = None,
303
+ ) -> list[Update] | None:
304
+ params: dict[str, Any] = {"timeout": timeout_s}
305
+ if offset is not None:
306
+ params["offset"] = offset
307
+ if allowed_updates is not None:
308
+ params["allowed_updates"] = allowed_updates
309
+ result = await self._post("getUpdates", params)
310
+ if result is None or not isinstance(result, list):
311
+ return None
312
+ try:
313
+ return msgspec.convert(result, type=list[Update])
314
+ except Exception as exc: # noqa: BLE001
315
+ logger.error(
316
+ "telegram.decode_error",
317
+ method="getUpdates",
318
+ error=str(exc),
319
+ error_type=exc.__class__.__name__,
320
+ )
321
+ return None
322
+
323
+ async def get_file(self, file_id: str) -> File | None:
324
+ result = await self._post("getFile", {"file_id": file_id})
325
+ return self._decode_result(method="getFile", payload=result, model=File)
326
+
327
+ async def download_file(self, file_path: str) -> bytes | None:
328
+ url = f"{self._file_base}/{file_path}"
329
+ try:
330
+ resp = await self._http_client.get(url)
331
+ except httpx.HTTPError as exc:
332
+ request_url = getattr(exc.request, "url", None)
333
+ logger.error(
334
+ "telegram.file_network_error",
335
+ url=str(request_url) if request_url is not None else None,
336
+ error=str(exc),
337
+ error_type=exc.__class__.__name__,
338
+ )
339
+ return None
340
+ try:
341
+ resp.raise_for_status()
342
+ except httpx.HTTPStatusError as exc:
343
+ if resp.status_code == 429:
344
+ retry_after: float | None = None
345
+ try:
346
+ response_payload = resp.json()
347
+ except Exception: # noqa: BLE001
348
+ response_payload = None
349
+ if isinstance(response_payload, dict):
350
+ retry_after = retry_after_from_payload(response_payload)
351
+ retry_after = 5.0 if retry_after is None else retry_after
352
+ logger.warning(
353
+ "telegram.rate_limited",
354
+ method="download_file",
355
+ status=resp.status_code,
356
+ url=str(resp.request.url),
357
+ retry_after=retry_after,
358
+ )
359
+ raise TelegramRetryAfter(retry_after) from exc
360
+
361
+ logger.error(
362
+ "telegram.file_http_error",
363
+ status=resp.status_code,
364
+ url=str(resp.request.url),
365
+ error=str(exc),
366
+ body=resp.text,
367
+ )
368
+ return None
369
+ return resp.content
370
+
371
+ async def send_message(
372
+ self,
373
+ chat_id: int,
374
+ text: str,
375
+ reply_to_message_id: int | None = None,
376
+ disable_notification: bool | None = False,
377
+ message_thread_id: int | None = None,
378
+ entities: list[dict] | None = None,
379
+ parse_mode: str | None = None,
380
+ reply_markup: dict[str, Any] | None = None,
381
+ *,
382
+ replace_message_id: int | None = None,
383
+ ) -> Message | None:
384
+ params: dict[str, Any] = {"chat_id": chat_id, "text": text}
385
+ if disable_notification is not None:
386
+ params["disable_notification"] = disable_notification
387
+ if reply_to_message_id is not None:
388
+ params["reply_to_message_id"] = reply_to_message_id
389
+ if message_thread_id is not None:
390
+ params["message_thread_id"] = message_thread_id
391
+ if entities is not None:
392
+ params["entities"] = entities
393
+ if parse_mode is not None:
394
+ params["parse_mode"] = parse_mode
395
+ params["link_preview_options"] = {"is_disabled": True}
396
+ if reply_markup is not None:
397
+ params["reply_markup"] = reply_markup
398
+ result = await self._post("sendMessage", params)
399
+ return self._decode_result(method="sendMessage", payload=result, model=Message)
400
+
401
+ async def send_document(
402
+ self,
403
+ chat_id: int,
404
+ filename: str,
405
+ content: bytes,
406
+ reply_to_message_id: int | None = None,
407
+ message_thread_id: int | None = None,
408
+ disable_notification: bool | None = False,
409
+ caption: str | None = None,
410
+ ) -> Message | None:
411
+ params: dict[str, Any] = {"chat_id": chat_id}
412
+ if disable_notification is not None:
413
+ params["disable_notification"] = disable_notification
414
+ if reply_to_message_id is not None:
415
+ params["reply_to_message_id"] = reply_to_message_id
416
+ if message_thread_id is not None:
417
+ params["message_thread_id"] = message_thread_id
418
+ if caption is not None:
419
+ params["caption"] = caption
420
+ result = await self._post_form(
421
+ "sendDocument",
422
+ params,
423
+ files={"document": (filename, content)},
424
+ )
425
+ return self._decode_result(method="sendDocument", payload=result, model=Message)
426
+
427
+ async def edit_message_text(
428
+ self,
429
+ chat_id: int,
430
+ message_id: int,
431
+ text: str,
432
+ entities: list[dict] | None = None,
433
+ parse_mode: str | None = None,
434
+ reply_markup: dict[str, Any] | None = None,
435
+ *,
436
+ wait: bool = True,
437
+ ) -> Message | None:
438
+ params: dict[str, Any] = {
439
+ "chat_id": chat_id,
440
+ "message_id": message_id,
441
+ "text": text,
442
+ }
443
+ if entities is not None:
444
+ params["entities"] = entities
445
+ if parse_mode is not None:
446
+ params["parse_mode"] = parse_mode
447
+ params["link_preview_options"] = {"is_disabled": True}
448
+ if reply_markup is not None:
449
+ params["reply_markup"] = reply_markup
450
+ result = await self._post("editMessageText", params)
451
+ return self._decode_result(
452
+ method="editMessageText",
453
+ payload=result,
454
+ model=Message,
455
+ )
456
+
457
+ async def delete_message(
458
+ self,
459
+ chat_id: int,
460
+ message_id: int,
461
+ ) -> bool:
462
+ result = await self._post(
463
+ "deleteMessage",
464
+ {"chat_id": chat_id, "message_id": message_id},
465
+ )
466
+ return bool(result)
467
+
468
+ async def set_my_commands(
469
+ self,
470
+ commands: list[dict[str, Any]],
471
+ *,
472
+ scope: dict[str, Any] | None = None,
473
+ language_code: str | None = None,
474
+ ) -> bool:
475
+ params: dict[str, Any] = {"commands": commands}
476
+ if scope is not None:
477
+ params["scope"] = scope
478
+ if language_code is not None:
479
+ params["language_code"] = language_code
480
+ result = await self._post("setMyCommands", params)
481
+ return bool(result)
482
+
483
+ async def get_me(self) -> User | None:
484
+ result = await self._post("getMe", {})
485
+ return self._decode_result(method="getMe", payload=result, model=User)
486
+
487
+ async def answer_callback_query(
488
+ self,
489
+ callback_query_id: str,
490
+ text: str | None = None,
491
+ show_alert: bool | None = None,
492
+ ) -> bool:
493
+ params: dict[str, Any] = {"callback_query_id": callback_query_id}
494
+ if text is not None:
495
+ params["text"] = text
496
+ if show_alert is not None:
497
+ params["show_alert"] = show_alert
498
+ result = await self._post("answerCallbackQuery", params)
499
+ return bool(result)
500
+
501
+ async def get_chat(self, chat_id: int) -> Chat | None:
502
+ result = await self._post("getChat", {"chat_id": chat_id})
503
+ return self._decode_result(method="getChat", payload=result, model=Chat)
504
+
505
+ async def get_chat_member(self, chat_id: int, user_id: int) -> ChatMember | None:
506
+ result = await self._post(
507
+ "getChatMember", {"chat_id": chat_id, "user_id": user_id}
508
+ )
509
+ return self._decode_result(
510
+ method="getChatMember",
511
+ payload=result,
512
+ model=ChatMember,
513
+ )
514
+
515
+ async def create_forum_topic(self, chat_id: int, name: str) -> ForumTopic | None:
516
+ result = await self._post(
517
+ "createForumTopic", {"chat_id": chat_id, "name": name}
518
+ )
519
+ return self._decode_result(
520
+ method="createForumTopic",
521
+ payload=result,
522
+ model=ForumTopic,
523
+ )
524
+
525
+ async def edit_forum_topic(
526
+ self,
527
+ chat_id: int,
528
+ message_thread_id: int,
529
+ name: str,
530
+ ) -> bool:
531
+ result = await self._post(
532
+ "editForumTopic",
533
+ {
534
+ "chat_id": chat_id,
535
+ "message_thread_id": message_thread_id,
536
+ "name": name,
537
+ },
538
+ )
539
+ return bool(result)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from .cancel import handle_callback_cancel, handle_cancel
4
+ from .menu import build_bot_commands
5
+ from .parse import is_cancel_command
6
+
7
+ __all__ = [
8
+ "build_bot_commands",
9
+ "handle_callback_cancel",
10
+ "handle_cancel",
11
+ "is_cancel_command",
12
+ ]