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/business.py ADDED
@@ -0,0 +1,1520 @@
1
+ """The `business` group: Telegram Business, and the bot that may act as me.
2
+
3
+ Six sub-nouns behind one Settings ▸ Telegram Business screen: opening hours,
4
+ location and chat intro (`business set`), the greeting and away messages
5
+ (`business message set`), the quick replies they send (`business reply *`),
6
+ the public chat links (`business link *`) and the connected chatbot
7
+ (`business bot *`).
8
+
9
+ Two things deserve their own paragraph.
10
+
11
+ **The connected bot is the most dangerous switch in tlgr.** `businessBotRights`
12
+ grants another program the ability to read my messages, reply as me, edit my
13
+ name, bio and photo, manage my gifts and *transfer my Stars*. So every right
14
+ is opt-in by name — there is no `--all` — the command is destructive (so `-y`
15
+ is required off a TTY), and the reply enumerates exactly what was granted.
16
+
17
+ **Opening hours are minutes-of-week arithmetic, and it is easy to get wrong.**
18
+ The server stores intervals as minutes since Monday 00:00 in the business's
19
+ own timezone; an interval that runs past midnight on Sunday wraps. `_hours`
20
+ sorts, merges and clamps, and `open_now` is server-set and never sent back.
21
+
22
+ Telethon is imported inside functions, never at module scope (§2.2).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Annotated, Any
28
+
29
+ from tlgr.core.errors import NotFoundError, UsageError
30
+ from tlgr.core.pagination import PageKind
31
+ from tlgr.core.timefmt import fmt_dt, parse_dt
32
+ from tlgr.models.base import Request
33
+ from tlgr.models.business import (
34
+ BotConnection,
35
+ BotPaused,
36
+ BotRights,
37
+ BusinessAway,
38
+ BusinessGreeting,
39
+ BusinessIntro,
40
+ BusinessLocation,
41
+ BusinessMessage,
42
+ BusinessOpen,
43
+ BusinessProfile,
44
+ BusinessRecipients,
45
+ BusinessSet,
46
+ ChatLink,
47
+ ChatLinkSet,
48
+ QuickReply,
49
+ QuickReplyMessage,
50
+ QuickReplySent,
51
+ QuickReplySet,
52
+ StarsTransferQuote,
53
+ WorkHours,
54
+ )
55
+ from tlgr.models.page import Page
56
+ from tlgr.models.peer import PeerRef
57
+ from tlgr.ops import _settings
58
+ from tlgr.ops._common import client, random_id
59
+ from tlgr.ops._params import arg, opt
60
+ from tlgr.ops._spec import OpContext, OperationSpec
61
+
62
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
63
+
64
+ DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
65
+ WEEK_MINUTES = 7 * 24 * 60
66
+
67
+ #: `--flag` → the `businessBotRights` field it grants. Spelled out rather than
68
+ #: generated, because this is the list a person audits before saying yes.
69
+ BOT_RIGHTS: dict[str, str] = {
70
+ "reply_to": "reply",
71
+ "read": "read_messages",
72
+ "delete_sent": "delete_sent_messages",
73
+ "delete_received": "delete_received_messages",
74
+ "edit_name": "edit_name",
75
+ "edit_bio": "edit_bio",
76
+ "edit_username": "edit_username",
77
+ "edit_photo": "edit_profile_photo",
78
+ "manage_gifts": "view_gifts",
79
+ "transfer_stars": "transfer_stars",
80
+ "manage_stories": "manage_stories",
81
+ }
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Shared shapes
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def _minutes(text: str, *, field: str) -> int:
90
+ """`09:00` → 540. A bare hour (`9`) is accepted; anything else is an error."""
91
+ raw = text.strip()
92
+ hours, _, mins = raw.partition(":")
93
+ if not hours.isdigit() or (mins and not mins.isdigit()):
94
+ raise UsageError(f"{text!r} is not a time of day (HH:MM)", field=field)
95
+ total = int(hours) * 60 + int(mins or 0)
96
+ if total > 24 * 60:
97
+ raise UsageError(f"{text!r} is not a time of day (HH:MM)", field=field)
98
+ return total
99
+
100
+
101
+ def _hours(entries: list[str]) -> list[Any]:
102
+ """`['mon 09:00-18:00', 'tue-fri 09:00-13:00,14:00-18:00']` as weekly opens.
103
+
104
+ Sorted and merged, because the server rejects overlapping intervals and a
105
+ human writing two lines for the same day is the normal way to produce
106
+ them. Intervals are clamped to one week; an interval that ends before it
107
+ starts is a usage error rather than a silent wrap, since "22:00-02:00"
108
+ almost always means the next day and guessing is worse than asking.
109
+ """
110
+ from telethon.tl import types
111
+
112
+ spans: list[tuple[int, int]] = []
113
+ for entry in entries:
114
+ days_part, _, ranges_part = entry.strip().partition(" ")
115
+ if not ranges_part:
116
+ raise UsageError(
117
+ f"--open takes '<day[-day]> HH:MM-HH:MM[,HH:MM-HH:MM]'; got {entry!r}",
118
+ field="open",
119
+ )
120
+ days = _days_of(days_part)
121
+ for span in ranges_part.split(","):
122
+ start_text, _, end_text = span.partition("-")
123
+ if not end_text:
124
+ raise UsageError(f"{span!r} is not a time range", field="open")
125
+ start, end = _minutes(start_text, field="open"), _minutes(end_text, field="open")
126
+ if end <= start:
127
+ raise UsageError(
128
+ f"{span!r} ends before it starts; write the two days separately",
129
+ field="open",
130
+ )
131
+ for day in days:
132
+ spans.append((day * 24 * 60 + start, day * 24 * 60 + end))
133
+
134
+ merged: list[tuple[int, int]] = []
135
+ for start, end in sorted(spans):
136
+ if merged and start <= merged[-1][1]:
137
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
138
+ else:
139
+ merged.append((start, end))
140
+ return [
141
+ types.BusinessWeeklyOpen(start_minute=start, end_minute=min(end, WEEK_MINUTES))
142
+ for start, end in merged
143
+ ]
144
+
145
+
146
+ def _days_of(text: str) -> list[int]:
147
+ """`mon`, `mon-fri` or `mon,wed` as day indices."""
148
+ out: list[int] = []
149
+ for part in text.lower().split(","):
150
+ start, _, end = part.partition("-")
151
+ if start not in DAYS:
152
+ raise UsageError(f"{part!r} is not a weekday (mon…sun)", field="open")
153
+ if not end:
154
+ out.append(DAYS.index(start))
155
+ continue
156
+ if end not in DAYS:
157
+ raise UsageError(f"{part!r} is not a weekday range", field="open")
158
+ first, last = DAYS.index(start), DAYS.index(end)
159
+ out.extend(range(first, last + 1) if first <= last else [])
160
+ return sorted(set(out))
161
+
162
+
163
+ def _hours_model(raw: Any) -> WorkHours | None:
164
+ if raw is None:
165
+ return None
166
+ opens = []
167
+ for entry in getattr(raw, "weekly_open", None) or []:
168
+ start = int(getattr(entry, "start_minute", 0) or 0)
169
+ end = int(getattr(entry, "end_minute", 0) or 0)
170
+ opens.append(
171
+ BusinessOpen(
172
+ start_minute=start,
173
+ end_minute=end,
174
+ day=DAYS[min(start // (24 * 60), 6)],
175
+ open=f"{start % (24 * 60) // 60:02d}:{start % 60:02d}",
176
+ close=f"{end % (24 * 60) // 60:02d}:{end % 60:02d}",
177
+ )
178
+ )
179
+ return WorkHours(
180
+ timezone_id=str(getattr(raw, "timezone_id", "") or ""),
181
+ weekly_open=opens,
182
+ open_now=getattr(raw, "open_now", None),
183
+ )
184
+
185
+
186
+ def _recipients_model(raw: Any) -> BusinessRecipients | None:
187
+ if raw is None:
188
+ return None
189
+ return BusinessRecipients(
190
+ contacts=bool(getattr(raw, "contacts", False)),
191
+ non_contacts=bool(getattr(raw, "non_contacts", False)),
192
+ existing_chats=bool(getattr(raw, "existing_chats", False)),
193
+ new_chats=bool(getattr(raw, "new_chats", False)),
194
+ exclude_selected=bool(getattr(raw, "exclude_selected", False)),
195
+ users=[int(v) for v in getattr(raw, "users", None) or []],
196
+ exclude_users=[int(v) for v in getattr(raw, "exclude_users", None) or []],
197
+ )
198
+
199
+
200
+ def _rights_model(raw: Any) -> BotRights | None:
201
+ if raw is None:
202
+ return None
203
+ return BotRights(
204
+ **{field: bool(getattr(raw, field, False)) for field in BotRights.__struct_fields__}
205
+ )
206
+
207
+
208
+ def _bot_model(raw: Any, known: dict[int, Any]) -> BotConnection:
209
+ bot_id = int(getattr(raw, "bot_id", 0) or 0)
210
+ return BotConnection(
211
+ bot_id=bot_id,
212
+ bot=_settings.peer_model(known.get(bot_id)),
213
+ connection_id=getattr(raw, "connection_id", None),
214
+ recipients=_recipients_model(getattr(raw, "recipients", None)),
215
+ rights=_rights_model(getattr(raw, "rights", None)),
216
+ paused=bool(getattr(raw, "paused", False)),
217
+ confirmed=not bool(getattr(raw, "can_reply", None) is False),
218
+ disabled=bool(getattr(raw, "disabled", False)),
219
+ date=fmt_dt(getattr(raw, "date", None)),
220
+ dc_id=getattr(raw, "dc_id", None),
221
+ )
222
+
223
+
224
+ async def _self_full(ctx: OpContext) -> Any:
225
+ from telethon.tl import types
226
+ from telethon.tl.functions import users as fn
227
+
228
+ answer = await client(ctx)(fn.GetFullUserRequest(id=types.InputUserSelf()))
229
+ return getattr(answer, "full_user", None)
230
+
231
+
232
+ def _greeting_model(raw: Any) -> BusinessGreeting | None:
233
+ if raw is None:
234
+ return None
235
+ return BusinessGreeting(
236
+ shortcut_id=int(getattr(raw, "shortcut_id", 0) or 0),
237
+ no_activity_days=int(getattr(raw, "no_activity_days", 0) or 0),
238
+ recipients=_recipients_model(getattr(raw, "recipients", None)),
239
+ )
240
+
241
+
242
+ def _away_model(raw: Any) -> BusinessAway | None:
243
+ if raw is None:
244
+ return None
245
+ schedule = getattr(raw, "schedule", None)
246
+ name = type(schedule).__name__
247
+ word = {
248
+ "BusinessAwayMessageScheduleAlways": "always",
249
+ "BusinessAwayMessageScheduleOutsideWorkHours": "outside-hours",
250
+ "BusinessAwayMessageScheduleCustom": "custom",
251
+ }.get(name, "always")
252
+ return BusinessAway(
253
+ shortcut_id=int(getattr(raw, "shortcut_id", 0) or 0),
254
+ schedule=word,
255
+ since=fmt_dt(getattr(schedule, "start_date", None)),
256
+ until=fmt_dt(getattr(schedule, "end_date", None)),
257
+ offline_only=bool(getattr(raw, "offline_only", False)),
258
+ recipients=_recipients_model(getattr(raw, "recipients", None)),
259
+ )
260
+
261
+
262
+ def _link_model(raw: Any) -> ChatLink:
263
+ from tlgr.ops._serialize import message_entities
264
+
265
+ slug = str(getattr(raw, "link", "") or "").rsplit("/", 1)[-1]
266
+ return ChatLink(
267
+ slug=slug,
268
+ link=str(getattr(raw, "link", "") or ""),
269
+ title=getattr(raw, "title", None),
270
+ message=str(getattr(raw, "message", "") or ""),
271
+ entities=message_entities(raw),
272
+ views=getattr(raw, "views", None),
273
+ )
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # business get / set
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ class GetReq(Request):
282
+ timezones: Annotated[
283
+ bool, opt("--timezones", help="Also print the timezone ids `business set --tz` takes.")
284
+ ] = False
285
+
286
+
287
+ async def get(ctx: OpContext, req: GetReq) -> BusinessProfile:
288
+ """My Telegram Business configuration, in one object.
289
+
290
+ Everything but the chat links and the connected bots lives in `userFull`
291
+ on self, which is why one command can answer the whole screen. Business
292
+ needs Premium; connected bots are the exception and work without it.
293
+ """
294
+ from telethon.tl.functions import account as afn
295
+ from telethon.tl.functions import help as hfn
296
+
297
+ handle = client(ctx)
298
+ full = await _self_full(ctx)
299
+ profile = BusinessProfile(
300
+ work_hours=_hours_model(getattr(full, "business_work_hours", None)),
301
+ location=_location_model(getattr(full, "business_location", None)),
302
+ greeting=_greeting_model(getattr(full, "business_greeting_message", None)),
303
+ away=_away_model(getattr(full, "business_away_message", None)),
304
+ intro=_intro_model(getattr(full, "business_intro", None)),
305
+ sponsored_enabled=getattr(full, "sponsored_enabled", None),
306
+ )
307
+ if profile.work_hours is not None:
308
+ profile.open_now = profile.work_hours.open_now
309
+
310
+ try:
311
+ bots = await handle(afn.GetConnectedBotsRequest())
312
+ except Exception as exc:
313
+ ctx.warn(f"connected bots are unavailable on this account: {exc}")
314
+ else:
315
+ known = _settings.entity_map(bots)
316
+ profile.connected_bots = [
317
+ _bot_model(row, known) for row in getattr(bots, "connected_bots", None) or []
318
+ ]
319
+
320
+ try:
321
+ links = await handle(afn.GetBusinessChatLinksRequest())
322
+ except Exception as exc:
323
+ ctx.warn(f"business chat links are unavailable on this account: {exc}")
324
+ else:
325
+ profile.chat_links = [_link_model(row) for row in getattr(links, "links", None) or []]
326
+
327
+ if req.timezones:
328
+ zones = await handle(hfn.GetTimezonesListRequest(hash=0))
329
+ profile.timezones = [
330
+ {
331
+ "id": getattr(zone, "id", ""),
332
+ "name": getattr(zone, "name", ""),
333
+ "utc_offset": getattr(zone, "utc_offset", 0),
334
+ }
335
+ for zone in getattr(zones, "timezones", None) or []
336
+ ]
337
+ me = await handle.get_me()
338
+ profile.premium = bool(getattr(me, "premium", False))
339
+ return profile
340
+
341
+
342
+ def _location_model(raw: Any) -> BusinessLocation | None:
343
+ if raw is None:
344
+ return None
345
+ geo = getattr(raw, "geo_point", None)
346
+ return BusinessLocation(
347
+ address=str(getattr(raw, "address", "") or ""),
348
+ lat=getattr(geo, "lat", None),
349
+ lon=getattr(geo, "long", None),
350
+ )
351
+
352
+
353
+ def _intro_model(raw: Any) -> BusinessIntro | None:
354
+ if raw is None:
355
+ return None
356
+ return BusinessIntro(
357
+ title=str(getattr(raw, "title", "") or ""),
358
+ description=str(getattr(raw, "description", "") or ""),
359
+ sticker_id=getattr(getattr(raw, "sticker", None), "id", None),
360
+ )
361
+
362
+
363
+ SPEC_GET = OperationSpec(
364
+ id="business.get",
365
+ request=GetReq,
366
+ response=BusinessProfile,
367
+ impl=get,
368
+ summary="Show my Telegram Business configuration",
369
+ idempotent=True,
370
+ columns=("premium", "open_now", "work_hours.timezone_id", "location.address"),
371
+ headers=("Premium", "Open now", "Timezone", "Address"),
372
+ example={
373
+ "premium": True,
374
+ "open_now": True,
375
+ "work_hours": {"timezone_id": "Europe/Amsterdam", "weekly_open": []},
376
+ },
377
+ example_args="business get",
378
+ covers=("business.overview",),
379
+ tags=frozenset({"agent-safe"}),
380
+ )
381
+
382
+
383
+ class SetReq(Request):
384
+ tz: Annotated[
385
+ str | None, opt("--tz", metavar="ID", help="Timezone id from `business get --timezones`.")
386
+ ] = None
387
+ open: Annotated[
388
+ tuple[str, ...],
389
+ opt("--open", metavar="SPEC", help="Repeatable: 'mon 09:00-18:00'."),
390
+ ] = ()
391
+ clear_hours: Annotated[bool, opt("--clear-hours", help="Remove the opening hours.")] = False
392
+ address: Annotated[
393
+ str | None, opt("--address", metavar="TEXT", help="Business address (<= 96 chars).")
394
+ ] = None
395
+ lat: Annotated[float | None, opt("--lat", metavar="DEG", help="Latitude.")] = None
396
+ lon: Annotated[float | None, opt("--lon", metavar="DEG", help="Longitude.")] = None
397
+ clear_location: Annotated[bool, opt("--clear-location", help="Remove the location.")] = False
398
+ intro_title: Annotated[
399
+ str | None, opt("--intro-title", metavar="TEXT", help="Chat intro title.")
400
+ ] = None
401
+ intro_text: Annotated[
402
+ str | None, opt("--intro-text", metavar="TEXT", help="Chat intro description.")
403
+ ] = None
404
+ intro_sticker: Annotated[
405
+ str | None,
406
+ opt(
407
+ "--intro-sticker",
408
+ metavar="SET/REF",
409
+ help="Sticker as <set>/<index> or <set>/<emoji>.",
410
+ ),
411
+ ] = None
412
+ clear_intro: Annotated[
413
+ bool, opt("--clear-intro", help="Revert to the random default intro.")
414
+ ] = False
415
+
416
+
417
+ async def set_(ctx: OpContext, req: SetReq) -> BusinessSet:
418
+ """Set opening hours, business location and chat intro.
419
+
420
+ Three RPCs behind one screen, and each `--clear-*` sends the constructor
421
+ *without* its field — which is how the API deletes one. Omitting a flag
422
+ leaves that struct untouched, so the three are independent.
423
+ """
424
+ from telethon.tl import types
425
+ from telethon.tl.functions import account as fn
426
+
427
+ handle = client(ctx)
428
+ result = BusinessSet()
429
+
430
+ if req.clear_hours:
431
+ await handle(fn.UpdateBusinessWorkHoursRequest(business_work_hours=None))
432
+ result.changed.append("work_hours")
433
+ elif req.open:
434
+ if not req.tz:
435
+ raise UsageError("--open needs --tz <timezone id>", field="tz")
436
+ hours = types.BusinessWorkHours(timezone_id=req.tz, weekly_open=_hours(list(req.open)))
437
+ await handle(fn.UpdateBusinessWorkHoursRequest(business_work_hours=hours))
438
+ result.work_hours = _hours_model(hours)
439
+ result.changed.append("work_hours")
440
+
441
+ if req.clear_location:
442
+ await handle(fn.UpdateBusinessLocationRequest())
443
+ result.changed.append("location")
444
+ elif req.address or req.lat is not None or req.lon is not None:
445
+ if not req.address:
446
+ raise UsageError("a business location needs --address", field="address")
447
+ if len(req.address) > 96:
448
+ raise UsageError("--address is at most 96 characters", field="address")
449
+ geo = (
450
+ types.InputGeoPoint(lat=req.lat, long=req.lon)
451
+ if req.lat is not None and req.lon is not None
452
+ else None
453
+ )
454
+ await handle(fn.UpdateBusinessLocationRequest(geo_point=geo, address=req.address))
455
+ result.location = BusinessLocation(address=req.address, lat=req.lat, lon=req.lon)
456
+ result.changed.append("location")
457
+
458
+ if req.clear_intro:
459
+ await handle(fn.UpdateBusinessIntroRequest(intro=None))
460
+ result.changed.append("intro")
461
+ elif req.intro_title is not None or req.intro_text is not None:
462
+ sticker = None
463
+ sticker_id = None
464
+ if req.intro_sticker:
465
+ from tlgr.ops import _media
466
+
467
+ # Always through a fresh `getStickerSet`: an InputDocument built
468
+ # from a remembered id carries a dead file_reference.
469
+ document = (await _media.resolve_stickers(ctx, [req.intro_sticker]))[0]
470
+ sticker = _media.input_document(document)
471
+ sticker_id = int(getattr(document, "id", 0) or 0)
472
+ intro = types.InputBusinessIntro(
473
+ title=req.intro_title or "", description=req.intro_text or "", sticker=sticker
474
+ )
475
+ await handle(fn.UpdateBusinessIntroRequest(intro=intro))
476
+ result.intro = BusinessIntro(
477
+ title=intro.title, description=intro.description, sticker_id=sticker_id
478
+ )
479
+ result.changed.append("intro")
480
+
481
+ if not result.changed:
482
+ raise UsageError(
483
+ "nothing to change: give --open/--tz, --address or --intro-title", field="open"
484
+ )
485
+ ctx.emit("business_set", {"changed": result.changed})
486
+ return result
487
+
488
+
489
+ SPEC_SET = OperationSpec(
490
+ id="business.set",
491
+ request=SetReq,
492
+ response=BusinessSet,
493
+ impl=set_,
494
+ summary="Set opening hours, business location and chat intro",
495
+ description=(
496
+ "`--clear-*` sends the constructor without its field, which is how "
497
+ "the API deletes one; a flag you omit leaves that struct alone."
498
+ ),
499
+ mutating=True,
500
+ idempotent=True,
501
+ rate_class="send",
502
+ columns=("changed", "work_hours.timezone_id", "location.address"),
503
+ headers=("Changed", "Timezone", "Address"),
504
+ example={"changed": ["work_hours"], "work_hours": {"timezone_id": "Europe/Amsterdam"}},
505
+ example_args="business set --tz Europe/Amsterdam --open 'mon-fri 09:00-18:00'",
506
+ covers=(
507
+ "business.intro",
508
+ "business.location",
509
+ "business.working-hours",
510
+ "location.business-address",
511
+ ),
512
+ tags=frozenset({"visible-to-others"}),
513
+ )
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # business message set
518
+ # ---------------------------------------------------------------------------
519
+
520
+
521
+ class MessageSetReq(Request):
522
+ kind: Annotated[str, arg(0, metavar="KIND", help="greeting or away.")]
523
+ shortcut: Annotated[
524
+ str | None, opt("--shortcut", metavar="NAME", help="Quick-reply shortcut to send.")
525
+ ] = None
526
+ schedule: Annotated[
527
+ str | None,
528
+ opt("--schedule", metavar="WHEN", help="away: always | outside-hours | custom."),
529
+ ] = None
530
+ since: Annotated[
531
+ str | None, opt("--since", metavar="WHEN", kind="datetime", help="custom schedule start.")
532
+ ] = None
533
+ until: Annotated[
534
+ str | None, opt("--until", metavar="WHEN", kind="datetime", help="custom schedule end.")
535
+ ] = None
536
+ offline_only: Annotated[
537
+ bool, opt("--offline-only", help="away: only send while I am offline.")
538
+ ] = False
539
+ no_activity_days: Annotated[
540
+ int, opt("--no-activity-days", metavar="N", help="greeting: silence after N quiet days.")
541
+ ] = 7
542
+ contacts: Annotated[bool, opt("--contacts", help="Include contacts.")] = False
543
+ non_contacts: Annotated[bool, opt("--non-contacts", help="Include non-contacts.")] = False
544
+ existing_chats: Annotated[bool, opt("--existing-chats", help="Include existing chats.")] = False
545
+ new_chats: Annotated[bool, opt("--new-chats", help="Include new chats.")] = False
546
+ users: Annotated[
547
+ str | None, opt("--users", metavar="LIST", help="Explicit recipient users.")
548
+ ] = None
549
+ exclude: Annotated[bool, opt("--exclude", help="Treat the selection as an exclusion.")] = False
550
+ exclude_users: Annotated[
551
+ str | None, opt("--exclude-users", metavar="LIST", help="away: users to exclude.")
552
+ ] = None
553
+ off: Annotated[bool, opt("--off", help="Disable this message.")] = False
554
+
555
+
556
+ async def _recipients_tl(ctx: OpContext, req: MessageSetReq, *, bot: bool = False) -> Any:
557
+ from telethon.tl import types
558
+
559
+ users = [
560
+ await _settings.input_user(ctx, part.strip(), field="users")
561
+ for part in (req.users or "").split(",")
562
+ if part.strip()
563
+ ]
564
+ kwargs: dict[str, Any] = {
565
+ "existing_chats": req.existing_chats or None,
566
+ "new_chats": req.new_chats or None,
567
+ "contacts": req.contacts or None,
568
+ "non_contacts": req.non_contacts or None,
569
+ "exclude_selected": req.exclude or None,
570
+ "users": users or None,
571
+ }
572
+ if bot:
573
+ kwargs["exclude_users"] = [
574
+ await _settings.input_user(ctx, part.strip(), field="exclude_users")
575
+ for part in (req.exclude_users or "").split(",")
576
+ if part.strip()
577
+ ] or None
578
+ return types.InputBusinessBotRecipients(**kwargs)
579
+ return types.InputBusinessRecipients(**kwargs)
580
+
581
+
582
+ async def message_set(ctx: OpContext, req: MessageSetReq) -> BusinessMessage:
583
+ """Configure the greeting message or the away message.
584
+
585
+ Both are "send this quick reply to these people", which is why they share
586
+ one command and one recipients shape. Omitting `--shortcut` (or passing
587
+ `--off`) disables the feature — the API expresses that by sending no
588
+ message at all.
589
+ """
590
+ from telethon.tl import types
591
+ from telethon.tl.functions import account as fn
592
+
593
+ handle = client(ctx)
594
+ kind = req.kind.strip().lower()
595
+ if kind not in ("greeting", "away"):
596
+ raise UsageError("KIND is `greeting` or `away`", field="kind")
597
+
598
+ if req.off or not req.shortcut:
599
+ request = (
600
+ fn.UpdateBusinessGreetingMessageRequest
601
+ if kind == "greeting"
602
+ else fn.UpdateBusinessAwayMessageRequest
603
+ )
604
+ await handle(request(message=None))
605
+ ctx.emit("business_message", {"kind": kind, "enabled": False})
606
+ return BusinessMessage(kind=kind, enabled=False)
607
+
608
+ shortcut_id = await _shortcut_id(ctx, req.shortcut)
609
+ recipients = await _recipients_tl(ctx, req)
610
+
611
+ if kind == "greeting":
612
+ message = types.InputBusinessGreetingMessage(
613
+ shortcut_id=shortcut_id,
614
+ recipients=recipients,
615
+ no_activity_days=req.no_activity_days,
616
+ )
617
+ await handle(fn.UpdateBusinessGreetingMessageRequest(message=message))
618
+ ctx.emit("business_message", {"kind": kind, "shortcut_id": shortcut_id})
619
+ return BusinessMessage(
620
+ kind=kind,
621
+ enabled=True,
622
+ shortcut_id=shortcut_id,
623
+ shortcut=req.shortcut,
624
+ no_activity_days=req.no_activity_days,
625
+ recipients=_recipients_model(recipients),
626
+ )
627
+
628
+ word = (req.schedule or "always").strip().lower()
629
+ if word == "always":
630
+ schedule: Any = types.BusinessAwayMessageScheduleAlways()
631
+ elif word == "outside-hours":
632
+ schedule = types.BusinessAwayMessageScheduleOutsideWorkHours()
633
+ elif word == "custom":
634
+ if not req.since or not req.until:
635
+ raise UsageError("--schedule custom needs --since and --until", field="since")
636
+ schedule = types.BusinessAwayMessageScheduleCustom(
637
+ start_date=parse_dt(req.since), end_date=parse_dt(req.until)
638
+ )
639
+ else:
640
+ raise UsageError("--schedule is always, outside-hours or custom", field="schedule")
641
+
642
+ message = types.InputBusinessAwayMessage(
643
+ shortcut_id=shortcut_id,
644
+ schedule=schedule,
645
+ recipients=recipients,
646
+ offline_only=req.offline_only or None,
647
+ )
648
+ await handle(fn.UpdateBusinessAwayMessageRequest(message=message))
649
+ ctx.emit("business_message", {"kind": kind, "shortcut_id": shortcut_id})
650
+ return BusinessMessage(
651
+ kind=kind,
652
+ enabled=True,
653
+ shortcut_id=shortcut_id,
654
+ shortcut=req.shortcut,
655
+ schedule=word,
656
+ since=fmt_dt(getattr(schedule, "start_date", None)),
657
+ until=fmt_dt(getattr(schedule, "end_date", None)),
658
+ offline_only=req.offline_only,
659
+ recipients=_recipients_model(recipients),
660
+ )
661
+
662
+
663
+ async def _shortcut_id(ctx: OpContext, name: str) -> int:
664
+ """A quick-reply shortcut's id, from its name or its id."""
665
+ if name.strip().isdigit():
666
+ return int(name)
667
+ page = await reply_list(ctx, ReplyListReq())
668
+ for row in page.items:
669
+ if row.shortcut == name.strip():
670
+ return row.shortcut_id
671
+ raise NotFoundError(
672
+ f"no quick-reply shortcut named {name!r}; create one with `business reply add`"
673
+ )
674
+
675
+
676
+ SPEC_MESSAGE_SET = OperationSpec(
677
+ id="business.message.set",
678
+ request=MessageSetReq,
679
+ response=BusinessMessage,
680
+ impl=message_set,
681
+ summary="Configure the greeting message or the away message",
682
+ description=(
683
+ "Both need an existing quick-reply shortcut; `--schedule outside-hours` "
684
+ "additionally needs opening hours. Omitting `--shortcut` disables the "
685
+ "feature, which is how the API expresses 'off'."
686
+ ),
687
+ mutating=True,
688
+ idempotent=True,
689
+ rate_class="send",
690
+ columns=("kind", "shortcut_id", "shortcut", "schedule", "enabled"),
691
+ headers=("Kind", "Shortcut", "Name", "Schedule", "Enabled"),
692
+ example={
693
+ "kind": "greeting",
694
+ "shortcut_id": 3,
695
+ "shortcut": "hello",
696
+ "no_activity_days": 7,
697
+ "enabled": True,
698
+ },
699
+ example_args="business message set greeting --shortcut hello --new-chats",
700
+ covers=(
701
+ "business.away-message",
702
+ "business.greeting-message",
703
+ "contacts-users.user-business-greeting-away",
704
+ ),
705
+ tags=frozenset({"visible-to-others"}),
706
+ )
707
+
708
+
709
+ # ---------------------------------------------------------------------------
710
+ # business reply list / add / edit / delete / send
711
+ # ---------------------------------------------------------------------------
712
+
713
+
714
+ class ReplyListReq(Request):
715
+ shortcut: Annotated[
716
+ str | None,
717
+ arg(0, metavar="SHORTCUT", required=False, help="Show the messages inside one shortcut."),
718
+ ] = None
719
+
720
+
721
+ async def reply_list(ctx: OpContext, req: ReplyListReq) -> Page[QuickReply]:
722
+ """List quick-reply shortcuts, or the messages inside one.
723
+
724
+ Quick-reply messages have their own id sequence, unrelated to chat message
725
+ ids — `msg_id` here is only meaningful inside its shortcut.
726
+ """
727
+ from telethon.tl.functions import messages as fn
728
+
729
+ handle = client(ctx)
730
+ result = await handle(fn.GetQuickRepliesRequest(hash=0))
731
+ rows = [
732
+ QuickReply(
733
+ shortcut_id=int(getattr(row, "shortcut_id", 0) or 0),
734
+ shortcut=str(getattr(row, "shortcut", "") or ""),
735
+ count=int(getattr(row, "count", 0) or 0),
736
+ top_message=getattr(row, "top_message", None),
737
+ )
738
+ for row in getattr(result, "quick_replies", None) or []
739
+ ]
740
+ if req.shortcut:
741
+ wanted = req.shortcut.strip()
742
+ rows = [row for row in rows if row.shortcut == wanted or str(row.shortcut_id) == wanted]
743
+ if not rows:
744
+ raise NotFoundError(f"no quick-reply shortcut named {req.shortcut!r}")
745
+ messages = await handle(
746
+ fn.GetQuickReplyMessagesRequest(shortcut_id=rows[0].shortcut_id, hash=0)
747
+ )
748
+ rows[0].messages = [_quick_message(m) for m in getattr(messages, "messages", None) or []]
749
+ return Page(items=rows, has_more=False, total=len(rows))
750
+
751
+
752
+ def _quick_message(raw: Any) -> QuickReplyMessage:
753
+ from tlgr.ops._serialize import media_summary, message_entities
754
+
755
+ summary = media_summary(getattr(raw, "media", None))
756
+ return QuickReplyMessage(
757
+ id=int(getattr(raw, "id", 0) or 0),
758
+ text=str(getattr(raw, "message", "") or ""),
759
+ entities=message_entities(raw),
760
+ media=summary.kind if summary is not None else None,
761
+ date=fmt_dt(getattr(raw, "date", None)),
762
+ )
763
+
764
+
765
+ SPEC_REPLY_LIST = OperationSpec(
766
+ id="business.reply.list",
767
+ request=ReplyListReq,
768
+ response=Page[QuickReply],
769
+ impl=reply_list,
770
+ summary="List quick-reply shortcuts, or the messages inside one",
771
+ paginated=PageKind.LOCAL,
772
+ idempotent=True,
773
+ columns=("shortcut_id", "shortcut", "count", "top_message"),
774
+ headers=("Id", "Shortcut", "Messages", "Top"),
775
+ example={
776
+ "items": [{"shortcut_id": 3, "shortcut": "hello", "count": 1}],
777
+ "has_more": False,
778
+ },
779
+ example_args="business reply list",
780
+ covers=(
781
+ "business.quick-replies-list",
782
+ "business.quick-reply-messages",
783
+ "messages-core.quick-reply-list",
784
+ ),
785
+ tags=frozenset({"agent-safe"}),
786
+ )
787
+
788
+
789
+ class ReplyAddReq(Request):
790
+ shortcut: Annotated[str, arg(0, metavar="SHORTCUT", help="Shortcut name (created if new).")]
791
+ text: Annotated[str | None, opt("--text", metavar="TEXT", help="Message text.")] = None
792
+ file: Annotated[
793
+ tuple[str, ...],
794
+ opt("--file", metavar="PATH", kind="path", help="Attach a file (repeatable)."),
795
+ ] = ()
796
+ parse: Annotated[str, opt("--parse", metavar="MODE", help="md | html | none.")] = "md"
797
+ copy_from: Annotated[
798
+ str | None,
799
+ opt("--copy-from", metavar="CHAT:ID", help="Copy an existing message into the shortcut."),
800
+ ] = None
801
+
802
+
803
+ async def reply_add(ctx: OpContext, req: ReplyAddReq) -> QuickReplySet:
804
+ """Add a message to a quick-reply shortcut, creating the shortcut if new.
805
+
806
+ The shortcut is addressed by *name* the first time and by *id* afterwards;
807
+ `messages.checkQuickReplyShortcut` is what tells the two apart, and it is
808
+ also what enforces `quick_replies_limit` before anything is sent.
809
+ """
810
+ from telethon.tl import types
811
+ from telethon.tl.functions import messages as fn
812
+
813
+ from tlgr.ops import _send
814
+
815
+ handle = client(ctx)
816
+ name = req.shortcut.strip()
817
+ existing = {
818
+ row.shortcut: row.shortcut_id for row in (await reply_list(ctx, ReplyListReq())).items
819
+ }
820
+ if name in existing:
821
+ shortcut: Any = types.InputQuickReplyShortcutId(shortcut_id=existing[name])
822
+ else:
823
+ await handle(fn.CheckQuickReplyShortcutRequest(shortcut=name))
824
+ shortcut = types.InputQuickReplyShortcut(shortcut=name)
825
+
826
+ if req.copy_from:
827
+ chat, _, msg_id = req.copy_from.rpartition(":")
828
+ if not chat or not msg_id.strip().lstrip("-").isdigit():
829
+ raise UsageError("--copy-from wants '<chat>:<msg_id>'", field="copy_from")
830
+ await handle(
831
+ fn.ForwardMessagesRequest(
832
+ from_peer=await _settings.resolve(ctx, chat),
833
+ id=[int(msg_id)],
834
+ random_id=[random_id()],
835
+ to_peer=types.InputPeerSelf(),
836
+ quick_reply_shortcut=shortcut,
837
+ )
838
+ )
839
+ return QuickReplySet(shortcut=name, shortcut_id=existing.get(name))
840
+
841
+ if not req.text and not req.file:
842
+ raise UsageError("give --text, --file or --copy-from", field="text")
843
+
844
+ text, entities = _send.body(req.text or "", parse=req.parse, entities=None)
845
+ if req.file:
846
+ media = await _send.input_media(ctx, str(req.file[0]))
847
+ result = await handle(
848
+ fn.SendMediaRequest(
849
+ peer=types.InputPeerSelf(),
850
+ media=media,
851
+ message=text,
852
+ entities=entities,
853
+ random_id=random_id(),
854
+ quick_reply_shortcut=shortcut,
855
+ )
856
+ )
857
+ else:
858
+ result = await handle(
859
+ fn.SendMessageRequest(
860
+ peer=types.InputPeerSelf(),
861
+ message=text,
862
+ entities=entities,
863
+ random_id=random_id(),
864
+ quick_reply_shortcut=shortcut,
865
+ )
866
+ )
867
+ ctx.emit("business_reply", {"shortcut": name})
868
+ ids = [
869
+ int(getattr(getattr(update, "message", None), "id", 0) or 0)
870
+ for update in getattr(result, "updates", None) or []
871
+ if getattr(update, "message", None) is not None
872
+ ]
873
+ return QuickReplySet(
874
+ shortcut=name,
875
+ shortcut_id=existing.get(name),
876
+ msg_id=ids[0] if ids else None,
877
+ msg_ids=ids,
878
+ )
879
+
880
+
881
+ SPEC_REPLY_ADD = OperationSpec(
882
+ id="business.reply.add",
883
+ request=ReplyAddReq,
884
+ response=QuickReplySet,
885
+ impl=reply_add,
886
+ summary="Add a message to a quick-reply shortcut (creating the shortcut if needed)",
887
+ mutating=True,
888
+ rate_class="send",
889
+ columns=("shortcut", "shortcut_id", "msg_id"),
890
+ headers=("Shortcut", "Id", "Message"),
891
+ example={"shortcut": "hello", "shortcut_id": 3, "msg_id": 1},
892
+ example_args='business reply add hello --text "Hi! I will reply shortly."',
893
+ covers=("business.quick-reply-add",),
894
+ )
895
+
896
+
897
+ class ReplyEditReq(Request):
898
+ shortcut: Annotated[
899
+ str | None, arg(0, metavar="SHORTCUT", required=False, help="The shortcut to edit.")
900
+ ] = None
901
+ msg_id: Annotated[
902
+ int | None, arg(1, metavar="MSG_ID", required=False, help="A message inside it.")
903
+ ] = None
904
+ text: Annotated[str | None, opt("--text", metavar="TEXT", help="New message text.")] = None
905
+ parse: Annotated[str, opt("--parse", metavar="MODE", help="md | html | none.")] = "md"
906
+ rename: Annotated[str | None, opt("--rename", metavar="NAME", help="New shortcut name.")] = None
907
+ order: Annotated[
908
+ str | None, opt("--order", metavar="LIST", help="Every shortcut, in the wanted order.")
909
+ ] = None
910
+
911
+
912
+ async def reply_edit(ctx: OpContext, req: ReplyEditReq) -> QuickReplySet:
913
+ """Edit a quick-reply message, rename a shortcut, or reorder the list.
914
+
915
+ `--order` wants the *complete* list of shortcut ids; the API replaces the
916
+ order rather than moving one entry, and a partial list would silently
917
+ drop the rest.
918
+ """
919
+ from telethon.tl import types
920
+ from telethon.tl.functions import messages as fn
921
+
922
+ from tlgr.ops import _send
923
+
924
+ handle = client(ctx)
925
+ rows = {row.shortcut: row.shortcut_id for row in (await reply_list(ctx, ReplyListReq())).items}
926
+
927
+ if req.order is not None:
928
+ order = [
929
+ rows.get(part.strip(), int(part.strip()) if part.strip().isdigit() else 0)
930
+ for part in req.order.split(",")
931
+ if part.strip()
932
+ ]
933
+ if not order or 0 in order:
934
+ raise UsageError("--order wants every shortcut, by name or id", field="order")
935
+ await handle(fn.ReorderQuickRepliesRequest(order=order))
936
+ return QuickReplySet(order=order)
937
+
938
+ if not req.shortcut:
939
+ raise UsageError("give a shortcut, or --order", field="shortcut")
940
+ shortcut_id = await _shortcut_id(ctx, req.shortcut)
941
+
942
+ if req.rename is not None:
943
+ await handle(fn.EditQuickReplyShortcutRequest(shortcut_id=shortcut_id, shortcut=req.rename))
944
+ return QuickReplySet(shortcut_id=shortcut_id, shortcut=req.rename)
945
+
946
+ if req.msg_id is None or req.text is None:
947
+ raise UsageError("editing a message needs MSG_ID and --text", field="msg_id")
948
+ text, entities = _send.body(req.text, parse=req.parse, entities=None)
949
+ await handle(
950
+ fn.EditMessageRequest(
951
+ peer=types.InputPeerSelf(),
952
+ id=req.msg_id,
953
+ message=text,
954
+ entities=entities,
955
+ quick_reply_shortcut_id=shortcut_id,
956
+ )
957
+ )
958
+ ctx.emit("business_reply_edit", {"shortcut_id": shortcut_id, "msg_id": req.msg_id})
959
+ return QuickReplySet(shortcut_id=shortcut_id, shortcut=req.shortcut, msg_id=req.msg_id)
960
+
961
+
962
+ SPEC_REPLY_EDIT = OperationSpec(
963
+ id="business.reply.edit",
964
+ request=ReplyEditReq,
965
+ response=QuickReplySet,
966
+ impl=reply_edit,
967
+ summary="Edit a quick-reply message, rename a shortcut or reorder the shortcut list",
968
+ mutating=True,
969
+ rate_class="send",
970
+ columns=("shortcut_id", "shortcut", "msg_id", "order"),
971
+ headers=("Id", "Shortcut", "Message", "Order"),
972
+ example={"shortcut_id": 3, "shortcut": "hello", "msg_id": 1},
973
+ example_args='business reply edit hello 1 --text "Hello!"',
974
+ covers=(
975
+ "business.quick-reply-edit",
976
+ "business.quick-reply-rename",
977
+ "business.quick-reply-reorder",
978
+ "messages-core.quick-reply-manage",
979
+ ),
980
+ )
981
+
982
+
983
+ class ReplyDeleteReq(Request):
984
+ shortcut: Annotated[str, arg(0, metavar="SHORTCUT", help="The shortcut to delete from.")]
985
+ msg_id: Annotated[
986
+ tuple[int, ...],
987
+ arg(1, metavar="MSG_ID", required=False, variadic=True, help="Messages to delete."),
988
+ ] = ()
989
+
990
+
991
+ async def reply_delete(ctx: OpContext, req: ReplyDeleteReq) -> QuickReplySet:
992
+ """Delete a quick-reply shortcut, or single messages inside it."""
993
+ from telethon.tl.functions import messages as fn
994
+
995
+ handle = client(ctx)
996
+ shortcut_id = await _shortcut_id(ctx, req.shortcut)
997
+ if req.msg_id:
998
+ await handle(
999
+ fn.DeleteQuickReplyMessagesRequest(
1000
+ shortcut_id=shortcut_id, id=[int(v) for v in req.msg_id]
1001
+ )
1002
+ )
1003
+ return QuickReplySet(
1004
+ shortcut_id=shortcut_id, shortcut=req.shortcut, deleted=len(req.msg_id)
1005
+ )
1006
+ await handle(fn.DeleteQuickReplyShortcutRequest(shortcut_id=shortcut_id))
1007
+ ctx.emit("business_reply_delete", {"shortcut_id": shortcut_id})
1008
+ return QuickReplySet(shortcut_id=shortcut_id, shortcut=req.shortcut, deleted=1)
1009
+
1010
+
1011
+ SPEC_REPLY_DELETE = OperationSpec(
1012
+ id="business.reply.delete",
1013
+ request=ReplyDeleteReq,
1014
+ response=QuickReplySet,
1015
+ impl=reply_delete,
1016
+ summary="Delete a quick-reply shortcut, or single messages inside it",
1017
+ mutating=True,
1018
+ destructive=True,
1019
+ rate_class="send",
1020
+ columns=("shortcut_id", "shortcut", "deleted"),
1021
+ headers=("Id", "Shortcut", "Deleted"),
1022
+ example={"shortcut_id": 3, "shortcut": "hello", "deleted": 1},
1023
+ example_args="business reply delete hello",
1024
+ covers=("business.quick-reply-delete", "business.quick-reply-delete-messages"),
1025
+ )
1026
+
1027
+
1028
+ class ReplySendReq(Request):
1029
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Private chat to send to.")]
1030
+ shortcut: Annotated[str, arg(1, metavar="SHORTCUT", help="The shortcut to send.")]
1031
+ only: Annotated[
1032
+ str | None, opt("--only", metavar="LIST", help="Only these message ids from the shortcut.")
1033
+ ] = None
1034
+
1035
+
1036
+ async def reply_send(ctx: OpContext, req: ReplySendReq) -> QuickReplySent:
1037
+ """Send a quick reply into a private chat. Users only, private chats only."""
1038
+ from telethon.tl.functions import messages as fn
1039
+
1040
+ handle = client(ctx)
1041
+ peer = await _settings.resolve(ctx, req.chat)
1042
+ shortcut_id = await _shortcut_id(ctx, req.shortcut)
1043
+ page = await reply_list(ctx, ReplyListReq(shortcut=req.shortcut))
1044
+ available = [message.id for message in page.items[0].messages]
1045
+ wanted = [int(part) for part in req.only.split(",") if part.strip()] if req.only else available
1046
+ if not wanted:
1047
+ raise UsageError(f"shortcut {req.shortcut!r} has no messages", field="shortcut")
1048
+ result = await handle(
1049
+ fn.SendQuickReplyMessagesRequest(
1050
+ peer=peer,
1051
+ shortcut_id=shortcut_id,
1052
+ id=wanted,
1053
+ random_id=[random_id() for _ in wanted],
1054
+ )
1055
+ )
1056
+ ids = [
1057
+ int(getattr(getattr(update, "message", None), "id", 0) or 0)
1058
+ for update in getattr(result, "updates", None) or []
1059
+ if getattr(update, "message", None) is not None
1060
+ ]
1061
+ chat_id = _settings.peer_of(peer)
1062
+ ctx.emit("business_reply_sent", {"chat_id": chat_id, "shortcut_id": shortcut_id})
1063
+ return QuickReplySent(chat_id=chat_id, shortcut_id=shortcut_id, message_ids=ids)
1064
+
1065
+
1066
+ SPEC_REPLY_SEND = OperationSpec(
1067
+ id="business.reply.send",
1068
+ request=ReplySendReq,
1069
+ response=QuickReplySent,
1070
+ impl=reply_send,
1071
+ summary="Send a quick reply into a private chat",
1072
+ aliases=("quickreply.send",),
1073
+ mutating=True,
1074
+ rate_class="send",
1075
+ columns=("chat_id", "shortcut_id", "message_ids"),
1076
+ headers=("Chat", "Shortcut", "Messages"),
1077
+ example={"chat_id": 777123, "shortcut_id": 3, "message_ids": [4242]},
1078
+ example_args="business reply send @alice hello",
1079
+ covers=("business.quick-reply-send",),
1080
+ tags=frozenset({"visible-to-others"}),
1081
+ )
1082
+
1083
+
1084
+ # ---------------------------------------------------------------------------
1085
+ # business link list / set
1086
+ # ---------------------------------------------------------------------------
1087
+
1088
+
1089
+ class LinkListReq(Request):
1090
+ slug: Annotated[
1091
+ str | None,
1092
+ opt("--slug", metavar="SLUG", help="Resolve one link, including other people's."),
1093
+ ] = None
1094
+
1095
+
1096
+ async def link_list(ctx: OpContext, req: LinkListReq) -> Page[ChatLink]:
1097
+ """My business chat links, or a resolved one.
1098
+
1099
+ Creating and editing needs Premium; *resolving* somebody else's link does
1100
+ not, which is why `--slug` is the one half of this command that works on
1101
+ any account.
1102
+ """
1103
+ from telethon.tl.functions import account as fn
1104
+
1105
+ handle = client(ctx)
1106
+ if req.slug:
1107
+ slug = req.slug.rsplit("/", 1)[-1]
1108
+ resolved = await handle(fn.ResolveBusinessChatLinkRequest(slug=slug))
1109
+ from tlgr.ops._serialize import message_entities
1110
+
1111
+ peer_id = getattr(getattr(resolved, "peer", None), "user_id", None)
1112
+ return Page(
1113
+ items=[
1114
+ ChatLink(
1115
+ slug=slug,
1116
+ link=f"https://t.me/m/{slug}",
1117
+ message=str(getattr(resolved, "message", "") or ""),
1118
+ entities=message_entities(resolved),
1119
+ title=str(peer_id) if peer_id else None,
1120
+ )
1121
+ ],
1122
+ has_more=False,
1123
+ total=1,
1124
+ )
1125
+ result = await handle(fn.GetBusinessChatLinksRequest())
1126
+ rows = [_link_model(row) for row in getattr(result, "links", None) or []]
1127
+ return Page(items=rows, has_more=False, total=len(rows))
1128
+
1129
+
1130
+ SPEC_LINK_LIST = OperationSpec(
1131
+ id="business.link.list",
1132
+ request=LinkListReq,
1133
+ response=Page[ChatLink],
1134
+ impl=link_list,
1135
+ summary="List my business chat links, or resolve someone's link",
1136
+ paginated=PageKind.LOCAL,
1137
+ idempotent=True,
1138
+ columns=("slug", "link", "title", "views"),
1139
+ headers=("Slug", "Link", "Title", "Views"),
1140
+ example={
1141
+ "items": [{"slug": "abc", "link": "https://t.me/m/abc", "message": "Hi!"}],
1142
+ "has_more": False,
1143
+ },
1144
+ example_args="business link list",
1145
+ covers=("business.chat-links", "dialogs.business-link-list"),
1146
+ tags=frozenset({"agent-safe"}),
1147
+ )
1148
+
1149
+
1150
+ class LinkSetReq(Request):
1151
+ slug: Annotated[
1152
+ str | None, arg(0, metavar="SLUG", required=False, help="Omit to create a new link.")
1153
+ ] = None
1154
+ text: Annotated[str | None, opt("--text", metavar="TEXT", help="Prefilled message.")] = None
1155
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="Link title.")] = None
1156
+ parse: Annotated[str, opt("--parse", metavar="MODE", help="md | html | none.")] = "md"
1157
+ entities: Annotated[
1158
+ str | None, opt("--entities", metavar="JSON", help="Explicit entities.")
1159
+ ] = None
1160
+ delete: Annotated[bool, opt("--delete", help="Delete the link.")] = False
1161
+
1162
+
1163
+ async def link_set(ctx: OpContext, req: LinkSetReq) -> ChatLinkSet:
1164
+ """Create, edit or delete a business chat link.
1165
+
1166
+ `CHATLINKS_TOO_MUCH` means the `business_chat_links_limit` from appConfig
1167
+ is reached; the server says which, so tlgr passes it through rather than
1168
+ counting the links itself and guessing.
1169
+ """
1170
+ from telethon.tl import types
1171
+ from telethon.tl.functions import account as fn
1172
+
1173
+ from tlgr.ops import _send
1174
+
1175
+ handle = client(ctx)
1176
+ if req.delete:
1177
+ if not req.slug:
1178
+ raise UsageError("--delete needs the slug to delete", field="slug")
1179
+ await handle(fn.DeleteBusinessChatLinkRequest(slug=req.slug))
1180
+ ctx.emit("business_link_deleted", {"slug": req.slug})
1181
+ return ChatLinkSet(slug=req.slug, deleted=True)
1182
+
1183
+ text, entities = _send.body(req.text or "", parse=req.parse, entities=req.entities)
1184
+ link = types.InputBusinessChatLink(message=text, entities=entities, title=req.title)
1185
+ if req.slug:
1186
+ result = await handle(fn.EditBusinessChatLinkRequest(slug=req.slug, link=link))
1187
+ else:
1188
+ result = await handle(fn.CreateBusinessChatLinkRequest(link=link))
1189
+ model = _link_model(result)
1190
+ ctx.emit("business_link", {"slug": model.slug})
1191
+ return ChatLinkSet(slug=model.slug, link=model.link, title=model.title, message=model.message)
1192
+
1193
+
1194
+ SPEC_LINK_SET = OperationSpec(
1195
+ id="business.link.set",
1196
+ request=LinkSetReq,
1197
+ response=ChatLinkSet,
1198
+ impl=link_set,
1199
+ summary="Create, edit or delete a business chat link",
1200
+ mutating=True,
1201
+ rate_class="send",
1202
+ columns=("slug", "link", "title", "deleted"),
1203
+ headers=("Slug", "Link", "Title", "Deleted"),
1204
+ example={"slug": "abc", "link": "https://t.me/m/abc", "message": "Hi!"},
1205
+ example_args='business link set --text "Hi! How can I help?"',
1206
+ covers=(
1207
+ "dialogs.business-link-create",
1208
+ "dialogs.business-link-delete",
1209
+ "dialogs.business-link-edit",
1210
+ ),
1211
+ covers_partial=("business.chat-links",),
1212
+ coverage_note="Listing and resolving links is `business link list`.",
1213
+ )
1214
+
1215
+
1216
+ # ---------------------------------------------------------------------------
1217
+ # business bot list / set / toggle
1218
+ # ---------------------------------------------------------------------------
1219
+
1220
+
1221
+ class BotListReq(Request):
1222
+ connection: Annotated[
1223
+ str | None,
1224
+ opt("--connection", metavar="ID", help="Bot side: inspect one business connection."),
1225
+ ] = None
1226
+
1227
+
1228
+ async def bot_list(ctx: OpContext, req: BotListReq) -> Page[BotConnection]:
1229
+ """Chatbots connected to my account, or one connection in detail.
1230
+
1231
+ `--connection` is the *bot's* view and only works from a bot session; a
1232
+ user account asking for it gets `BOT_METHOD_INVALID`, which is why the
1233
+ flag says so rather than the error doing it.
1234
+ """
1235
+ from telethon.tl.functions import account as fn
1236
+
1237
+ handle = client(ctx)
1238
+ if req.connection:
1239
+ from tlgr.ops import _bots
1240
+
1241
+ await _bots.require_bot_session(ctx, "business bot list --connection")
1242
+ result = await handle(fn.GetBotBusinessConnectionRequest(connection_id=req.connection))
1243
+ known = _settings.entity_map(result)
1244
+ rows = [
1245
+ _bot_model(getattr(update, "connection", update), known)
1246
+ for update in getattr(result, "updates", None) or []
1247
+ if getattr(update, "connection", None) is not None
1248
+ ]
1249
+ return Page(items=rows, has_more=False, total=len(rows))
1250
+
1251
+ result = await handle(fn.GetConnectedBotsRequest())
1252
+ known = _settings.entity_map(result)
1253
+ rows = [_bot_model(row, known) for row in getattr(result, "connected_bots", None) or []]
1254
+ return Page(items=rows, has_more=False, total=len(rows))
1255
+
1256
+
1257
+ SPEC_BOT_LIST = OperationSpec(
1258
+ id="business.bot.list",
1259
+ request=BotListReq,
1260
+ response=Page[BotConnection],
1261
+ impl=bot_list,
1262
+ summary="List chatbots connected to my account (and inspect one connection)",
1263
+ paginated=PageKind.LOCAL,
1264
+ idempotent=True,
1265
+ columns=("bot_id", "connection_id", "paused", "confirmed", "disabled"),
1266
+ headers=("Bot", "Connection", "Paused", "Confirmed", "Disabled"),
1267
+ example={
1268
+ "items": [{"bot_id": 5000001, "paused": False, "rights": {"reply": True}}],
1269
+ "has_more": False,
1270
+ },
1271
+ example_args="business bot list",
1272
+ covers=(
1273
+ "bots.business-bots-list",
1274
+ "business.bot-connection",
1275
+ "business.connected-bots",
1276
+ "dialogs.business-bot-bar",
1277
+ ),
1278
+ tags=frozenset({"agent-safe"}),
1279
+ )
1280
+
1281
+
1282
+ class BotSetReq(Request):
1283
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to connect.")]
1284
+ reply_to: Annotated[bool, opt("--reply-to", help="Right: reply to messages.")] = False
1285
+ read: Annotated[bool, opt("--read", help="Right: read messages.")] = False
1286
+ delete_sent: Annotated[bool, opt("--delete-sent", help="Right: delete messages it sent.")] = (
1287
+ False
1288
+ )
1289
+ delete_received: Annotated[
1290
+ bool, opt("--delete-received", help="Right: delete messages it received.")
1291
+ ] = False
1292
+ edit_name: Annotated[bool, opt("--edit-name", help="Right: edit my name.")] = False
1293
+ edit_bio: Annotated[bool, opt("--edit-bio", help="Right: edit my bio.")] = False
1294
+ edit_username: Annotated[bool, opt("--edit-username", help="Right: edit my username.")] = False
1295
+ edit_photo: Annotated[bool, opt("--edit-photo", help="Right: edit my profile photo.")] = False
1296
+ manage_gifts: Annotated[
1297
+ bool, opt("--manage-gifts", help="Right: view and manage gifts and Stars.")
1298
+ ] = False
1299
+ transfer_stars: Annotated[
1300
+ bool, opt("--transfer-stars", help="Right: transfer Stars to the bot.")
1301
+ ] = False
1302
+ manage_stories: Annotated[bool, opt("--manage-stories", help="Right: manage stories.")] = False
1303
+ contacts: Annotated[bool, opt("--contacts", help="Recipients: contacts.")] = False
1304
+ non_contacts: Annotated[bool, opt("--non-contacts", help="Recipients: non-contacts.")] = False
1305
+ existing_chats: Annotated[bool, opt("--existing-chats", help="Recipients: existing chats.")] = (
1306
+ False
1307
+ )
1308
+ new_chats: Annotated[bool, opt("--new-chats", help="Recipients: new chats.")] = False
1309
+ users: Annotated[
1310
+ str | None, opt("--users", metavar="LIST", help="Explicit recipient users.")
1311
+ ] = None
1312
+ exclude_users: Annotated[
1313
+ str | None, opt("--exclude-users", metavar="LIST", help="Users to exclude.")
1314
+ ] = None
1315
+ exclude: Annotated[bool, opt("--exclude", help="Invert the selection.")] = False
1316
+ confirm: Annotated[bool, opt("--confirm", help="Activate a pending connection.")] = False
1317
+ disconnect: Annotated[bool, opt("--disconnect", help="Remove the bot.")] = False
1318
+
1319
+
1320
+ async def bot_set(ctx: OpContext, req: BotSetReq) -> BotConnection:
1321
+ """Connect, re-scope, confirm or disconnect a business chatbot.
1322
+
1323
+ Every right is opt-in **by name**. There is deliberately no `--all`: this
1324
+ is the command that lets another program read your messages, rewrite your
1325
+ profile and move your Stars, and the reply enumerates exactly what was
1326
+ granted so an audit does not have to trust the flags that were typed.
1327
+ """
1328
+ from telethon.tl import types
1329
+ from telethon.tl.functions import account as fn
1330
+
1331
+ handle = client(ctx)
1332
+ bot = await _settings.input_user(ctx, req.bot, field="bot")
1333
+
1334
+ if req.confirm:
1335
+ await handle(fn.ConfirmBotConnectionRequest(bot_id=bot))
1336
+ ctx.emit("business_bot_confirmed", {})
1337
+ page = await bot_list(ctx, BotListReq())
1338
+ return page.items[0] if page.items else BotConnection(confirmed=True)
1339
+
1340
+ recipients = types.InputBusinessBotRecipients(
1341
+ existing_chats=req.existing_chats or None,
1342
+ new_chats=req.new_chats or None,
1343
+ contacts=req.contacts or None,
1344
+ non_contacts=req.non_contacts or None,
1345
+ exclude_selected=req.exclude or None,
1346
+ users=[
1347
+ await _settings.input_user(ctx, part.strip(), field="users")
1348
+ for part in (req.users or "").split(",")
1349
+ if part.strip()
1350
+ ]
1351
+ or None,
1352
+ exclude_users=[
1353
+ await _settings.input_user(ctx, part.strip(), field="exclude_users")
1354
+ for part in (req.exclude_users or "").split(",")
1355
+ if part.strip()
1356
+ ]
1357
+ or None,
1358
+ )
1359
+
1360
+ granted = {field: True for flag, field in BOT_RIGHTS.items() if getattr(req, flag)}
1361
+ rights = None if req.disconnect else types.BusinessBotRights(**granted)
1362
+ await handle(
1363
+ fn.UpdateConnectedBotRequest(
1364
+ bot=bot,
1365
+ recipients=recipients,
1366
+ deleted=req.disconnect or None,
1367
+ rights=rights,
1368
+ )
1369
+ )
1370
+ ctx.emit(
1371
+ "business_bot",
1372
+ {"granted": sorted(granted), "deleted": req.disconnect},
1373
+ )
1374
+ return BotConnection(
1375
+ bot_id=_settings.peer_of(await _settings.resolve(ctx, req.bot)),
1376
+ recipients=_recipients_model(recipients),
1377
+ rights=_rights_model(rights),
1378
+ deleted=req.disconnect,
1379
+ )
1380
+
1381
+
1382
+ SPEC_BOT_SET = OperationSpec(
1383
+ id="business.bot.set",
1384
+ request=BotSetReq,
1385
+ response=BotConnection,
1386
+ impl=bot_set,
1387
+ summary="Connect, re-scope, confirm or disconnect a business chatbot",
1388
+ description=(
1389
+ "Rights default to none and each one is named explicitly, because a "
1390
+ "connected bot can read, reply, rewrite the profile and move Stars. "
1391
+ "Acting *as* the bot on somebody's account "
1392
+ "(`invokeWithBusinessConnection`) is a bot-side surface and out of "
1393
+ "scope for the user-side CLI."
1394
+ ),
1395
+ mutating=True,
1396
+ destructive=True,
1397
+ rate_class="send",
1398
+ columns=("bot_id", "rights", "deleted"),
1399
+ headers=("Bot", "Rights", "Removed"),
1400
+ example={"bot_id": 5000001, "rights": {"reply": True, "read_messages": True}},
1401
+ example_args="business bot set @mybot --reply-to --read --new-chats",
1402
+ covers=(
1403
+ "bots.business-bot-connect",
1404
+ "bots.business-bot-disconnect",
1405
+ "bots.business-bot-remove-from-chat",
1406
+ "business.account-edit-via-bot",
1407
+ "business.confirm-bot-connection",
1408
+ "stories.business-story",
1409
+ ),
1410
+ covers_partial=("business.connected-bots",),
1411
+ coverage_note="Listing the connections is `business bot list`.",
1412
+ tags=frozenset({"visible-to-others"}),
1413
+ )
1414
+
1415
+
1416
+ class BotToggleReq(Request):
1417
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat to change.")]
1418
+ resume: Annotated[bool, opt("--resume", help="Un-pause the bot in this chat.")] = False
1419
+ remove: Annotated[bool, opt("--remove", help="Remove the bot from this chat entirely.")] = False
1420
+
1421
+
1422
+ async def bot_toggle(ctx: OpContext, req: BotToggleReq) -> BotPaused:
1423
+ """Pause, resume or exclude the connected bot in one chat."""
1424
+ from telethon.tl.functions import account as fn
1425
+
1426
+ handle = client(ctx)
1427
+ peer = await _settings.resolve(ctx, req.chat)
1428
+ chat_id = _settings.peer_of(peer)
1429
+ if req.remove:
1430
+ await handle(fn.DisablePeerConnectedBotRequest(peer=peer))
1431
+ ctx.emit("business_bot_chat", {"chat_id": chat_id, "removed": True})
1432
+ return BotPaused(chat_id=chat_id, removed=True)
1433
+ paused = not req.resume
1434
+ await handle(fn.ToggleConnectedBotPausedRequest(peer=peer, paused=paused))
1435
+ ctx.emit("business_bot_chat", {"chat_id": chat_id, "paused": paused})
1436
+ return BotPaused(chat_id=chat_id, paused=paused)
1437
+
1438
+
1439
+ SPEC_BOT_TOGGLE = OperationSpec(
1440
+ id="business.bot.toggle",
1441
+ request=BotToggleReq,
1442
+ response=BotPaused,
1443
+ impl=bot_toggle,
1444
+ summary="Pause, resume or exclude the connected bot in one chat",
1445
+ aliases=("business.bot.pause",),
1446
+ mutating=True,
1447
+ idempotent=True,
1448
+ rate_class="send",
1449
+ columns=("chat_id", "paused", "removed"),
1450
+ headers=("Chat", "Paused", "Removed"),
1451
+ example={"chat_id": 777123, "paused": True},
1452
+ example_args="business bot toggle @alice",
1453
+ covers=("business.bot-pause-chat", "business.bot-remove-chat"),
1454
+ )
1455
+
1456
+
1457
+ # ---------------------------------------------------------------------------
1458
+ # business stars transfer
1459
+ # ---------------------------------------------------------------------------
1460
+
1461
+
1462
+ class StarsTransferReq(Request):
1463
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The connected bot.")]
1464
+ amount: Annotated[int | None, opt("--amount", metavar="STARS", help="Stars to transfer.")] = (
1465
+ None
1466
+ )
1467
+
1468
+
1469
+ async def stars_transfer(ctx: OpContext, req: StarsTransferReq) -> StarsTransferQuote:
1470
+ """Price a Stars transfer to a business bot — and refuse to make it.
1471
+
1472
+ `payments.sendStarsForm` is absent from tlgr's surface by policy (see
1473
+ `ops/payment.py`), and this PR does not open a second door onto the same
1474
+ money. The form is fetched so the price is visible, and `ok` is false with
1475
+ the reason attached.
1476
+ """
1477
+ from telethon.tl import types
1478
+ from telethon.tl.functions import payments as fn
1479
+
1480
+ if req.amount is None or req.amount <= 0:
1481
+ raise UsageError("--amount is the number of Stars to transfer", field="amount")
1482
+ bot = await _settings.input_user(ctx, req.bot, field="bot")
1483
+ invoice = types.InputInvoiceBusinessBotTransferStars(bot=bot, stars=int(req.amount))
1484
+ form = await client(ctx)(fn.GetPaymentFormRequest(invoice=invoice))
1485
+ prices = getattr(getattr(form, "invoice", None), "prices", None) or []
1486
+ return StarsTransferQuote(
1487
+ bot_id=_settings.peer_of(await _settings.resolve(ctx, req.bot)),
1488
+ stars=sum(int(getattr(price, "amount", 0) or 0) for price in prices) or int(req.amount),
1489
+ currency=str(getattr(getattr(form, "invoice", None), "currency", "XTR") or "XTR"),
1490
+ ok=False,
1491
+ reason=_settings.NO_SPEND,
1492
+ form_id=getattr(form, "form_id", None),
1493
+ )
1494
+
1495
+
1496
+ SPEC_STARS_TRANSFER = OperationSpec(
1497
+ id="business.stars.transfer",
1498
+ request=StarsTransferReq,
1499
+ response=StarsTransferQuote,
1500
+ impl=stars_transfer,
1501
+ summary="Price a Stars transfer from a business account to its bot",
1502
+ description=(
1503
+ "Reads `payments.getPaymentForm` and stops there. tlgr never signs a "
1504
+ "payment form — PR-10 settled that for the `payment` group and this "
1505
+ "group inherits it rather than opening a second door onto the money."
1506
+ ),
1507
+ idempotent=True,
1508
+ columns=("bot_id", "stars", "currency", "ok", "reason"),
1509
+ headers=("Bot", "Stars", "Currency", "Sent", "Why not"),
1510
+ example={"bot_id": 5000001, "stars": 100, "currency": "XTR", "ok": False},
1511
+ example_args="business stars transfer @mybot --amount 100",
1512
+ covers_partial=("stars.business-bot-transfer",),
1513
+ coverage_note=(
1514
+ "The price and the form are reported; signing the form is deliberately "
1515
+ "absent from tlgr's whole surface."
1516
+ ),
1517
+ tags=frozenset({"agent-safe"}),
1518
+ )
1519
+
1520
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]