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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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_")]
|