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/_serialize.py ADDED
@@ -0,0 +1,667 @@
1
+ """Telethon object → model. One function per shape, and only one.
2
+
3
+ v1 assembled a message dict in three places with slightly different keys, so
4
+ `message list`, `message get` and the dialog preview disagreed about what a
5
+ message looks like. Everything here is duck-typed on the attributes Telethon
6
+ objects carry, which keeps the functions importable (and testable) without
7
+ Telethon and without a client.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ from typing import Any
14
+
15
+ from tlgr.core.timefmt import fmt_dt, to_unix
16
+ from tlgr.models.dialog import ActionBar, ChatTheme, ChatWallpaper, NotifySettings
17
+ from tlgr.models.message import (
18
+ Button,
19
+ MediaSummary,
20
+ Message,
21
+ MessageEntity,
22
+ ReactionSummary,
23
+ ReplyHeader,
24
+ ReplyMarkup,
25
+ ServiceAction,
26
+ )
27
+ from tlgr.models.peer import Peer, Photo
28
+
29
+ __all__ = [
30
+ "action_bar",
31
+ "chat_theme",
32
+ "entity_to_peer",
33
+ "marked_id",
34
+ "media_summary",
35
+ "message_entities",
36
+ "message_to_model",
37
+ "notify_settings",
38
+ "peer_id_of",
39
+ "photo_summary",
40
+ "reactions_summary",
41
+ "reply_markup",
42
+ "service_action",
43
+ "tl_snake",
44
+ "wallpaper",
45
+ ]
46
+
47
+ _CHANNEL_MARK = -1000000000000
48
+
49
+ #: TL media class → the `MediaKind` it maps to. The document branch decides
50
+ #: for itself (see `media_summary`); this table is only for the media types
51
+ #: that are what their class name says they are.
52
+ _MEDIA_KINDS = {
53
+ "MessageMediaPhoto": "photo",
54
+ "MessageMediaContact": "contact",
55
+ "MessageMediaGeo": "geo",
56
+ "MessageMediaGeoLive": "geo_live",
57
+ "MessageMediaVenue": "venue",
58
+ "MessageMediaPoll": "poll",
59
+ "MessageMediaDice": "dice",
60
+ "MessageMediaGame": "game",
61
+ "MessageMediaInvoice": "invoice",
62
+ "MessageMediaWebPage": "webpage",
63
+ "MessageMediaStory": "story",
64
+ "MessageMediaGiveaway": "giveaway",
65
+ "MessageMediaGiveawayResults": "giveaway",
66
+ "MessageMediaPaidMedia": "paid",
67
+ "MessageMediaToDo": "todo",
68
+ "MessageMediaUnsupported": "unsupported",
69
+ "MessageMediaEmpty": "unsupported",
70
+ }
71
+
72
+
73
+ def tl_snake(name: str, prefix: str = "") -> str:
74
+ """`MessageActionChatAddUser` → `chat_add_user`."""
75
+ name = name.removeprefix(prefix)
76
+ out: list[str] = []
77
+ for index, char in enumerate(name):
78
+ if char.isupper() and index:
79
+ out.append("_")
80
+ out.append(char.lower())
81
+ return "".join(out)
82
+
83
+
84
+ def marked_id(raw_id: int, kind: str) -> int:
85
+ """The marked id from `utils.get_peer_id`, without importing Telethon.
86
+
87
+ Every peer id on the wire is marked (COR-10): `chat get` returning `123`
88
+ and `chat list` returning `-100…123` for the same channel is the bug this
89
+ closes.
90
+ """
91
+ if raw_id < 0:
92
+ return raw_id
93
+ if kind in ("channel", "supergroup"):
94
+ return _CHANNEL_MARK - raw_id
95
+ if kind == "group":
96
+ return -raw_id
97
+ return raw_id
98
+
99
+
100
+ def peer_id_of(peer: Any) -> int | None:
101
+ """A `PeerUser`/`PeerChat`/`PeerChannel` as its marked id, or None.
102
+
103
+ The same arithmetic `utils.get_peer_id` does, kept here so a serialiser
104
+ can read a raw `from_id` without a client and without importing Telethon
105
+ (§2.2 keeps that import out of everything below `ops/`).
106
+ """
107
+ if peer is None:
108
+ return None
109
+ for attribute, kind in (
110
+ ("user_id", "user"),
111
+ ("chat_id", "group"),
112
+ ("channel_id", "channel"),
113
+ ):
114
+ value = getattr(peer, attribute, None)
115
+ if isinstance(value, int):
116
+ return marked_id(value, kind)
117
+ return None
118
+
119
+
120
+ def _photo(obj: Any) -> Photo | None:
121
+ if obj is None:
122
+ return None
123
+ identifier = getattr(obj, "photo_id", None) or getattr(obj, "id", None)
124
+ if identifier is None:
125
+ return None
126
+ stripped = getattr(obj, "stripped_thumb", None)
127
+ return Photo(
128
+ id=int(identifier),
129
+ has_video=bool(getattr(obj, "has_video", False)),
130
+ stripped_thumb_b64=base64.b64encode(stripped).decode() if stripped else None,
131
+ dc_id=getattr(obj, "dc_id", None),
132
+ )
133
+
134
+
135
+ #: The photo shape, exported so an op can build one without reaching for a
136
+ #: private name.
137
+ photo_summary = _photo
138
+
139
+
140
+ def entity_to_peer(entity: Any) -> Peer:
141
+ """A Telethon User/Chat/Channel as the `Peer` every response embeds."""
142
+ name = type(entity).__name__
143
+ raw_id = int(getattr(entity, "id", 0) or 0)
144
+ usernames = [
145
+ u.username
146
+ for u in (getattr(entity, "usernames", None) or [])
147
+ if getattr(u, "username", None)
148
+ ]
149
+
150
+ if name in ("User", "UserEmpty"):
151
+ if getattr(entity, "is_self", False):
152
+ kind, title = "saved", "Saved Messages"
153
+ else:
154
+ kind = "bot" if getattr(entity, "bot", False) else "user"
155
+ first = getattr(entity, "first_name", "") or ""
156
+ last = getattr(entity, "last_name", "") or ""
157
+ title = f"{first} {last}".strip()
158
+ elif name in ("Chat", "ChatForbidden", "ChatEmpty"):
159
+ kind, title = "group", getattr(entity, "title", "") or ""
160
+ elif name in ("Channel", "ChannelForbidden"):
161
+ kind = "supergroup" if getattr(entity, "megagroup", False) else "channel"
162
+ title = getattr(entity, "title", "") or ""
163
+ else:
164
+ kind, title = "unknown", str(getattr(entity, "title", "") or "")
165
+
166
+ return Peer(
167
+ id=marked_id(raw_id, kind),
168
+ raw_id=raw_id,
169
+ kind=kind, # type: ignore[arg-type]
170
+ title=title,
171
+ username=getattr(entity, "username", None),
172
+ usernames=usernames,
173
+ is_self=bool(getattr(entity, "is_self", False)),
174
+ )
175
+
176
+
177
+ def media_summary(media: Any) -> MediaSummary | None:
178
+ """What the media IS, from the attributes it already carries.
179
+
180
+ Ported from v1's `media_details()` with its logic intact: attributes are
181
+ *collected* and the kind decided afterwards, because a GIF carries Video
182
+ AND Animated and a video sticker carries Video AND Sticker — "first
183
+ attribute wins" gets both of them wrong.
184
+ """
185
+ if media is None:
186
+ return None
187
+ tl_type = type(media).__name__
188
+
189
+ if getattr(media, "photo", None) is not None and not hasattr(media, "document"):
190
+ photo = media.photo
191
+ return MediaSummary(
192
+ kind="photo",
193
+ tl_type=tl_type,
194
+ spoiler=bool(getattr(media, "spoiler", False)),
195
+ ttl_seconds=getattr(media, "ttl_seconds", None),
196
+ dc_id=getattr(photo, "dc_id", None),
197
+ thumbs=[
198
+ str(getattr(size, "type", ""))
199
+ for size in (getattr(photo, "sizes", None) or [])
200
+ if getattr(size, "type", None)
201
+ ],
202
+ )
203
+
204
+ document = getattr(media, "document", None)
205
+ if document is None:
206
+ summary = MediaSummary(
207
+ kind=_MEDIA_KINDS.get(tl_type, "unsupported"), # type: ignore[arg-type]
208
+ tl_type=tl_type,
209
+ downloadable=False,
210
+ spoiler=bool(getattr(media, "spoiler", False)),
211
+ )
212
+ _fill_non_file(summary, media)
213
+ return summary
214
+
215
+ mime = getattr(document, "mime_type", None)
216
+ summary = MediaSummary(
217
+ kind="file",
218
+ tl_type=tl_type,
219
+ mime_type=mime,
220
+ size=getattr(document, "size", None),
221
+ dc_id=getattr(document, "dc_id", None),
222
+ spoiler=bool(getattr(media, "spoiler", False)),
223
+ ttl_seconds=getattr(media, "ttl_seconds", None),
224
+ thumbs=[
225
+ str(getattr(size, "type", ""))
226
+ for size in (getattr(document, "thumbs", None) or [])
227
+ if getattr(size, "type", None)
228
+ ],
229
+ )
230
+
231
+ sticker = voice = audio = video = video_note = animated = False
232
+ for attribute in getattr(document, "attributes", None) or []:
233
+ name = type(attribute).__name__
234
+ if name == "DocumentAttributeSticker":
235
+ sticker = True
236
+ summary.alt = getattr(attribute, "alt", None) or None
237
+ stickerset = getattr(attribute, "stickerset", None)
238
+ short_name = getattr(stickerset, "short_name", None)
239
+ if short_name:
240
+ summary.sticker_set = short_name
241
+ elif name == "DocumentAttributeAudio":
242
+ voice = bool(getattr(attribute, "voice", False))
243
+ audio = not voice
244
+ summary.duration = getattr(attribute, "duration", None)
245
+ summary.performer = getattr(attribute, "performer", None)
246
+ summary.title = getattr(attribute, "title", None)
247
+ summary.waveform = getattr(attribute, "waveform", None) is not None
248
+ elif name == "DocumentAttributeVideo":
249
+ video_note = bool(getattr(attribute, "round_message", False))
250
+ video = not video_note
251
+ summary.duration = getattr(attribute, "duration", None)
252
+ summary.width = getattr(attribute, "w", None)
253
+ summary.height = getattr(attribute, "h", None)
254
+ summary.round = video_note
255
+ summary.supports_streaming = bool(getattr(attribute, "supports_streaming", False))
256
+ elif name == "DocumentAttributeAnimated":
257
+ animated = True
258
+ elif name == "DocumentAttributeImageSize":
259
+ summary.width = getattr(attribute, "w", None)
260
+ summary.height = getattr(attribute, "h", None)
261
+ elif name == "DocumentAttributeFilename":
262
+ summary.file_name = getattr(attribute, "file_name", None)
263
+
264
+ if sticker:
265
+ summary.kind = "sticker"
266
+ elif voice:
267
+ summary.kind = "voice"
268
+ elif video_note:
269
+ summary.kind = "video_note"
270
+ elif animated or mime == "image/gif":
271
+ summary.kind = "gif"
272
+ summary.is_animated = True
273
+ elif video:
274
+ summary.kind = "video"
275
+ elif audio:
276
+ summary.kind = "audio"
277
+ return summary
278
+
279
+
280
+ def _fill_non_file(summary: MediaSummary, media: Any) -> None:
281
+ """Flatten the non-file media variants onto the summary."""
282
+ geo = getattr(media, "geo", None)
283
+ if geo is not None:
284
+ summary.latitude = getattr(geo, "lat", None)
285
+ summary.longitude = getattr(geo, "long", None)
286
+ summary.venue_title = getattr(media, "title", None)
287
+ summary.contact_phone = getattr(media, "phone_number", None)
288
+ first = getattr(media, "first_name", None)
289
+ if first is not None:
290
+ last = getattr(media, "last_name", "") or ""
291
+ summary.contact_name = f"{first} {last}".strip()
292
+ summary.dice_emoji = getattr(media, "emoticon", None)
293
+ summary.dice_value = getattr(media, "value", None)
294
+ webpage = getattr(media, "webpage", None)
295
+ if webpage is not None:
296
+ summary.webpage_url = getattr(webpage, "url", None)
297
+ summary.webpage_title = getattr(webpage, "title", None)
298
+ story_id = getattr(media, "id", None)
299
+ if summary.kind == "story" and story_id is not None:
300
+ summary.story_id = int(story_id)
301
+ stars = getattr(media, "stars_amount", None)
302
+ if stars is not None:
303
+ summary.paid_stars = int(stars)
304
+
305
+
306
+ def reactions_summary(message: Any) -> ReactionSummary | None:
307
+ """Compact reaction state, including whether WE already reacted.
308
+
309
+ Returns None when the message carries no reactions, so the field only
310
+ appears where it means something. `mine` comes from
311
+ `ReactionCount.chosen_order`, which Telegram sets only for this account —
312
+ without it the only way to learn that a reaction is already there is to
313
+ send one and read MESSAGE_NOT_MODIFIED as a failure.
314
+ """
315
+ raw = getattr(message, "reactions", None)
316
+ if not raw:
317
+ return None
318
+ counts: dict[str, int] = {}
319
+ mine: list[str] = []
320
+ for result in getattr(raw, "results", None) or []:
321
+ reaction = getattr(result, "reaction", None)
322
+ emoji = getattr(reaction, "emoticon", None)
323
+ if emoji is None:
324
+ # Custom (premium) reactions carry only a document id; naming them
325
+ # is better than dropping them silently.
326
+ document = getattr(reaction, "document_id", None)
327
+ emoji = f"custom:{document}" if document is not None else "?"
328
+ counts[emoji] = counts.get(emoji, 0) + int(getattr(result, "count", 0) or 0)
329
+ if getattr(result, "chosen_order", None) is not None:
330
+ mine.append(emoji)
331
+ if not counts:
332
+ return None
333
+ return ReactionSummary(
334
+ counts=counts,
335
+ mine=mine,
336
+ total=sum(counts.values()),
337
+ can_see_list=getattr(raw, "can_see_list", None),
338
+ as_tags=bool(getattr(raw, "reactions_as_tags", False)),
339
+ )
340
+
341
+
342
+ def message_entities(message: Any) -> list[MessageEntity]:
343
+ """Formatting runs, with their UTF-16 offsets untouched."""
344
+ out: list[MessageEntity] = []
345
+ for entity in getattr(message, "entities", None) or []:
346
+ user_id = getattr(entity, "user_id", None)
347
+ out.append(
348
+ MessageEntity(
349
+ type=tl_snake(type(entity).__name__, "MessageEntity"),
350
+ offset=int(getattr(entity, "offset", 0)),
351
+ length=int(getattr(entity, "length", 0)),
352
+ url=getattr(entity, "url", None),
353
+ user_id=getattr(user_id, "user_id", user_id),
354
+ language=getattr(entity, "language", None),
355
+ document_id=getattr(entity, "document_id", None),
356
+ collapsed=getattr(entity, "collapsed", None),
357
+ )
358
+ )
359
+ return out
360
+
361
+
362
+ def service_action(action: Any) -> ServiceAction | None:
363
+ """A service message is an event, not "a message with empty text"."""
364
+ if action is None:
365
+ return None
366
+ tl_type = type(action).__name__
367
+ users = getattr(action, "users", None) or []
368
+ single = getattr(action, "user_id", None)
369
+ if single is not None:
370
+ users = [*users, single]
371
+ return ServiceAction(
372
+ type=tl_snake(tl_type, "MessageAction"),
373
+ tl_type=tl_type,
374
+ user_ids=[int(u) for u in users],
375
+ title=getattr(action, "title", None),
376
+ photo=_photo(getattr(action, "photo", None)),
377
+ duration=getattr(action, "duration", None),
378
+ call_id=getattr(action, "call_id", None),
379
+ ttl_seconds=getattr(action, "period", None) or getattr(action, "ttl_seconds", None),
380
+ boosts=getattr(action, "boosts", None),
381
+ stars=getattr(action, "stars", None),
382
+ )
383
+
384
+
385
+ def _reply_header(message: Any) -> ReplyHeader | None:
386
+ reply = getattr(message, "reply_to", None)
387
+ if reply is None:
388
+ message_id = getattr(message, "reply_to_msg_id", None)
389
+ return ReplyHeader(message_id=message_id) if message_id else None
390
+ peer = getattr(reply, "reply_to_peer_id", None)
391
+ return ReplyHeader(
392
+ message_id=getattr(reply, "reply_to_msg_id", None),
393
+ peer_id=getattr(peer, "channel_id", None) or getattr(peer, "user_id", None),
394
+ top_message_id=getattr(reply, "reply_to_top_id", None),
395
+ forum_topic=bool(getattr(reply, "forum_topic", False)),
396
+ quote_text=getattr(reply, "quote_text", None),
397
+ quote_offset=getattr(reply, "quote_offset", None),
398
+ story_id=getattr(reply, "story_id", None),
399
+ )
400
+
401
+
402
+ def _forward(message: Any) -> Any:
403
+ from tlgr.models.message import Forward
404
+
405
+ forward = getattr(message, "fwd_from", None) or getattr(message, "forward", None)
406
+ if forward is None:
407
+ return None
408
+ peer = getattr(forward, "from_id", None)
409
+ from_id = None
410
+ if peer is not None:
411
+ for attribute, kind in (
412
+ ("user_id", "user"),
413
+ ("channel_id", "channel"),
414
+ ("chat_id", "group"),
415
+ ):
416
+ value = getattr(peer, attribute, None)
417
+ if value is not None:
418
+ from_id = marked_id(int(value), kind)
419
+ break
420
+ return Forward(
421
+ from_id=from_id,
422
+ from_name=getattr(forward, "from_name", None),
423
+ date=fmt_dt(getattr(forward, "date", None)),
424
+ channel_post_id=getattr(forward, "channel_post", None),
425
+ post_author=getattr(forward, "post_author", None),
426
+ saved_from_msg_id=getattr(forward, "saved_from_msg_id", None),
427
+ imported=bool(getattr(forward, "imported", False)),
428
+ )
429
+
430
+
431
+ def _text_of(message: Any) -> str:
432
+ """The message body, whether or not the object is bound to a client.
433
+
434
+ Telethon's `Message.text` applies the client's parse mode and returns
435
+ `None` when there is no client — which is every message tlgr builds from
436
+ a raw `Updates` reply or receives through a fake. Falling back to
437
+ `raw_text`/`message` is the difference between `message send` reporting
438
+ the text it sent and reporting an empty string.
439
+ """
440
+ for attribute in ("text", "raw_text", "message"):
441
+ value = getattr(message, attribute, None)
442
+ if isinstance(value, str) and value:
443
+ return value
444
+ return ""
445
+
446
+
447
+ def _sender_of(message: Any) -> int | None:
448
+ """Who sent it, computed from `from_id` when Telethon has not.
449
+
450
+ `Message.sender_id` is filled in by `_finish_init`, which only runs for a
451
+ client-bound message. Every message tlgr builds from a raw `Updates` reply
452
+ therefore reports no sender unless it is derived here, and "who sent this"
453
+ is not an optional field.
454
+ """
455
+ known = getattr(message, "sender_id", None)
456
+ if isinstance(known, int):
457
+ return known
458
+ peer = getattr(message, "from_id", None) or getattr(message, "peer_id", None)
459
+ return peer_id_of(peer)
460
+
461
+
462
+ def message_to_model(
463
+ message: Any, *, chat_id: int | None = None, link: str | None = None
464
+ ) -> Message:
465
+ """The one function that turns a Telethon message into a `Message`."""
466
+ action = getattr(message, "action", None)
467
+ date = getattr(message, "date", None)
468
+ if chat_id is None:
469
+ chat_id = int(getattr(message, "chat_id", 0) or 0)
470
+ return Message(
471
+ id=int(getattr(message, "id", 0)),
472
+ chat_id=chat_id,
473
+ date=fmt_dt(date) or "",
474
+ date_unix=to_unix(date) or 0,
475
+ text=_text_of(message),
476
+ out=bool(getattr(message, "out", False)),
477
+ kind="service" if action is not None else "message",
478
+ sender_id=_sender_of(message),
479
+ post_author=getattr(message, "post_author", None),
480
+ via_bot_id=getattr(message, "via_bot_id", None),
481
+ from_rank=getattr(message, "from_rank", None) if hasattr(message, "from_rank") else None,
482
+ entities=message_entities(message),
483
+ media=media_summary(getattr(message, "media", None)),
484
+ reactions=reactions_summary(message),
485
+ forward=_forward(message),
486
+ reply_to=_reply_header(message),
487
+ action=service_action(action),
488
+ grouped_id=getattr(message, "grouped_id", None),
489
+ edit_date=fmt_dt(getattr(message, "edit_date", None)),
490
+ pinned=bool(getattr(message, "pinned", False)),
491
+ silent=bool(getattr(message, "silent", False)),
492
+ noforwards=bool(getattr(message, "noforwards", False)),
493
+ mentioned=bool(getattr(message, "mentioned", False)),
494
+ media_unread=bool(getattr(message, "media_unread", False)),
495
+ ttl_period=getattr(message, "ttl_period", None),
496
+ effect_id=getattr(message, "effect", None),
497
+ views=getattr(message, "views", None),
498
+ forwards=getattr(message, "forwards", None),
499
+ edit_hide=bool(getattr(message, "edit_hide", False)),
500
+ reply_markup=reply_markup(getattr(message, "reply_markup", None)),
501
+ link=link,
502
+ )
503
+
504
+
505
+ def reply_markup(markup: Any) -> ReplyMarkup | None:
506
+ """A message's keyboard, in the schema `bot press` addresses buttons in.
507
+
508
+ PR-1 declared the shape and left it unfilled, which made the two P0 ids
509
+ about rendering a keyboard true only on paper: a caller could not see a
510
+ button, so a caller could not press one. The `n` on each button is the
511
+ flat row-major index `bot press --button <n>` takes, printed rather than
512
+ left to be counted.
513
+ """
514
+ from tlgr.ops._bots import BUTTON_TYPES
515
+
516
+ kinds = {
517
+ "ReplyInlineMarkup": "inline",
518
+ "ReplyKeyboardMarkup": "keyboard",
519
+ "ReplyKeyboardHide": "hide",
520
+ "ReplyKeyboardForceReply": "force_reply",
521
+ }
522
+ kind = kinds.get(type(markup).__name__)
523
+ if kind is None:
524
+ return None
525
+
526
+ rows: list[list[Button]] = []
527
+ index = 0
528
+ for row in getattr(markup, "rows", None) or []:
529
+ built: list[Button] = []
530
+ for button in getattr(row, "buttons", None) or []:
531
+ data = getattr(button, "data", None)
532
+ user_id = getattr(button, "user_id", None)
533
+ built.append(
534
+ Button(
535
+ text=str(getattr(button, "text", "") or ""),
536
+ type=BUTTON_TYPES.get(type(button).__name__, "unsupported"),
537
+ n=index,
538
+ data_b64=base64.b64encode(data).decode() if data else None,
539
+ url=getattr(button, "url", None),
540
+ query=getattr(button, "query", None),
541
+ user_id=int(user_id) if isinstance(user_id, int) else None,
542
+ requires_password=bool(getattr(button, "requires_password", False)),
543
+ same_peer=bool(getattr(button, "same_peer", False)),
544
+ copy_text=getattr(button, "copy_text", None),
545
+ button_id=getattr(button, "button_id", None),
546
+ )
547
+ )
548
+ index += 1
549
+ rows.append(built)
550
+ return ReplyMarkup(
551
+ kind=kind, # type: ignore[arg-type]
552
+ rows=rows,
553
+ resize=bool(getattr(markup, "resize", False)) or None,
554
+ single_use=bool(getattr(markup, "single_use", False)) or None,
555
+ selective=bool(getattr(markup, "selective", False)) or None,
556
+ persistent=bool(getattr(markup, "persistent", False)) or None,
557
+ placeholder=getattr(markup, "placeholder", None),
558
+ )
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # The chat-list shapes
563
+ # ---------------------------------------------------------------------------
564
+
565
+
566
+ #: `NotificationSound*` → the string `chat notify set --sound` accepts back.
567
+ def _sound(value: Any) -> str | None:
568
+ name = type(value).__name__
569
+ if value is None:
570
+ return None
571
+ if name == "NotificationSoundDefault":
572
+ return "default"
573
+ if name == "NotificationSoundNone":
574
+ return "none"
575
+ if name == "NotificationSoundLocal":
576
+ return f"local:{getattr(value, 'title', '') or ''}"
577
+ if name == "NotificationSoundRingtone":
578
+ return str(getattr(value, "id", "") or "")
579
+ return None
580
+
581
+
582
+ def notify_settings(raw: Any) -> NotifySettings | None:
583
+ """`peerNotifySettings` as a model, keeping the tri-state intact.
584
+
585
+ An unset field means "inherit the scope default", which is a third state
586
+ next to on and off. It is preserved as `None` rather than folded into
587
+ `False`, because folding it is how a chat that inherits a muted scope
588
+ gets reported as unmuted.
589
+ """
590
+ if raw is None:
591
+ return None
592
+ until = getattr(raw, "mute_until", None)
593
+ unix = to_unix(until)
594
+ return NotifySettings(
595
+ muted=bool(unix and unix > 0),
596
+ mute_until=fmt_dt(until),
597
+ mute_until_unix=unix,
598
+ silent=getattr(raw, "silent", None),
599
+ show_previews=getattr(raw, "show_previews", None),
600
+ # `peerNotifySettings` splits the sound three ways (iOS, Android,
601
+ # other); `other_sound` is the desktop/API one and the only sensible
602
+ # single answer for a CLI.
603
+ sound=_sound(getattr(raw, "other_sound", None) or getattr(raw, "sound", None)),
604
+ stories_muted=getattr(raw, "stories_muted", None),
605
+ stories_hide_sender=getattr(raw, "stories_hide_sender", None),
606
+ stories_sound=_sound(getattr(raw, "stories_sound", None)),
607
+ )
608
+
609
+
610
+ def action_bar(raw: Any, *, chat_id: int = 0) -> ActionBar:
611
+ """`peerSettings` as the action bar a client draws above a chat."""
612
+ return ActionBar(
613
+ chat_id=chat_id,
614
+ report_spam=bool(getattr(raw, "report_spam", False)),
615
+ add_contact=bool(getattr(raw, "add_contact", False)),
616
+ block_contact=bool(getattr(raw, "block_contact", False)),
617
+ share_contact=bool(getattr(raw, "share_contact", False)),
618
+ need_contacts_exception=bool(getattr(raw, "need_contacts_exception", False)),
619
+ report_geo=bool(getattr(raw, "report_geo", False)),
620
+ autoarchived=bool(getattr(raw, "autoarchived", False)),
621
+ invite_members=bool(getattr(raw, "invite_members", False)),
622
+ request_chat_title=getattr(raw, "request_chat_title", None),
623
+ request_chat_date=fmt_dt(getattr(raw, "request_chat_date", None)),
624
+ request_chat_broadcast=bool(getattr(raw, "request_chat_broadcast", False)),
625
+ business_bot_id=getattr(raw, "business_bot_id", None),
626
+ business_bot_paused=bool(getattr(raw, "business_bot_paused", False)),
627
+ business_bot_can_reply=bool(getattr(raw, "business_bot_can_reply", False)),
628
+ charge_paid_message_stars=getattr(raw, "charge_paid_message_stars", None),
629
+ registration_month=getattr(raw, "registration_month", None),
630
+ phone_country=getattr(raw, "phone_country", None),
631
+ name_change_date=fmt_dt(getattr(raw, "name_change_date", None)),
632
+ photo_change_date=fmt_dt(getattr(raw, "photo_change_date", None)),
633
+ geo_distance=getattr(raw, "geo_distance", None),
634
+ )
635
+
636
+
637
+ def chat_theme(raw: Any) -> ChatTheme | None:
638
+ """A `chatTheme` / `theme` / unique-gift theme as one shape."""
639
+ if raw is None:
640
+ return None
641
+ emoticon = getattr(raw, "emoticon", None)
642
+ gift = getattr(raw, "gift", None) or getattr(raw, "unique_gift", None)
643
+ return ChatTheme(
644
+ emoticon=emoticon,
645
+ gift_id=getattr(gift, "id", None) if gift is not None else getattr(raw, "gift_id", None),
646
+ gift_slug=getattr(gift, "slug", None) if gift is not None else getattr(raw, "slug", None),
647
+ title=str(getattr(raw, "title", "") or getattr(gift, "title", "") or emoticon or ""),
648
+ premium_required=bool(getattr(raw, "premium_required", False)),
649
+ )
650
+
651
+
652
+ def wallpaper(raw: Any) -> ChatWallpaper | None:
653
+ """A `wallPaper` (or `wallPaperNoFile`) reduced to what a CLI can echo."""
654
+ if raw is None:
655
+ return None
656
+ settings = getattr(raw, "settings", None)
657
+ return ChatWallpaper(
658
+ id=getattr(raw, "id", None),
659
+ slug=getattr(raw, "slug", None),
660
+ dark=bool(getattr(raw, "dark", False)),
661
+ pattern=bool(getattr(raw, "pattern", False)),
662
+ blur=bool(getattr(settings, "blur", False)),
663
+ intensity=getattr(settings, "intensity", None),
664
+ background_color=getattr(settings, "background_color", None),
665
+ second_background_color=getattr(settings, "second_background_color", None),
666
+ emoticon=getattr(settings, "emoticon", None),
667
+ )