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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/telegram/outbox.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, TYPE_CHECKING
|
|
6
|
+
from collections.abc import Awaitable, Callable, Hashable
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
|
|
10
|
+
from .client_api import RetryAfter
|
|
11
|
+
|
|
12
|
+
SEND_PRIORITY = 0
|
|
13
|
+
DELETE_PRIORITY = 1
|
|
14
|
+
EDIT_PRIORITY = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class OutboxOp:
|
|
19
|
+
execute: Callable[[], Awaitable[Any]]
|
|
20
|
+
priority: int
|
|
21
|
+
queued_at: float
|
|
22
|
+
chat_id: int | None
|
|
23
|
+
label: str | None = None
|
|
24
|
+
done: anyio.Event = field(default_factory=anyio.Event)
|
|
25
|
+
result: Any = None
|
|
26
|
+
|
|
27
|
+
def set_result(self, result: Any) -> None:
|
|
28
|
+
if self.done.is_set():
|
|
29
|
+
return
|
|
30
|
+
self.result = result
|
|
31
|
+
self.done.set()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TelegramOutbox:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
interval_for_chat: Callable[[int | None], float],
|
|
39
|
+
clock: Callable[[], float] = time.monotonic,
|
|
40
|
+
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
|
|
41
|
+
on_error: Callable[[OutboxOp, Exception], None] | None = None,
|
|
42
|
+
on_outbox_error: Callable[[Exception], None] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._interval_for_chat = interval_for_chat
|
|
45
|
+
self._clock = clock
|
|
46
|
+
self._sleep = sleep
|
|
47
|
+
self._on_error = on_error
|
|
48
|
+
self._on_outbox_error = on_outbox_error
|
|
49
|
+
self._pending: dict[Hashable, OutboxOp] = {}
|
|
50
|
+
self._cond = anyio.Condition()
|
|
51
|
+
self._start_lock = anyio.Lock()
|
|
52
|
+
self._closed = False
|
|
53
|
+
self._tg: TaskGroup | None = None
|
|
54
|
+
self.next_at = 0.0
|
|
55
|
+
self.retry_at = 0.0
|
|
56
|
+
|
|
57
|
+
async def ensure_worker(self) -> None:
|
|
58
|
+
async with self._start_lock:
|
|
59
|
+
if self._tg is not None or self._closed:
|
|
60
|
+
return
|
|
61
|
+
self._tg = await anyio.create_task_group().__aenter__()
|
|
62
|
+
self._tg.start_soon(self.run)
|
|
63
|
+
|
|
64
|
+
async def enqueue(self, *, key: Hashable, op: OutboxOp, wait: bool = True) -> Any:
|
|
65
|
+
await self.ensure_worker()
|
|
66
|
+
async with self._cond:
|
|
67
|
+
if self._closed:
|
|
68
|
+
op.set_result(None)
|
|
69
|
+
return op.result
|
|
70
|
+
previous = self._pending.get(key)
|
|
71
|
+
if previous is not None:
|
|
72
|
+
op.queued_at = previous.queued_at
|
|
73
|
+
previous.set_result(None)
|
|
74
|
+
self._pending[key] = op
|
|
75
|
+
self._cond.notify()
|
|
76
|
+
if not wait:
|
|
77
|
+
return None
|
|
78
|
+
await op.done.wait()
|
|
79
|
+
return op.result
|
|
80
|
+
|
|
81
|
+
async def drop_pending(self, *, key: Hashable) -> None:
|
|
82
|
+
async with self._cond:
|
|
83
|
+
pending = self._pending.pop(key, None)
|
|
84
|
+
if pending is not None:
|
|
85
|
+
pending.set_result(None)
|
|
86
|
+
self._cond.notify()
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
async with self._cond:
|
|
90
|
+
self._closed = True
|
|
91
|
+
self.fail_pending()
|
|
92
|
+
self._cond.notify_all()
|
|
93
|
+
if self._tg is not None:
|
|
94
|
+
await self._tg.__aexit__(None, None, None)
|
|
95
|
+
self._tg = None
|
|
96
|
+
|
|
97
|
+
def fail_pending(self) -> None:
|
|
98
|
+
for pending in list(self._pending.values()):
|
|
99
|
+
pending.set_result(None)
|
|
100
|
+
self._pending.clear()
|
|
101
|
+
|
|
102
|
+
def pick_locked(self) -> tuple[Hashable, OutboxOp] | None:
|
|
103
|
+
if not self._pending:
|
|
104
|
+
return None
|
|
105
|
+
return min(
|
|
106
|
+
self._pending.items(),
|
|
107
|
+
key=lambda item: (item[1].priority, item[1].queued_at),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def execute_op(self, op: OutboxOp) -> Any:
|
|
111
|
+
try:
|
|
112
|
+
return await op.execute()
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
if isinstance(exc, RetryAfter):
|
|
115
|
+
raise
|
|
116
|
+
if self._on_error is not None:
|
|
117
|
+
self._on_error(op, exc)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
async def sleep_until(self, deadline: float) -> None:
|
|
121
|
+
delay = deadline - self._clock()
|
|
122
|
+
if delay > 0:
|
|
123
|
+
await self._sleep(delay)
|
|
124
|
+
|
|
125
|
+
async def run(self) -> None:
|
|
126
|
+
cancel_exc = anyio.get_cancelled_exc_class()
|
|
127
|
+
try:
|
|
128
|
+
while True:
|
|
129
|
+
async with self._cond:
|
|
130
|
+
while not self._pending and not self._closed:
|
|
131
|
+
await self._cond.wait()
|
|
132
|
+
if self._closed and not self._pending:
|
|
133
|
+
return
|
|
134
|
+
blocked_until = max(self.next_at, self.retry_at)
|
|
135
|
+
if self._clock() < blocked_until:
|
|
136
|
+
await self.sleep_until(blocked_until)
|
|
137
|
+
continue
|
|
138
|
+
async with self._cond:
|
|
139
|
+
if self._closed and not self._pending:
|
|
140
|
+
return
|
|
141
|
+
picked = self.pick_locked()
|
|
142
|
+
if picked is None:
|
|
143
|
+
continue
|
|
144
|
+
key, op = picked
|
|
145
|
+
self._pending.pop(key, None)
|
|
146
|
+
started_at = self._clock()
|
|
147
|
+
try:
|
|
148
|
+
result = await self.execute_op(op)
|
|
149
|
+
except RetryAfter as exc:
|
|
150
|
+
self.retry_at = max(self.retry_at, self._clock() + exc.retry_after)
|
|
151
|
+
async with self._cond:
|
|
152
|
+
if self._closed:
|
|
153
|
+
op.set_result(None)
|
|
154
|
+
elif key not in self._pending:
|
|
155
|
+
self._pending[key] = op
|
|
156
|
+
self._cond.notify()
|
|
157
|
+
else:
|
|
158
|
+
op.set_result(None)
|
|
159
|
+
continue
|
|
160
|
+
self.next_at = started_at + self._interval_for_chat(op.chat_id)
|
|
161
|
+
op.set_result(result)
|
|
162
|
+
except cancel_exc:
|
|
163
|
+
return
|
|
164
|
+
except Exception as exc: # noqa: BLE001
|
|
165
|
+
async with self._cond:
|
|
166
|
+
self._closed = True
|
|
167
|
+
self.fail_pending()
|
|
168
|
+
self._cond.notify_all()
|
|
169
|
+
if self._on_outbox_error is not None:
|
|
170
|
+
self._on_outbox_error(exc)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if TYPE_CHECKING:
|
|
175
|
+
from anyio.abc import TaskGroup
|
|
176
|
+
else:
|
|
177
|
+
TaskGroup = object
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
|
4
|
+
|
|
5
|
+
import anyio
|
|
6
|
+
import msgspec
|
|
7
|
+
|
|
8
|
+
from ..logging import get_logger
|
|
9
|
+
from .api_schemas import (
|
|
10
|
+
CallbackQuery,
|
|
11
|
+
Document,
|
|
12
|
+
Message,
|
|
13
|
+
PhotoSize,
|
|
14
|
+
Sticker,
|
|
15
|
+
Update,
|
|
16
|
+
Video,
|
|
17
|
+
)
|
|
18
|
+
from .client_api import BotClient
|
|
19
|
+
from .types import (
|
|
20
|
+
TelegramCallbackQuery,
|
|
21
|
+
TelegramDocument,
|
|
22
|
+
TelegramIncomingMessage,
|
|
23
|
+
TelegramIncomingUpdate,
|
|
24
|
+
TelegramVoice,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_incoming_update(
|
|
31
|
+
update: Update,
|
|
32
|
+
*,
|
|
33
|
+
chat_id: int | None = None,
|
|
34
|
+
chat_ids: set[int] | None = None,
|
|
35
|
+
) -> TelegramIncomingUpdate | None:
|
|
36
|
+
if update.message is not None:
|
|
37
|
+
return _parse_incoming_message(
|
|
38
|
+
update.message,
|
|
39
|
+
chat_id=chat_id,
|
|
40
|
+
chat_ids=chat_ids,
|
|
41
|
+
)
|
|
42
|
+
if update.callback_query is not None:
|
|
43
|
+
return _parse_callback_query(
|
|
44
|
+
update.callback_query,
|
|
45
|
+
chat_id=chat_id,
|
|
46
|
+
chat_ids=chat_ids,
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_incoming_message(
|
|
52
|
+
msg: Message,
|
|
53
|
+
*,
|
|
54
|
+
chat_id: int | None = None,
|
|
55
|
+
chat_ids: set[int] | None = None,
|
|
56
|
+
) -> TelegramIncomingMessage | None:
|
|
57
|
+
raw_text = msg.text
|
|
58
|
+
caption = msg.caption
|
|
59
|
+
text = raw_text if raw_text is not None else caption
|
|
60
|
+
if text is None:
|
|
61
|
+
text = ""
|
|
62
|
+
file_command = False
|
|
63
|
+
stripped = text.lstrip()
|
|
64
|
+
if stripped.startswith("/"):
|
|
65
|
+
token = stripped.split(maxsplit=1)[0]
|
|
66
|
+
file_command = token.startswith("/file")
|
|
67
|
+
voice_payload: TelegramVoice | None = None
|
|
68
|
+
if msg.voice is not None:
|
|
69
|
+
voice_payload = TelegramVoice(
|
|
70
|
+
file_id=msg.voice.file_id,
|
|
71
|
+
mime_type=msg.voice.mime_type,
|
|
72
|
+
file_size=msg.voice.file_size,
|
|
73
|
+
duration=msg.voice.duration,
|
|
74
|
+
raw=msgspec.to_builtins(msg.voice),
|
|
75
|
+
)
|
|
76
|
+
if raw_text is None and caption is None:
|
|
77
|
+
text = ""
|
|
78
|
+
document_payload: TelegramDocument | None = None
|
|
79
|
+
if msg.document is not None:
|
|
80
|
+
document_payload = _document_from_media(msg.document)
|
|
81
|
+
if document_payload is None and msg.video is not None:
|
|
82
|
+
document_payload = _document_from_media(msg.video)
|
|
83
|
+
if document_payload is None:
|
|
84
|
+
best = _best_photo(msg.photo)
|
|
85
|
+
if best is not None:
|
|
86
|
+
document_payload = _document_from_photo(best)
|
|
87
|
+
if document_payload is None and file_command and msg.sticker is not None:
|
|
88
|
+
document_payload = _document_from_sticker(msg.sticker)
|
|
89
|
+
has_text = raw_text is not None or caption is not None
|
|
90
|
+
if not has_text and voice_payload is None and document_payload is None:
|
|
91
|
+
return None
|
|
92
|
+
msg_chat_id = msg.chat.id
|
|
93
|
+
chat_type = msg.chat.type
|
|
94
|
+
is_forum = msg.chat.is_forum
|
|
95
|
+
allowed = chat_ids
|
|
96
|
+
if allowed is None and chat_id is not None:
|
|
97
|
+
allowed = {chat_id}
|
|
98
|
+
if allowed is not None and msg_chat_id not in allowed:
|
|
99
|
+
return None
|
|
100
|
+
reply = msg.reply_to_message
|
|
101
|
+
reply_to_message_id = reply.message_id if reply is not None else None
|
|
102
|
+
reply_to_text = reply.text if reply is not None else None
|
|
103
|
+
reply_to_is_bot = (
|
|
104
|
+
reply.from_.is_bot if reply is not None and reply.from_ is not None else None
|
|
105
|
+
)
|
|
106
|
+
reply_to_username = (
|
|
107
|
+
reply.from_.username if reply is not None and reply.from_ is not None else None
|
|
108
|
+
)
|
|
109
|
+
sender_id = msg.from_.id if msg.from_ is not None else None
|
|
110
|
+
media_group_id = msg.media_group_id
|
|
111
|
+
thread_id = msg.message_thread_id
|
|
112
|
+
is_topic_message = msg.is_topic_message
|
|
113
|
+
if thread_id is not None and reply_to_message_id == thread_id:
|
|
114
|
+
reply_to_message_id = None
|
|
115
|
+
reply_to_text = None
|
|
116
|
+
reply_to_is_bot = None
|
|
117
|
+
reply_to_username = None
|
|
118
|
+
return TelegramIncomingMessage(
|
|
119
|
+
transport="telegram",
|
|
120
|
+
chat_id=msg_chat_id,
|
|
121
|
+
message_id=msg.message_id,
|
|
122
|
+
text=text,
|
|
123
|
+
reply_to_message_id=reply_to_message_id,
|
|
124
|
+
reply_to_text=reply_to_text,
|
|
125
|
+
reply_to_is_bot=reply_to_is_bot,
|
|
126
|
+
reply_to_username=reply_to_username,
|
|
127
|
+
sender_id=sender_id,
|
|
128
|
+
media_group_id=media_group_id,
|
|
129
|
+
thread_id=thread_id,
|
|
130
|
+
is_topic_message=is_topic_message,
|
|
131
|
+
chat_type=chat_type,
|
|
132
|
+
is_forum=is_forum,
|
|
133
|
+
voice=voice_payload,
|
|
134
|
+
document=document_payload,
|
|
135
|
+
raw=msgspec.to_builtins(msg),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_callback_query(
|
|
140
|
+
query: CallbackQuery,
|
|
141
|
+
*,
|
|
142
|
+
chat_id: int | None = None,
|
|
143
|
+
chat_ids: set[int] | None = None,
|
|
144
|
+
) -> TelegramCallbackQuery | None:
|
|
145
|
+
callback_id = query.id
|
|
146
|
+
msg = query.message
|
|
147
|
+
if msg is None:
|
|
148
|
+
return None
|
|
149
|
+
msg_chat_id = msg.chat.id
|
|
150
|
+
allowed = chat_ids
|
|
151
|
+
if allowed is None and chat_id is not None:
|
|
152
|
+
allowed = {chat_id}
|
|
153
|
+
if allowed is not None and msg_chat_id not in allowed:
|
|
154
|
+
return None
|
|
155
|
+
data = query.data
|
|
156
|
+
sender_id = query.from_.id if query.from_ is not None else None
|
|
157
|
+
return TelegramCallbackQuery(
|
|
158
|
+
transport="telegram",
|
|
159
|
+
chat_id=msg_chat_id,
|
|
160
|
+
message_id=msg.message_id,
|
|
161
|
+
callback_query_id=callback_id,
|
|
162
|
+
data=data,
|
|
163
|
+
sender_id=sender_id,
|
|
164
|
+
raw=msgspec.to_builtins(query),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _best_photo(photos: list[PhotoSize] | None) -> PhotoSize | None:
|
|
169
|
+
if not photos:
|
|
170
|
+
return None
|
|
171
|
+
best = None
|
|
172
|
+
best_score = -1
|
|
173
|
+
for item in photos:
|
|
174
|
+
size = item.file_size
|
|
175
|
+
score = size if size is not None else item.width * item.height
|
|
176
|
+
if score > best_score:
|
|
177
|
+
best_score = score
|
|
178
|
+
best = item
|
|
179
|
+
return best
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _document_from_media(media: Document | Video) -> TelegramDocument:
|
|
183
|
+
return TelegramDocument(
|
|
184
|
+
file_id=media.file_id,
|
|
185
|
+
file_name=media.file_name,
|
|
186
|
+
mime_type=media.mime_type,
|
|
187
|
+
file_size=media.file_size,
|
|
188
|
+
raw=msgspec.to_builtins(media),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _document_from_photo(photo: PhotoSize) -> TelegramDocument:
|
|
193
|
+
return TelegramDocument(
|
|
194
|
+
file_id=photo.file_id,
|
|
195
|
+
file_name=None,
|
|
196
|
+
mime_type=None,
|
|
197
|
+
file_size=photo.file_size,
|
|
198
|
+
raw=msgspec.to_builtins(photo),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _document_from_sticker(sticker: Sticker) -> TelegramDocument:
|
|
203
|
+
return TelegramDocument(
|
|
204
|
+
file_id=sticker.file_id,
|
|
205
|
+
file_name=None,
|
|
206
|
+
mime_type=None,
|
|
207
|
+
file_size=sticker.file_size,
|
|
208
|
+
raw=msgspec.to_builtins(sticker),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def poll_incoming(
|
|
213
|
+
bot: BotClient,
|
|
214
|
+
*,
|
|
215
|
+
chat_id: int | None = None,
|
|
216
|
+
chat_ids: Iterable[int] | Callable[[], Iterable[int]] | None = None,
|
|
217
|
+
offset: int | None = None,
|
|
218
|
+
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
|
|
219
|
+
) -> AsyncIterator[TelegramIncomingUpdate]:
|
|
220
|
+
while True:
|
|
221
|
+
updates = await bot.get_updates(
|
|
222
|
+
offset=offset,
|
|
223
|
+
timeout_s=50,
|
|
224
|
+
allowed_updates=["message", "callback_query"],
|
|
225
|
+
)
|
|
226
|
+
if updates is None:
|
|
227
|
+
logger.info("loop.get_updates.failed")
|
|
228
|
+
await sleep(2)
|
|
229
|
+
continue
|
|
230
|
+
logger.debug("loop.updates", updates=updates)
|
|
231
|
+
resolved_chat_ids = chat_ids() if callable(chat_ids) else chat_ids
|
|
232
|
+
allowed = set(resolved_chat_ids) if resolved_chat_ids is not None else None
|
|
233
|
+
if allowed is None and chat_id is not None:
|
|
234
|
+
allowed = {chat_id}
|
|
235
|
+
for upd in updates:
|
|
236
|
+
offset = upd.update_id + 1
|
|
237
|
+
msg = parse_incoming_update(upd, chat_ids=allowed)
|
|
238
|
+
if msg is not None:
|
|
239
|
+
yield msg
|
yee88/telegram/render.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from markdown_it import MarkdownIt
|
|
8
|
+
from sulguk import transform_html
|
|
9
|
+
|
|
10
|
+
from ..markdown import MarkdownParts, assemble_markdown_parts
|
|
11
|
+
|
|
12
|
+
MAX_BODY_CHARS = 3500
|
|
13
|
+
|
|
14
|
+
_MD_RENDERER = MarkdownIt("commonmark", {"html": False})
|
|
15
|
+
_BULLET_RE = re.compile(r"(?m)^(\s*)•")
|
|
16
|
+
_FENCE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>[`~]{3,})(?P<info>.*)$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class _FenceState:
|
|
21
|
+
fence: str
|
|
22
|
+
indent: str
|
|
23
|
+
header: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]:
|
|
27
|
+
html = _MD_RENDERER.render(md or "")
|
|
28
|
+
rendered = transform_html(html)
|
|
29
|
+
|
|
30
|
+
text = _BULLET_RE.sub(r"\1-", rendered.text)
|
|
31
|
+
|
|
32
|
+
entities = [dict(e) for e in rendered.entities]
|
|
33
|
+
return text, entities
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_line_ending(line: str) -> tuple[str, str]:
|
|
37
|
+
if line.endswith("\r\n"):
|
|
38
|
+
return line[:-2], "\r\n"
|
|
39
|
+
if line.endswith("\n"):
|
|
40
|
+
return line[:-1], "\n"
|
|
41
|
+
if line.endswith("\r"):
|
|
42
|
+
return line[:-1], "\r"
|
|
43
|
+
return line, ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _split_long_line(line: str, max_chars: int) -> list[str]:
|
|
47
|
+
if len(line) <= max_chars:
|
|
48
|
+
return [line]
|
|
49
|
+
content, ending = _split_line_ending(line)
|
|
50
|
+
parts: list[str] = []
|
|
51
|
+
for idx in range(0, len(content), max_chars):
|
|
52
|
+
chunk = content[idx : idx + max_chars]
|
|
53
|
+
if idx + max_chars >= len(content):
|
|
54
|
+
chunk += ending
|
|
55
|
+
parts.append(chunk)
|
|
56
|
+
if not parts and ending:
|
|
57
|
+
parts.append(ending)
|
|
58
|
+
return parts
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _split_block(block: str, max_chars: int) -> list[str]:
|
|
62
|
+
if len(block) <= max_chars:
|
|
63
|
+
return [block]
|
|
64
|
+
pieces: list[str] = []
|
|
65
|
+
current = ""
|
|
66
|
+
for line in block.splitlines(keepends=True):
|
|
67
|
+
for part in _split_long_line(line, max_chars):
|
|
68
|
+
if not part:
|
|
69
|
+
continue
|
|
70
|
+
if current and len(current) + len(part) > max_chars:
|
|
71
|
+
pieces.append(current)
|
|
72
|
+
current = ""
|
|
73
|
+
current += part
|
|
74
|
+
if len(current) == max_chars:
|
|
75
|
+
pieces.append(current)
|
|
76
|
+
current = ""
|
|
77
|
+
if current:
|
|
78
|
+
pieces.append(current)
|
|
79
|
+
return pieces
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _update_fence_state(line: str, state: _FenceState | None) -> _FenceState | None:
|
|
83
|
+
match = _FENCE_RE.match(line)
|
|
84
|
+
if match is None:
|
|
85
|
+
return state
|
|
86
|
+
fence = match.group("fence")
|
|
87
|
+
indent = match.group("indent")
|
|
88
|
+
if state is None:
|
|
89
|
+
return _FenceState(fence=fence, indent=indent, header=line)
|
|
90
|
+
if fence[0] == state.fence[0] and len(fence) >= len(state.fence):
|
|
91
|
+
return None
|
|
92
|
+
return state
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _scan_fence_state(text: str, state: _FenceState | None) -> _FenceState | None:
|
|
96
|
+
for line in text.splitlines():
|
|
97
|
+
state = _update_fence_state(line, state)
|
|
98
|
+
return state
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_trailing_newline(text: str) -> str:
|
|
102
|
+
if text.endswith("\n") or text.endswith("\r"):
|
|
103
|
+
return text
|
|
104
|
+
return text + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _close_fence_chunk(text: str, state: _FenceState) -> str:
|
|
108
|
+
return _ensure_trailing_newline(text) + f"{state.indent}{state.fence}\n"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _reopen_fence_prefix(state: _FenceState) -> str:
|
|
112
|
+
return f"{state.header}\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def split_markdown_body(body: str, max_chars: int) -> list[str]:
|
|
116
|
+
if not body or not body.strip():
|
|
117
|
+
return []
|
|
118
|
+
max_chars = max(1, int(max_chars))
|
|
119
|
+
segments = re.split(r"(\n{2,})", body)
|
|
120
|
+
blocks: list[str] = []
|
|
121
|
+
for idx in range(0, len(segments), 2):
|
|
122
|
+
paragraph = segments[idx]
|
|
123
|
+
separator = segments[idx + 1] if idx + 1 < len(segments) else ""
|
|
124
|
+
block = paragraph + separator
|
|
125
|
+
if block:
|
|
126
|
+
blocks.append(block)
|
|
127
|
+
|
|
128
|
+
chunks: list[str] = []
|
|
129
|
+
current = ""
|
|
130
|
+
state: _FenceState | None = None
|
|
131
|
+
for block in blocks:
|
|
132
|
+
for piece in _split_block(block, max_chars):
|
|
133
|
+
if not current:
|
|
134
|
+
current = piece
|
|
135
|
+
state = _scan_fence_state(piece, state)
|
|
136
|
+
continue
|
|
137
|
+
if len(current) + len(piece) <= max_chars:
|
|
138
|
+
current += piece
|
|
139
|
+
state = _scan_fence_state(piece, state)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if state is not None:
|
|
143
|
+
current = _close_fence_chunk(current, state)
|
|
144
|
+
chunks.append(current)
|
|
145
|
+
current = _reopen_fence_prefix(state) if state is not None else ""
|
|
146
|
+
current += piece
|
|
147
|
+
state = _scan_fence_state(piece, state)
|
|
148
|
+
|
|
149
|
+
if current:
|
|
150
|
+
chunks.append(current)
|
|
151
|
+
|
|
152
|
+
return [chunk for chunk in chunks if chunk.strip()]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def trim_body(body: str | None, *, max_chars: int = MAX_BODY_CHARS) -> str | None:
|
|
156
|
+
if not body:
|
|
157
|
+
return None
|
|
158
|
+
if len(body) > max_chars:
|
|
159
|
+
body = body[: max_chars - 1] + "…"
|
|
160
|
+
return body if body.strip() else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def prepare_telegram(parts: MarkdownParts) -> tuple[str, list[dict[str, Any]]]:
|
|
164
|
+
trimmed = MarkdownParts(
|
|
165
|
+
header=parts.header or "",
|
|
166
|
+
body=trim_body(parts.body, max_chars=MAX_BODY_CHARS),
|
|
167
|
+
footer=parts.footer,
|
|
168
|
+
)
|
|
169
|
+
return render_markdown(assemble_markdown_parts(trimmed))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def prepare_telegram_multi(
|
|
173
|
+
parts: MarkdownParts, *, max_body_chars: int = MAX_BODY_CHARS
|
|
174
|
+
) -> list[tuple[str, list[dict[str, Any]]]]:
|
|
175
|
+
body = parts.body
|
|
176
|
+
if body is not None and not body.strip():
|
|
177
|
+
body = None
|
|
178
|
+
body_chunks = split_markdown_body(body, max_body_chars) if body is not None else []
|
|
179
|
+
if not body_chunks:
|
|
180
|
+
body_chunks = [""]
|
|
181
|
+
total = len(body_chunks)
|
|
182
|
+
|
|
183
|
+
payloads: list[tuple[str, list[dict[str, Any]]]] = []
|
|
184
|
+
for idx, chunk in enumerate(body_chunks, start=1):
|
|
185
|
+
header = parts.header or ""
|
|
186
|
+
if idx > 1:
|
|
187
|
+
if header:
|
|
188
|
+
header = f"{header} · continued ({idx}/{total})"
|
|
189
|
+
else:
|
|
190
|
+
header = f"continued ({idx}/{total})"
|
|
191
|
+
payloads.append(
|
|
192
|
+
render_markdown(
|
|
193
|
+
assemble_markdown_parts(
|
|
194
|
+
MarkdownParts(header=header, body=chunk, footer=parts.footer)
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return payloads
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import msgspec
|
|
9
|
+
|
|
10
|
+
from ..utils.json_state import atomic_write_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Logger(Protocol):
|
|
14
|
+
def warning(self, event: str, **fields: Any) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _VersionedState(Protocol):
|
|
18
|
+
version: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JsonStateStore[T: _VersionedState]:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
path: Path,
|
|
25
|
+
*,
|
|
26
|
+
version: int,
|
|
27
|
+
state_type: type[T],
|
|
28
|
+
state_factory: Callable[[], T],
|
|
29
|
+
log_prefix: str,
|
|
30
|
+
logger: _Logger,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._path = path
|
|
33
|
+
self._lock = anyio.Lock()
|
|
34
|
+
self._loaded = False
|
|
35
|
+
self._mtime_ns: int | None = None
|
|
36
|
+
self._state_type = state_type
|
|
37
|
+
self._state_factory = state_factory
|
|
38
|
+
self._version = version
|
|
39
|
+
self._log_prefix = log_prefix
|
|
40
|
+
self._logger = logger
|
|
41
|
+
self._state = state_factory()
|
|
42
|
+
|
|
43
|
+
def _stat_mtime_ns(self) -> int | None:
|
|
44
|
+
try:
|
|
45
|
+
return self._path.stat().st_mtime_ns
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def _reload_locked_if_needed(self) -> None:
|
|
50
|
+
current = self._stat_mtime_ns()
|
|
51
|
+
if self._loaded and current == self._mtime_ns:
|
|
52
|
+
return
|
|
53
|
+
self._load_locked()
|
|
54
|
+
|
|
55
|
+
def _load_locked(self) -> None:
|
|
56
|
+
self._loaded = True
|
|
57
|
+
self._mtime_ns = self._stat_mtime_ns()
|
|
58
|
+
if self._mtime_ns is None:
|
|
59
|
+
self._state = self._state_factory()
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
payload = msgspec.json.decode(
|
|
63
|
+
self._path.read_bytes(), type=self._state_type
|
|
64
|
+
)
|
|
65
|
+
except Exception as exc: # noqa: BLE001
|
|
66
|
+
self._logger.warning(
|
|
67
|
+
f"{self._log_prefix}.load_failed",
|
|
68
|
+
path=str(self._path),
|
|
69
|
+
error=str(exc),
|
|
70
|
+
error_type=exc.__class__.__name__,
|
|
71
|
+
)
|
|
72
|
+
self._state = self._state_factory()
|
|
73
|
+
return
|
|
74
|
+
if payload.version != self._version:
|
|
75
|
+
self._logger.warning(
|
|
76
|
+
f"{self._log_prefix}.version_mismatch",
|
|
77
|
+
path=str(self._path),
|
|
78
|
+
version=payload.version,
|
|
79
|
+
expected=self._version,
|
|
80
|
+
)
|
|
81
|
+
self._state = self._state_factory()
|
|
82
|
+
return
|
|
83
|
+
self._state = payload
|
|
84
|
+
|
|
85
|
+
def _save_locked(self) -> None:
|
|
86
|
+
payload = msgspec.to_builtins(self._state)
|
|
87
|
+
atomic_write_json(self._path, payload)
|
|
88
|
+
self._mtime_ns = self._stat_mtime_ns()
|