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,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
@@ -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()