tlgr-cli 2.0.1__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 (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/todo.py ADDED
@@ -0,0 +1,514 @@
1
+ """The `todo` group: checklists.
2
+
3
+ The whole group turns on one invariant: **a task's id is never reused and
4
+ never renumbered.** `messages.editMessage` rewrites the entire `todoList`, and
5
+ completions are keyed by task id — so removing task 2 by renumbering 3 and 4
6
+ down to 2 and 3 would silently move every tick onto the wrong row. Deleting
7
+ keeps the survivors' ids, and appending always uses `max(existing) + 1`
8
+ because reusing an id is `TODO_ITEM_DUPLICATE`.
9
+
10
+ Telethon is imported inside functions, never at module scope (§2.2).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated, Any
16
+
17
+ from tlgr.core.errors import NotFoundError, PermissionError_, UsageError
18
+ from tlgr.core.timefmt import fmt_dt, to_unix
19
+ from tlgr.models.base import Request
20
+ from tlgr.models.peer import PeerRef
21
+ from tlgr.models.todo import Todo, TodoTask
22
+ from tlgr.ops import _send
23
+ from tlgr.ops._common import already, client, only, random_id
24
+ from tlgr.ops._params import arg, opt
25
+ from tlgr.ops._serialize import peer_id_of
26
+ from tlgr.ops._spec import OpContext, OperationSpec
27
+
28
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
29
+
30
+ _EXAMPLE_TODO: dict[str, Any] = {
31
+ "chat_id": 777123,
32
+ "msg_id": 12345,
33
+ "title": "Release checklist",
34
+ "tasks": [
35
+ {"id": 1, "title": "tag the commit", "done": True, "completed_by": 777},
36
+ {"id": 2, "title": "publish the wheel"},
37
+ ],
38
+ "done_count": 1,
39
+ }
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Reading a checklist off a message
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ def _text_with_entities(value: Any) -> tuple[str, list[Any]]:
48
+ from tlgr.ops._serialize import message_entities
49
+
50
+ if value is None:
51
+ return "", []
52
+
53
+ class _Holder:
54
+ entities = list(getattr(value, "entities", None) or [])
55
+
56
+ return str(getattr(value, "text", value) or ""), message_entities(_Holder())
57
+
58
+
59
+ def todo_model(media: Any, *, chat_id: int = 0, msg_id: int = 0) -> Todo:
60
+ """`MessageMediaToDo` → the one `Todo` shape this group returns."""
61
+ raw = getattr(media, "todo", None)
62
+ completions = {
63
+ int(getattr(item, "id", 0)): item for item in (getattr(media, "completions", None) or [])
64
+ }
65
+ title, entities = _text_with_entities(getattr(raw, "title", None))
66
+ tasks: list[TodoTask] = []
67
+ for item in getattr(raw, "list", None) or []:
68
+ task_id = int(getattr(item, "id", 0))
69
+ text, task_entities = _text_with_entities(getattr(item, "title", None))
70
+ done = completions.get(task_id)
71
+ tasks.append(
72
+ TodoTask(
73
+ id=task_id,
74
+ title=text,
75
+ entities=task_entities,
76
+ done=done is not None,
77
+ completed_by=peer_id_of(getattr(done, "completed_by", None)) if done else None,
78
+ completed_date=fmt_dt(getattr(done, "date", None)) if done else None,
79
+ completed_date_unix=to_unix(getattr(done, "date", None)) if done else None,
80
+ )
81
+ )
82
+ return Todo(
83
+ chat_id=chat_id,
84
+ msg_id=msg_id,
85
+ title=title,
86
+ entities=entities,
87
+ others_can_add=bool(getattr(raw, "others_can_append", False)),
88
+ others_can_complete=bool(getattr(raw, "others_can_complete", False)),
89
+ tasks=tasks,
90
+ done_count=sum(1 for task in tasks if task.done),
91
+ )
92
+
93
+
94
+ async def _fetch(ctx: OpContext, peer: Any, chat_id: int, msg_id: int) -> tuple[Any, Todo]:
95
+ message = await client(ctx).get_messages(peer, ids=msg_id)
96
+ media = getattr(message, "media", None) if message is not None else None
97
+ if getattr(media, "todo", None) is None:
98
+ raise NotFoundError(f"message {msg_id} in {chat_id} is not a checklist")
99
+ return message, todo_model(media, chat_id=chat_id, msg_id=msg_id)
100
+
101
+
102
+ async def _reread(ctx: OpContext, peer: Any, chat_id: int, msg_id: int, updates: Any) -> Todo:
103
+ for update in getattr(updates, "updates", None) or []:
104
+ media = getattr(getattr(update, "message", None), "media", None)
105
+ if getattr(media, "todo", None) is not None:
106
+ return todo_model(media, chat_id=chat_id, msg_id=msg_id)
107
+ return (await _fetch(ctx, peer, chat_id, msg_id))[1]
108
+
109
+
110
+ def _tl_items(entries: list[tuple[int, str]], parse: str | None) -> list[Any]:
111
+ from telethon.tl import types
112
+
113
+ out: list[Any] = []
114
+ for task_id, text in entries:
115
+ plain, entities = _send.body(text, parse=parse)
116
+ out.append(
117
+ types.TodoItem(
118
+ id=task_id,
119
+ title=types.TextWithEntities(
120
+ text=plain, entities=_send.tl_entities(entities) or []
121
+ ),
122
+ )
123
+ )
124
+ return out
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # todo create
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ class CreateReq(_send.SendOptions, kw_only=True):
133
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to post it.")]
134
+ title: Annotated[str, arg(1, metavar="TITLE", help="The list's title.")]
135
+ tasks: Annotated[
136
+ list[str], arg(2, metavar="TASK", variadic=True, help="The tasks, in order.")
137
+ ] = []
138
+ others_can_add: Annotated[bool, opt("--others-can-add", help="Anyone may append tasks.")] = (
139
+ False
140
+ )
141
+ others_can_complete: Annotated[
142
+ bool, opt("--others-can-complete", help="Anyone may tick tasks.")
143
+ ] = False
144
+ parse: Annotated[
145
+ str | None, opt("--parse", metavar="MODE", help="md|html|none for title and tasks.")
146
+ ] = None
147
+ reply_to: Annotated[
148
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
149
+ ] = None
150
+
151
+
152
+ async def create(ctx: OpContext, req: CreateReq) -> Todo:
153
+ """Send a checklist.
154
+
155
+ Ids are assigned 1..n here, once, and never touched again: every later
156
+ command in this group addresses a task by the id it was born with.
157
+ """
158
+ from telethon.tl import types
159
+ from telethon.tl.functions import messages as fn
160
+
161
+ if not req.tasks:
162
+ raise UsageError("a checklist needs at least one task", field="tasks")
163
+
164
+ peer = await _send.resolve(ctx, req.chat)
165
+ chat_id = _send.peer_id_of(peer)
166
+ title, entities = _send.body(req.title, parse=req.parse)
167
+ todo = types.TodoList(
168
+ title=types.TextWithEntities(text=title, entities=_send.tl_entities(entities) or []),
169
+ list=_tl_items(list(enumerate(req.tasks, start=1)), req.parse),
170
+ others_can_append=req.others_can_add or None,
171
+ others_can_complete=req.others_can_complete or None,
172
+ )
173
+ values = {
174
+ "peer": peer,
175
+ "media": types.InputMediaTodo(todo=todo),
176
+ "message": "",
177
+ "random_id": random_id(),
178
+ "silent": req.silent or None,
179
+ "noforwards": req.protect or None,
180
+ "reply_to": await _send.reply_target(ctx, reply_to=req.reply_to, topic=req.topic),
181
+ "schedule_date": _send.schedule_at(req.schedule),
182
+ "send_as": await _send.resolve(ctx, req.send_as) if req.send_as is not None else None,
183
+ "effect": _send.effect_id(req.effect),
184
+ "allow_paid_stars": req.paid_stars,
185
+ }
186
+ result = await client(ctx)(fn.SendMediaRequest(**only(values, fn.SendMediaRequest)))
187
+ sent = _send.message_from_updates(result, chat_id=chat_id)
188
+ checklist = await _reread(ctx, peer, chat_id, sent.id, result)
189
+ checklist.msg_id = sent.id
190
+ ctx.emit("todo_created", {"chat_id": chat_id, "msg_id": sent.id, "title": checklist.title})
191
+ return checklist
192
+
193
+
194
+ SPEC_CREATE = OperationSpec(
195
+ id="todo.create",
196
+ request=CreateReq,
197
+ response=Todo,
198
+ impl=create,
199
+ summary="Send a checklist / to-do list",
200
+ description=(
201
+ "Creating a checklist needs Telegram Premium. Task ids are assigned "
202
+ "1..n and are the handle every other `todo` command uses."
203
+ ),
204
+ aliases=("message.checklist",),
205
+ mutating=True,
206
+ rate_class="send",
207
+ columns=("chat_id", "msg_id", "title", "done_count"),
208
+ example=_EXAMPLE_TODO,
209
+ example_args="todo create @team 'Release checklist' 'tag the commit' 'publish the wheel'",
210
+ covers=("messages-core.checklist-create", "todo.create"),
211
+ )
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # todo get
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ class GetReq(Request):
220
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
221
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Checklist message id.")]
222
+
223
+
224
+ async def get(ctx: OpContext, req: GetReq) -> Todo:
225
+ """Read a checklist: the tasks, and who ticked what and when."""
226
+ peer = await _send.resolve(ctx, req.chat)
227
+ chat_id = _send.peer_id_of(peer)
228
+ return (await _fetch(ctx, peer, chat_id, req.msg_id))[1]
229
+
230
+
231
+ SPEC_GET = OperationSpec(
232
+ id="todo.get",
233
+ request=GetReq,
234
+ response=Todo,
235
+ impl=get,
236
+ summary="Read a checklist: tasks, who ticked what and when",
237
+ columns=("msg_id", "title", "done_count"),
238
+ example=_EXAMPLE_TODO,
239
+ example_args="todo get @team 12345",
240
+ covers=("todo.view-completions",),
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # todo toggle
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ class ToggleReq(Request):
250
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
251
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Checklist message id.")]
252
+ done: Annotated[list[str], opt("--done", metavar="ID", help="Task ids to tick.")] = []
253
+ undone: Annotated[list[str], opt("--undone", metavar="ID", help="Task ids to untick.")] = []
254
+ send_as: Annotated[
255
+ PeerRef | None,
256
+ opt("--send-as", metavar="PEER", kind="peer", help="Tick as a channel or anonymously."),
257
+ ] = None
258
+
259
+
260
+ def _task_ids(values: list[str], known: set[int], field: str) -> list[int]:
261
+ from tlgr.ops._common import ids as expand
262
+
263
+ out = expand(tuple(values))
264
+ unknown = [task for task in out if task not in known]
265
+ if unknown:
266
+ raise UsageError(f"this checklist has no task {unknown[0]}", field=field)
267
+ return out
268
+
269
+
270
+ async def toggle(ctx: OpContext, req: ToggleReq) -> Todo:
271
+ """Tick and untick tasks in one call.
272
+
273
+ `toggleTodoCompleted` takes both vectors at once, so "move task 2 to done
274
+ and task 3 back" is one request and one service message rather than two
275
+ that could interleave with somebody else's edit.
276
+ """
277
+ from telethon.tl.functions import messages as fn
278
+
279
+ peer = await _send.resolve(ctx, req.chat)
280
+ chat_id = _send.peer_id_of(peer)
281
+ _, checklist = await _fetch(ctx, peer, chat_id, req.msg_id)
282
+ known = {task.id for task in checklist.tasks}
283
+ completed = _task_ids(req.done, known, "done")
284
+ incompleted = _task_ids(req.undone, known, "undone")
285
+ if not completed and not incompleted:
286
+ raise UsageError("name at least one task with --done or --undone", field="done")
287
+
288
+ state = {task.id: task.done for task in checklist.tasks}
289
+ if all(state.get(task) for task in completed) and not any(
290
+ state.get(task) for task in incompleted
291
+ ):
292
+ already(ctx)
293
+ checklist.already = True
294
+ return checklist
295
+
296
+ if req.send_as is not None:
297
+ # Ticking honours the chat's default send-as identity, and there is no
298
+ # per-request field for it; the default is what the server reads.
299
+ await client(ctx)(
300
+ fn.SaveDefaultSendAsRequest(peer=peer, send_as=await _send.resolve(ctx, req.send_as))
301
+ )
302
+
303
+ result = await client(ctx)(
304
+ fn.ToggleTodoCompletedRequest(
305
+ peer=peer, msg_id=req.msg_id, completed=completed, incompleted=incompleted
306
+ )
307
+ )
308
+ updated = await _reread(ctx, peer, chat_id, req.msg_id, result)
309
+ ctx.emit(
310
+ "todo_toggled",
311
+ {"chat_id": chat_id, "msg_id": req.msg_id, "done": completed, "undone": incompleted},
312
+ )
313
+ return updated
314
+
315
+
316
+ SPEC_TOGGLE = OperationSpec(
317
+ id="todo.toggle",
318
+ request=ToggleReq,
319
+ response=Todo,
320
+ impl=toggle,
321
+ summary="Tick and untick checklist tasks in one call",
322
+ description=(
323
+ "Needs to be the author unless the list was created with "
324
+ "`--others-can-complete`. A no-op is `already: true`."
325
+ ),
326
+ aliases=("todo.done", "todo.undone"),
327
+ mutating=True,
328
+ idempotent=True,
329
+ rate_class="send",
330
+ columns=("msg_id", "done_count"),
331
+ example=_EXAMPLE_TODO,
332
+ example_args="todo toggle @team 12345 --done 1",
333
+ covers=(
334
+ "messages-core.checklist-toggle-task",
335
+ "todo.send-as",
336
+ "todo.toggle-completed",
337
+ ),
338
+ )
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # todo add
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ class AddReq(Request):
347
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
348
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Checklist message id.")]
349
+ tasks: Annotated[list[str], arg(2, metavar="TASK", variadic=True, help="Tasks to append.")] = []
350
+ parse: Annotated[str | None, opt("--parse", metavar="MODE", help="md|html|none.")] = None
351
+
352
+
353
+ async def add(ctx: OpContext, req: AddReq) -> Todo:
354
+ """Append tasks to an existing checklist.
355
+
356
+ New ids continue from `max(existing) + 1`. Reusing an id the list has
357
+ already seen is `TODO_ITEM_DUPLICATE`, and reusing one it has *forgotten*
358
+ would re-attach an old completion to a new task.
359
+ """
360
+ from telethon.tl.functions import messages as fn
361
+
362
+ if not req.tasks:
363
+ raise UsageError("name at least one task to append", field="tasks")
364
+
365
+ peer = await _send.resolve(ctx, req.chat)
366
+ chat_id = _send.peer_id_of(peer)
367
+ _, checklist = await _fetch(ctx, peer, chat_id, req.msg_id)
368
+ if not checklist.others_can_add:
369
+ # Non-authors are refused by the server; saying so here costs nothing
370
+ # and turns an RPC error into an answer.
371
+ ctx.warn("this checklist is not open for appending; only its author may add tasks")
372
+
373
+ start = max((task.id for task in checklist.tasks), default=0) + 1
374
+ entries = list(enumerate(req.tasks, start=start))
375
+ result = await client(ctx)(
376
+ fn.AppendTodoListRequest(peer=peer, msg_id=req.msg_id, list=_tl_items(entries, req.parse))
377
+ )
378
+ return await _reread(ctx, peer, chat_id, req.msg_id, result)
379
+
380
+
381
+ SPEC_ADD = OperationSpec(
382
+ id="todo.add",
383
+ request=AddReq,
384
+ response=Todo,
385
+ impl=add,
386
+ summary="Append tasks to an existing checklist",
387
+ description=(
388
+ "Ids continue from the highest one the list already has; they are "
389
+ "never reused, because a completion is keyed by task id."
390
+ ),
391
+ mutating=True,
392
+ rate_class="send",
393
+ columns=("msg_id", "done_count"),
394
+ example=_EXAMPLE_TODO,
395
+ example_args="todo add @team 12345 'sign the release'",
396
+ covers=("messages-core.checklist-append-tasks",),
397
+ )
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # todo edit
402
+ # ---------------------------------------------------------------------------
403
+
404
+
405
+ class EditReq(Request):
406
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
407
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Checklist message id.")]
408
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None
409
+ add_task: Annotated[
410
+ list[str], opt("--add-task", metavar="TEXT", help="Add a task; repeatable.")
411
+ ] = []
412
+ remove_task: Annotated[
413
+ list[str], opt("--remove-task", metavar="ID", help="Remove a task id; repeatable.")
414
+ ] = []
415
+ rename_task: Annotated[
416
+ list[str], opt("--rename-task", metavar="ID=TEXT", help="Retitle a task; repeatable.")
417
+ ] = []
418
+ others_can_add: Annotated[
419
+ str | None, opt("--others-can-add", metavar="on|off", help="Who may append.")
420
+ ] = None
421
+ others_can_complete: Annotated[
422
+ str | None, opt("--others-can-complete", metavar="on|off", help="Who may tick.")
423
+ ] = None
424
+ parse: Annotated[str | None, opt("--parse", metavar="MODE", help="md|html|none.")] = None
425
+
426
+
427
+ def _switch(value: str | None, current: bool, field: str) -> bool:
428
+ if value is None:
429
+ return current
430
+ if value not in ("on", "off"):
431
+ raise UsageError(f"--{field} is on or off", field=field)
432
+ return value == "on"
433
+
434
+
435
+ async def edit(ctx: OpContext, req: EditReq) -> Todo:
436
+ """Rename a list, add or remove tasks, change who may add or tick.
437
+
438
+ `editMessage` replaces the whole `todoList`, so this is a read-modify-write
439
+ that *keeps every surviving task's id*. Renumbering would be the natural
440
+ thing to do and would move every completion onto the wrong row.
441
+ """
442
+ from telethon.tl import types
443
+ from telethon.tl.functions import messages as fn
444
+
445
+ peer = await _send.resolve(ctx, req.chat)
446
+ chat_id = _send.peer_id_of(peer)
447
+ message, checklist = await _fetch(ctx, peer, chat_id, req.msg_id)
448
+
449
+ known = {task.id for task in checklist.tasks}
450
+ doomed = set(_task_ids(req.remove_task, known, "remove-task"))
451
+ renames: dict[int, str] = {}
452
+ for pair in req.rename_task:
453
+ raw, sep, text = pair.partition("=")
454
+ if not sep:
455
+ raise UsageError("--rename-task wants ID=TEXT", field="rename-task")
456
+ try:
457
+ task_id = int(raw)
458
+ except ValueError as exc:
459
+ raise UsageError(f"{raw!r} is not a task id", field="rename-task") from exc
460
+ if task_id not in known:
461
+ raise UsageError(f"this checklist has no task {task_id}", field="rename-task")
462
+ renames[task_id] = text
463
+
464
+ entries = [
465
+ (task.id, renames.get(task.id, task.title))
466
+ for task in checklist.tasks
467
+ if task.id not in doomed
468
+ ]
469
+ start = max(known, default=0) + 1
470
+ entries += list(enumerate(req.add_task, start=start))
471
+ if not entries:
472
+ raise UsageError(
473
+ "a checklist cannot be emptied; delete the message instead", field="remove-task"
474
+ )
475
+
476
+ title, entities = _send.body(
477
+ req.title if req.title is not None else checklist.title, parse=req.parse
478
+ )
479
+ todo = types.TodoList(
480
+ title=types.TextWithEntities(text=title, entities=_send.tl_entities(entities) or []),
481
+ list=_tl_items(entries, req.parse),
482
+ others_can_append=_switch(req.others_can_add, checklist.others_can_add, "others-can-add")
483
+ or None,
484
+ others_can_complete=_switch(
485
+ req.others_can_complete, checklist.others_can_complete, "others-can-complete"
486
+ )
487
+ or None,
488
+ )
489
+ if not getattr(message, "out", False):
490
+ raise PermissionError_("only the author of a checklist may edit it")
491
+
492
+ result = await client(ctx)(
493
+ fn.EditMessageRequest(peer=peer, id=req.msg_id, media=types.InputMediaTodo(todo=todo))
494
+ )
495
+ return await _reread(ctx, peer, chat_id, req.msg_id, result)
496
+
497
+
498
+ SPEC_EDIT = OperationSpec(
499
+ id="todo.edit",
500
+ request=EditReq,
501
+ response=Todo,
502
+ impl=edit,
503
+ summary="Rename a checklist, remove or add tasks, change who may add or tick",
504
+ description=(
505
+ "Surviving tasks keep their ids: completions are keyed by id, and "
506
+ "renumbering would move every tick onto a different task."
507
+ ),
508
+ mutating=True,
509
+ rate_class="send",
510
+ columns=("msg_id", "title", "done_count"),
511
+ example=_EXAMPLE_TODO,
512
+ example_args="todo edit @team 12345 --title 'Release 2.0'",
513
+ covers=("messages-core.checklist-edit", "todo.edit-list", "todo.permissions"),
514
+ )