@wirecat/tg-cli 0.0.0 → 0.43.1

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 (181) hide show
  1. package/LICENSE +204 -0
  2. package/README.md +527 -0
  3. package/THIRD_PARTY_NOTICES +55 -0
  4. package/dist/app.d.ts +11 -0
  5. package/dist/app.d.ts.map +1 -0
  6. package/dist/app.js +16 -0
  7. package/dist/app.js.map +1 -0
  8. package/dist/bin/tg.d.ts +3 -0
  9. package/dist/bin/tg.d.ts.map +1 -0
  10. package/dist/bin/tg.js +18 -0
  11. package/dist/bin/tg.js.map +1 -0
  12. package/dist/bot/adapter.d.ts +9 -0
  13. package/dist/bot/adapter.d.ts.map +1 -0
  14. package/dist/bot/adapter.js +171 -0
  15. package/dist/bot/adapter.js.map +1 -0
  16. package/dist/bot/generated/definitions.d.ts +3 -0
  17. package/dist/bot/generated/definitions.d.ts.map +1 -0
  18. package/dist/bot/generated/definitions.js +19104 -0
  19. package/dist/bot/generated/definitions.js.map +1 -0
  20. package/dist/bot/generated/manifest.d.ts +3 -0
  21. package/dist/bot/generated/manifest.d.ts.map +1 -0
  22. package/dist/bot/generated/manifest.js +15354 -0
  23. package/dist/bot/generated/manifest.js.map +1 -0
  24. package/dist/bot/generated/schemas.d.ts +1407 -0
  25. package/dist/bot/generated/schemas.d.ts.map +1 -0
  26. package/dist/bot/generated/schemas.js +5118 -0
  27. package/dist/bot/generated/schemas.js.map +1 -0
  28. package/dist/bot/generated/types.d.ts +6884 -0
  29. package/dist/bot/generated/types.d.ts.map +1 -0
  30. package/dist/bot/generated/types.js +5 -0
  31. package/dist/bot/generated/types.js.map +1 -0
  32. package/dist/bot/map.d.ts +122 -0
  33. package/dist/bot/map.d.ts.map +1 -0
  34. package/dist/bot/map.js +193 -0
  35. package/dist/bot/map.js.map +1 -0
  36. package/dist/bot/proxy.d.ts +15 -0
  37. package/dist/bot/proxy.d.ts.map +1 -0
  38. package/dist/bot/proxy.js +64 -0
  39. package/dist/bot/proxy.js.map +1 -0
  40. package/dist/bot/transport.d.ts +42 -0
  41. package/dist/bot/transport.d.ts.map +1 -0
  42. package/dist/bot/transport.js +158 -0
  43. package/dist/bot/transport.js.map +1 -0
  44. package/dist/browser.d.ts +6 -0
  45. package/dist/browser.d.ts.map +1 -0
  46. package/dist/browser.js +18 -0
  47. package/dist/browser.js.map +1 -0
  48. package/dist/commands/bot-api.d.ts +3 -0
  49. package/dist/commands/bot-api.d.ts.map +1 -0
  50. package/dist/commands/bot-api.js +112 -0
  51. package/dist/commands/bot-api.js.map +1 -0
  52. package/dist/commands/bot.d.ts +7 -0
  53. package/dist/commands/bot.d.ts.map +1 -0
  54. package/dist/commands/bot.js +99 -0
  55. package/dist/commands/bot.js.map +1 -0
  56. package/dist/commands/context.d.ts +59 -0
  57. package/dist/commands/context.d.ts.map +1 -0
  58. package/dist/commands/context.js +176 -0
  59. package/dist/commands/context.js.map +1 -0
  60. package/dist/commands/proxy-config.d.ts +9 -0
  61. package/dist/commands/proxy-config.d.ts.map +1 -0
  62. package/dist/commands/proxy-config.js +63 -0
  63. package/dist/commands/proxy-config.js.map +1 -0
  64. package/dist/commands/session.d.ts +22 -0
  65. package/dist/commands/session.d.ts.map +1 -0
  66. package/dist/commands/session.js +191 -0
  67. package/dist/commands/session.js.map +1 -0
  68. package/dist/commands/setup.d.ts +3 -0
  69. package/dist/commands/setup.d.ts.map +1 -0
  70. package/dist/commands/setup.js +203 -0
  71. package/dist/commands/setup.js.map +1 -0
  72. package/dist/commands/update.d.ts +2 -0
  73. package/dist/commands/update.d.ts.map +1 -0
  74. package/dist/commands/update.js +35 -0
  75. package/dist/commands/update.js.map +1 -0
  76. package/dist/install/postinstall.d.ts +8 -0
  77. package/dist/install/postinstall.d.ts.map +1 -0
  78. package/dist/install/postinstall.js +62 -0
  79. package/dist/install/postinstall.js.map +1 -0
  80. package/dist/paths.d.ts +9 -0
  81. package/dist/paths.d.ts.map +1 -0
  82. package/dist/paths.js +11 -0
  83. package/dist/paths.js.map +1 -0
  84. package/dist/program.d.ts +8 -0
  85. package/dist/program.d.ts.map +1 -0
  86. package/dist/program.js +138 -0
  87. package/dist/program.js.map +1 -0
  88. package/dist/proxy.d.ts +31 -0
  89. package/dist/proxy.d.ts.map +1 -0
  90. package/dist/proxy.js +99 -0
  91. package/dist/proxy.js.map +1 -0
  92. package/dist/telegram/adapter.d.ts +432 -0
  93. package/dist/telegram/adapter.d.ts.map +1 -0
  94. package/dist/telegram/adapter.js +1898 -0
  95. package/dist/telegram/adapter.js.map +1 -0
  96. package/dist/telegram/bot-history.d.ts +18 -0
  97. package/dist/telegram/bot-history.d.ts.map +1 -0
  98. package/dist/telegram/bot-history.js +145 -0
  99. package/dist/telegram/bot-history.js.map +1 -0
  100. package/dist/telegram/comments.d.ts +18 -0
  101. package/dist/telegram/comments.d.ts.map +1 -0
  102. package/dist/telegram/comments.js +48 -0
  103. package/dist/telegram/comments.js.map +1 -0
  104. package/dist/telegram/credentials.d.ts +22 -0
  105. package/dist/telegram/credentials.d.ts.map +1 -0
  106. package/dist/telegram/credentials.js +49 -0
  107. package/dist/telegram/credentials.js.map +1 -0
  108. package/dist/telegram/errors.d.ts +6 -0
  109. package/dist/telegram/errors.d.ts.map +1 -0
  110. package/dist/telegram/errors.js +186 -0
  111. package/dist/telegram/errors.js.map +1 -0
  112. package/dist/telegram/folder-rules.d.ts +14 -0
  113. package/dist/telegram/folder-rules.d.ts.map +1 -0
  114. package/dist/telegram/folder-rules.js +23 -0
  115. package/dist/telegram/folder-rules.js.map +1 -0
  116. package/dist/telegram/format-html.d.ts +4 -0
  117. package/dist/telegram/format-html.d.ts.map +1 -0
  118. package/dist/telegram/format-html.js +29 -0
  119. package/dist/telegram/format-html.js.map +1 -0
  120. package/dist/telegram/format-markdown.d.ts +3 -0
  121. package/dist/telegram/format-markdown.d.ts.map +1 -0
  122. package/dist/telegram/format-markdown.js +160 -0
  123. package/dist/telegram/format-markdown.js.map +1 -0
  124. package/dist/telegram/join-requests.d.ts +15 -0
  125. package/dist/telegram/join-requests.d.ts.map +1 -0
  126. package/dist/telegram/join-requests.js +35 -0
  127. package/dist/telegram/join-requests.js.map +1 -0
  128. package/dist/telegram/map.d.ts +92 -0
  129. package/dist/telegram/map.d.ts.map +1 -0
  130. package/dist/telegram/map.js +471 -0
  131. package/dist/telegram/map.js.map +1 -0
  132. package/dist/telegram/poll-voters.d.ts +15 -0
  133. package/dist/telegram/poll-voters.d.ts.map +1 -0
  134. package/dist/telegram/poll-voters.js +31 -0
  135. package/dist/telegram/poll-voters.js.map +1 -0
  136. package/dist/telegram/polls.d.ts +5 -0
  137. package/dist/telegram/polls.d.ts.map +1 -0
  138. package/dist/telegram/polls.js +20 -0
  139. package/dist/telegram/polls.js.map +1 -0
  140. package/dist/telegram/profile.d.ts +9 -0
  141. package/dist/telegram/profile.d.ts.map +1 -0
  142. package/dist/telegram/profile.js +175 -0
  143. package/dist/telegram/profile.js.map +1 -0
  144. package/dist/telegram/proxy.d.ts +48 -0
  145. package/dist/telegram/proxy.d.ts.map +1 -0
  146. package/dist/telegram/proxy.js +108 -0
  147. package/dist/telegram/proxy.js.map +1 -0
  148. package/dist/telegram/registration.d.ts +31 -0
  149. package/dist/telegram/registration.d.ts.map +1 -0
  150. package/dist/telegram/registration.js +95 -0
  151. package/dist/telegram/registration.js.map +1 -0
  152. package/dist/telegram/send-as.d.ts +17 -0
  153. package/dist/telegram/send-as.d.ts.map +1 -0
  154. package/dist/telegram/send-as.js +67 -0
  155. package/dist/telegram/send-as.js.map +1 -0
  156. package/dist/telegram/stats.d.ts +13 -0
  157. package/dist/telegram/stats.d.ts.map +1 -0
  158. package/dist/telegram/stats.js +135 -0
  159. package/dist/telegram/stats.js.map +1 -0
  160. package/dist/telegram/storage.d.ts +4 -0
  161. package/dist/telegram/storage.d.ts.map +1 -0
  162. package/dist/telegram/storage.js +68 -0
  163. package/dist/telegram/storage.js.map +1 -0
  164. package/dist/telegram/upload.d.ts +9 -0
  165. package/dist/telegram/upload.d.ts.map +1 -0
  166. package/dist/telegram/upload.js +29 -0
  167. package/dist/telegram/upload.js.map +1 -0
  168. package/dist/update.d.ts +28 -0
  169. package/dist/update.d.ts.map +1 -0
  170. package/dist/update.js +56 -0
  171. package/dist/update.js.map +1 -0
  172. package/dist/version.d.ts +2 -0
  173. package/dist/version.d.ts.map +1 -0
  174. package/dist/version.js +2 -0
  175. package/dist/version.js.map +1 -0
  176. package/install/postinstall.mjs +3 -0
  177. package/install/windows.ps1 +161 -0
  178. package/package.json +72 -3
  179. package/skills/tg-cli/SKILL.md +489 -0
  180. package/spec/bot/LICENSE +21 -0
  181. package/spec/bot/README.md +16 -0
@@ -0,0 +1,1898 @@
1
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { format } from "node:util";
4
+ import { ChatInviteLink, FileLocation, getMarkedPeerId, Long, MtcuteError, MtPeerNotFoundError, networkMiddlewares, PeersIndex, TelegramClient, Message as TgMessage, tl, User, } from "@mtcute/node";
5
+ import { CliError, isCliError } from "@wirecat/cli-core";
6
+ import { observedCounters, pickChat, } from "@wirecat/cli-messaging";
7
+ import { commentsOf, discussionOf } from "./comments.js";
8
+ import { toCliError } from "./errors.js";
9
+ import { ruleFlags } from "./folder-rules.js";
10
+ import { formatHtml } from "./format-html.js";
11
+ import { formatMarkdown } from "./format-markdown.js";
12
+ import { answerJoinRequestOf, joinRequestsOf } from "./join-requests.js";
13
+ import { ADMIN_RIGHT_FIELDS, answerId, attachmentsOf, checkSpoiler, eventOf, GROUP_SETTINGS, groupMembersCount, peerToChat, toAccount, toAccountSession, toChat, toDeletions, toFolder, toFormatted, toGroupCard, toGroupMember, toInputMedia, toInputPoll, toInviteLink, toInvitePreview, toLinkChat, toMember, toMessage, toMessageHit, toPoll, toReactionChange, toTopic, } from "./map.js";
14
+ import { pollVotersOf } from "./poll-voters.js";
15
+ import { refuseClose, refuseVote } from "./polls.js";
16
+ import { toProfileFacts } from "./profile.js";
17
+ import { proxiedTransport } from "./proxy.js";
18
+ import { savedSenderOf, sendAsIdentities, sendAsPeer } from "./send-as.js";
19
+ import { toOfficialChannelStats, toOfficialGraph, toOfficialGroupStats } from "./stats.js";
20
+ import { openSessionStorage } from "./storage.js";
21
+ import { uploadAttachment } from "./upload.js";
22
+ /** `https://t.me/name`, `t.me/name` or `@name`, as the name alone. */
23
+ const publicName = (link) => link
24
+ .replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, "")
25
+ .replace(/^@/, "")
26
+ .replace(/[/?].*$/, "");
27
+ const SAVED = new Set(["me", "self", "saved"]);
28
+ const PHONE_CODE_RETRIES = ["PHONE_CODE_EMPTY", "PHONE_CODE_EXPIRED", "PHONE_CODE_INVALID", "PHONE_CODE_HASH_EMPTY"];
29
+ /** Telegram's own cap on a group's member list. */
30
+ const MEMBERS_MAX = 10_000;
31
+ /** Pages of 100 that `chats events` reads at most; the rest is `more`. */
32
+ const EVENT_PAGES = 10;
33
+ /**
34
+ * One Telegram account over one connection, speaking only the domain model above this line. Every
35
+ * command closes it in a `finally`: an open socket keeps Node alive, and a piped command that prints
36
+ * and never returns is a defect.
37
+ */
38
+ export class TelegramAdapter {
39
+ async formatMarkdown(text) {
40
+ return formatMarkdown(text);
41
+ }
42
+ async formatHtml(text) {
43
+ return formatHtml(text);
44
+ }
45
+ #client;
46
+ #sessionPath;
47
+ #login;
48
+ #proxyFailed;
49
+ /** Async because the runtime's SQLite module is imported on demand (cli-messaging `openCache`). */
50
+ static async open(options) {
51
+ mkdirSync(dirname(options.sessionPath), { recursive: true, mode: 0o700 });
52
+ const adapter = new TelegramAdapter(options, await openSessionStorage(options.sessionPath));
53
+ // Loads the logged-in user from the session before anything else. mtcute does it on the first
54
+ // request, but sendText reads that user before making one — measured 2026-09-27: "User info is
55
+ // not cached yet" on the first send to Saved Messages.
56
+ await adapter.#client.prepare();
57
+ return adapter;
58
+ }
59
+ constructor({ credentials, sessionPath, diagnostic, verbose = false, listen = false, catchUp = false, login, proxy, note, }, storage) {
60
+ this.#sessionPath = sessionPath;
61
+ this.#login = login;
62
+ const proxied = proxy ? proxiedTransport(proxy) : undefined;
63
+ // watch and serve outlive a proxy that is down for a moment; mtcute's own retries suit them.
64
+ this.#proxyFailed = listen ? undefined : proxied?.failed;
65
+ // mtcute's default handler writes with console.log, which is stdout — where only data may go.
66
+ const write = diagnostic ?? ((line) => process.stderr.write(`${line}\n`));
67
+ const say = note ?? write;
68
+ const { maxWait, maxRetries } = listen ? FLOOD_SLEEP.listening : FLOOD_SLEEP.oneShot;
69
+ this.#client = new TelegramClient({
70
+ ...(proxied ? { transport: proxied.transport } : {}),
71
+ apiId: credentials.id,
72
+ apiHash: credentials.hash,
73
+ storage,
74
+ // The storage manager has an exit hook of its own; the close is the command's (storage.ts `setup`).
75
+ storageOptions: { cleanup: false },
76
+ disableUpdates: !listen,
77
+ ...(listen ? { updates: { catchUp } } : {}),
78
+ logLevel: verbose ? 3 : 1,
79
+ network: {
80
+ middlewares: networkMiddlewares.basic({
81
+ floodWaiter: {
82
+ maxWait,
83
+ maxRetries,
84
+ onBeforeWait: (context, seconds) => say(`Telegram asks to wait ${seconds} s before ${context.request._} — waiting, then going on`),
85
+ },
86
+ }),
87
+ },
88
+ });
89
+ this.#client.log.mgr.handler = (_color, _level, tag, fmt, args) => write(`[${tag}] ${format(fmt, ...args)}`);
90
+ }
91
+ async login(prompts) {
92
+ return this.#call(async () => {
93
+ if (prompts.method === "phone" && prompts.forceSms)
94
+ return toAccount(await this.#loginAskingForSms(prompts));
95
+ const user = await this.#client.start({
96
+ ...(prompts.method === "qr" ? { qrCodeHandler: prompts.showQr } : { phone: prompts.phone }),
97
+ code: prompts.code,
98
+ password: prompts.password,
99
+ codeSentCallback: (sent) => prompts.note(`Telegram sent a login code (${sent.type})`),
100
+ invalidCodeCallback: (what) => prompts.note(`that ${what} was not accepted — try again`),
101
+ });
102
+ return toAccount(user);
103
+ });
104
+ }
105
+ /**
106
+ * mtcute's `start({ forceSms })` without its one failure: when Telegram has no SMS to resend
107
+ * (`SEND_CODE_UNAVAILABLE`) it aborts the login, though the code it first sent to the app still works.
108
+ */
109
+ async #loginAskingForSms(prompts) {
110
+ let needsPassword = false;
111
+ try {
112
+ return await this.#client.getMe();
113
+ }
114
+ catch (error) {
115
+ if (tl.RpcError.is(error, "SESSION_PASSWORD_NEEDED"))
116
+ needsPassword = true;
117
+ else if (!tl.RpcError.is(error, "AUTH_KEY_UNREGISTERED"))
118
+ throw error;
119
+ }
120
+ if (!needsPassword) {
121
+ const phone = await prompts.phone();
122
+ let sent;
123
+ try {
124
+ const answer = await this.#client.sendCode({ phone });
125
+ if (answer instanceof User)
126
+ return answer;
127
+ sent = answer;
128
+ }
129
+ catch (error) {
130
+ if (!tl.RpcError.is(error, "SESSION_PASSWORD_NEEDED"))
131
+ throw error;
132
+ needsPassword = true;
133
+ }
134
+ if (sent) {
135
+ if (sent.type === "app" || sent.type === "email") {
136
+ try {
137
+ sent = await this.#client.resendCode({ phone, phoneCodeHash: sent.phoneCodeHash });
138
+ }
139
+ catch (error) {
140
+ if (!tl.RpcError.is(error, "SEND_CODE_UNAVAILABLE"))
141
+ throw error;
142
+ prompts.note("Telegram offers no SMS for this account");
143
+ }
144
+ }
145
+ if (sent.type === "email_required")
146
+ throw new MtcuteError("Email login setup is required to sign in");
147
+ prompts.note(`Telegram sent a login code (${sent.type})`);
148
+ for (;;) {
149
+ try {
150
+ return await this.#client.signIn({
151
+ phone,
152
+ phoneCodeHash: sent.phoneCodeHash,
153
+ phoneCode: await prompts.code(),
154
+ });
155
+ }
156
+ catch (error) {
157
+ if (tl.RpcError.is(error, "SESSION_PASSWORD_NEEDED")) {
158
+ needsPassword = true;
159
+ break;
160
+ }
161
+ if (!PHONE_CODE_RETRIES.some((text) => tl.RpcError.is(error, text)))
162
+ throw error;
163
+ prompts.note("that code was not accepted — try again");
164
+ }
165
+ }
166
+ }
167
+ }
168
+ for (;;) {
169
+ try {
170
+ return await this.#client.checkPassword(await prompts.password());
171
+ }
172
+ catch (error) {
173
+ if (!tl.RpcError.is(error, "PASSWORD_HASH_INVALID"))
174
+ throw error;
175
+ prompts.note("that password was not accepted — try again");
176
+ }
177
+ }
178
+ }
179
+ /** The logged-in user's id from the session, without a request; `null` before a login. */
180
+ self() {
181
+ const cached = this.#client.storage.self.getCached(true);
182
+ return cached ? String(cached.userId) : null;
183
+ }
184
+ /** Only here the phone: `account show` masks it, and a login's answer is printed as it is. */
185
+ me() {
186
+ return this.#call(async () => {
187
+ const user = await this.#client.getMe();
188
+ return { ...toAccount(user), phone: user.phoneNumber };
189
+ });
190
+ }
191
+ /**
192
+ * For `doctor --online`: Telegram's clock, from `help.getConfig`'s `date` in whole seconds, and
193
+ * whether the account is frozen, from `help.getAppConfig`'s `freeze_*` fields
194
+ * (core.telegram.org/api/config, /api/auth#frozen-accounts). A frozen account still reads, so a
195
+ * working `me()` cannot tell. Both read only.
196
+ */
197
+ health() {
198
+ return this.#call(async () => {
199
+ const config = await this.#client.call({ _: "help.getConfig" });
200
+ const serverTime = config.date * 1000;
201
+ let appConfig;
202
+ try {
203
+ appConfig = await this.#client.appConfig.get();
204
+ }
205
+ catch {
206
+ // The clock is still worth reporting; the standing is then unknown, never "active".
207
+ return { serverTime, serverTimeResolutionMs: 1000, standingChecked: false };
208
+ }
209
+ const standing = frozenOf(appConfig);
210
+ return { serverTime, serverTimeResolutionMs: 1000, standingChecked: true, ...(standing ? { standing } : {}) };
211
+ });
212
+ }
213
+ /** Telegram lists dialogs by position, so a page is the dialogs up to its end, cut; `limit` unset is every one. */
214
+ chats({ limit, offset }) {
215
+ return this.#call(async () => {
216
+ const wanted = limit === undefined ? Number.POSITIVE_INFINITY : offset + limit + 1;
217
+ const items = await this.#dialogs(wanted);
218
+ const end = limit === undefined ? items.length : offset + limit;
219
+ return { items: items.slice(offset, end), hasMore: items.length > end };
220
+ });
221
+ }
222
+ discussionOf(channelId, postId) {
223
+ const post = messageNumber(postId, "a post id is a number");
224
+ return this.#call(() => discussionOf(this.#client, Number(channelId), post));
225
+ }
226
+ comments(channelId, postId, { limit, before }) {
227
+ const post = messageNumber(postId, "a post id is a number");
228
+ const offset = before === undefined ? undefined : messageNumber(before, "--before-id takes a comment id");
229
+ return this.#call(async () => {
230
+ const page = await commentsOf(this.#client, Number(channelId), post, {
231
+ limit,
232
+ ...(offset === undefined ? {} : { before: offset }),
233
+ });
234
+ return { items: page.items.map(toMessage), hasMore: page.hasMore };
235
+ });
236
+ }
237
+ /** Telegram reads a thread with messages.search and its top message id (core.telegram.org/api/threads). */
238
+ topicHistory(reference, threadId, { limit, before }) {
239
+ const thread = topicNumber(threadId);
240
+ const offset = before === undefined ? undefined : messageNumber(before, "--before-id takes a message id");
241
+ return this.#call(async () => {
242
+ const page = await this.#client.searchMessages({
243
+ chatId: await this.#inputOf(reference),
244
+ threadId: thread,
245
+ limit,
246
+ ...(offset === undefined ? {} : { offset }),
247
+ });
248
+ return { items: page.map(remoteMessage).reverse(), hasMore: page.next !== undefined };
249
+ });
250
+ }
251
+ history(reference, { limit, before }) {
252
+ return this.#call(async () => {
253
+ const peer = await this.#inputOf(reference);
254
+ const offset = before === undefined ? undefined : { id: messageNumber(before), date: 0 };
255
+ const page = await this.#client.getHistory(peer, { limit, ...(offset ? { offset } : {}) });
256
+ // mtcute drops deleted entries and the inexact-count flag. Its iterator follows next, never total.
257
+ return { items: page.map(remoteMessage).reverse(), hasMore: page.next !== undefined };
258
+ });
259
+ }
260
+ /**
261
+ * Forward from a message or a moment: `reverse` reads upwards from the offset, inclusive, so an id
262
+ * starts one past it. The filter keeps a date offset honest — Telegram places it, it does not cut at it.
263
+ */
264
+ historyAfter(reference, { limit, after }) {
265
+ const offset = "id" in after
266
+ ? { id: messageNumber(after.id, "--after-id takes a message id") + 1, date: 0 }
267
+ : { id: 0, date: Math.floor(after.time / 1000) };
268
+ const newer = (message) => "id" in after ? Number(message.id) > Number(after.id) : Date.parse(message.timestamp) > after.time;
269
+ return this.#call(async () => {
270
+ const peer = await this.#inputOf(reference);
271
+ const page = await this.#client.getHistory(peer, { limit, reverse: true, offset });
272
+ // As history(): a short page is no end, deleted entries are dropped from it; only an empty one is.
273
+ return { items: page.map(remoteMessage).filter(newer), hasMore: page.next !== undefined };
274
+ });
275
+ }
276
+ /** One person's newest messages in a chat: Telegram's search by sender, newest first, answered oldest first. */
277
+ historyFrom(reference, person, { limit }) {
278
+ return this.#call(async () => {
279
+ const [chatId, fromUser] = await Promise.all([this.#inputOf(reference), this.#inputOf(person)]);
280
+ const page = await this.#client.searchMessages({ chatId, fromUser, limit });
281
+ return { items: page.map(remoteMessage).reverse(), hasMore: page.total > page.length };
282
+ });
283
+ }
284
+ /**
285
+ * Telegram's own text search, newest first: in one chat with `chat`, in every chat otherwise. Its
286
+ * matching is undocumented, so the answer is candidates for the store's strict query.
287
+ */
288
+ searchMessages(query, { limit, signal }) {
289
+ return this.#call(async () => {
290
+ const seconds = (ms) => (ms === undefined ? 0 : Math.floor(ms / 1000));
291
+ const common = {
292
+ q: query.text,
293
+ filter: { _: "inputMessagesFilterEmpty" },
294
+ minDate: seconds(query.minDate),
295
+ maxDate: seconds(query.maxDate),
296
+ limit,
297
+ };
298
+ // The raw call, because only it takes these: a flood wait is answered at once instead of slept
299
+ // through, and the search's time bound cancels the request rather than leaving it to hold the connection.
300
+ const options = { floodSleepThreshold: 0, ...(signal ? { abortSignal: signal } : {}) };
301
+ const found = query.chat === undefined
302
+ ? await this.#client.call({
303
+ _: "messages.searchGlobal",
304
+ ...common,
305
+ offsetRate: 0,
306
+ offsetPeer: { _: "inputPeerEmpty" },
307
+ offsetId: 0,
308
+ }, options)
309
+ : await this.#client.call({
310
+ _: "messages.search",
311
+ ...common,
312
+ peer: await this.#client.resolvePeer(await this.#inputOf(query.chat)),
313
+ ...(query.from === undefined
314
+ ? {}
315
+ : { fromId: await this.#client.resolvePeer(await this.#inputOf(query.from)) }),
316
+ offsetId: 0,
317
+ addOffset: 0,
318
+ maxId: 0,
319
+ minId: 0,
320
+ hash: Long.ZERO,
321
+ }, options);
322
+ if (found._ === "messages.messagesNotModified")
323
+ return { items: [], hasMore: false, chats: [] };
324
+ const peers = PeersIndex.from(found);
325
+ const page = found.messages.filter((one) => one._ !== "messageEmpty").map((one) => new TgMessage(one, peers));
326
+ const chats = new Map(page.map((message) => [String(message.chat.id), peerToChat(message.chat)]));
327
+ return {
328
+ items: page.map((message) => remoteHit(message, "remote_fetch")),
329
+ hasMore: page.length === limit,
330
+ chats: [...chats.values()],
331
+ };
332
+ });
333
+ }
334
+ /** Back from a moment, newest first as Telegram reads, answered oldest first. The filter cuts at the moment itself. */
335
+ historyBefore(reference, { limit, time }) {
336
+ return this.#call(async () => {
337
+ const peer = await this.#inputOf(reference);
338
+ const page = await this.#client.getHistory(peer, { limit, offset: { id: 0, date: Math.floor(time / 1000) } });
339
+ const older = page.map(remoteMessage).filter((message) => Date.parse(message.timestamp) < time);
340
+ return { items: older.reverse(), hasMore: page.next !== undefined };
341
+ });
342
+ }
343
+ /** The chat as its dialog describes it, and for a group, who is in it — at most 200, Telegram's cap. */
344
+ chat(reference) {
345
+ return this.#call(async () => {
346
+ const peer = await this.#inputOf(reference);
347
+ const [dialog] = await this.#client.getPeerDialogs(peer);
348
+ const found = dialog ? dialog.peer : await this.#client.getPeer(peer);
349
+ const chat = dialog ? toChat(dialog) : peerToChat(found);
350
+ if (chat.kind !== "group")
351
+ return { ...chat, members: null };
352
+ const members = await this.#membersOf(peer);
353
+ return {
354
+ ...chat,
355
+ participantsCount: await this.#groupCount(found, members?.total ?? null),
356
+ members: members?.map((member) => toMember(member.user)) ?? null,
357
+ };
358
+ });
359
+ }
360
+ /** How many profile photos they show and the oldest one's date: the newest first, so the last page holds it. */
361
+ photos(person) {
362
+ return this.#call(async () => {
363
+ const peer = await this.#inputOf(person);
364
+ const newest = await this.#client.getProfilePhotos(peer, { limit: 1 });
365
+ if (newest.total <= 1)
366
+ return { count: newest.total, oldestAt: newest[0]?.date.toISOString() ?? null };
367
+ const [oldest] = await this.#client.getProfilePhotos(peer, { offset: newest.total - 1, limit: 1 });
368
+ return { count: newest.total, oldestAt: oldest?.date.toISOString() ?? null };
369
+ });
370
+ }
371
+ /** A person, their bio, and the groups this account shares with them — newest conversation first. */
372
+ contact(reference) {
373
+ return this.#call(async () => {
374
+ const { user, full, dialog, chats } = await this.#personOf(reference);
375
+ return {
376
+ ...toMember(user),
377
+ description: full.bio || null,
378
+ lastMessagedAt: dialog ? (toChat(dialog).lastMessageAt ?? null) : null,
379
+ chats,
380
+ };
381
+ });
382
+ }
383
+ /** The same three requests as `contact`, with everything Telegram said kept. */
384
+ profile(reference) {
385
+ return this.#call(async () => {
386
+ const { full, chats } = await this.#personOf(reference);
387
+ return toProfileFacts(full, chats);
388
+ });
389
+ }
390
+ async #personOf(reference) {
391
+ const peer = await this.#inputOf(reference);
392
+ const user = await this.#client.getPeer(peer);
393
+ if (user.type !== "user")
394
+ throw new CliError("validation_error", `"${reference}" is a chat, not a person`);
395
+ const [full, [dialog], common] = await Promise.all([
396
+ this.#client.getFullUser(peer),
397
+ this.#client.getPeerDialogs(peer),
398
+ this.#client.getCommonChats(peer),
399
+ ]);
400
+ const dialogs = common.length > 0 ? await this.#client.getPeerDialogs(common.map((chat) => chat.id)) : [];
401
+ // Telegram's common chats are groups only; the one-to-one chat is shared with them too.
402
+ const chats = [dialog ?? null, ...dialogs]
403
+ .filter((one) => one !== null)
404
+ .map(toChat)
405
+ .map(({ id, title, kind, lastMessageAt }) => ({ id, title, kind, lastMessageAt }))
406
+ .sort((a, b) => (b.lastMessageAt ?? "").localeCompare(a.lastMessageAt ?? ""));
407
+ return { user, full, dialog, chats };
408
+ }
409
+ /**
410
+ * One request: history from just above the message, shifted `after` messages newer. Telegram's
411
+ * offset id is exclusive, hence the `+ 1`; ids are not contiguous, so the window is cut by position.
412
+ */
413
+ fetchCounters(reference, messageId, fields, signal) {
414
+ const id = messageNumber(messageId);
415
+ return this.#call(async () => {
416
+ signal?.throwIfAborted();
417
+ const [found] = await this.#client.getMessages(await this.#inputOf(reference), [id]);
418
+ signal?.throwIfAborted();
419
+ return found
420
+ ? (observedCounters(toMessage(found), new Date().toISOString(), fields).counterObservations ?? {})
421
+ : {};
422
+ });
423
+ }
424
+ around(reference, messageId, { before, after }) {
425
+ const id = messageNumber(messageId, "a message id is a number");
426
+ return this.#call(async () => {
427
+ const peer = await this.#inputOf(reference);
428
+ const page = await this.#client.getHistory(peer, {
429
+ offset: { id: id + 1, date: 0 },
430
+ addOffset: -after,
431
+ limit: before + 1 + after,
432
+ });
433
+ const items = page.map(remoteMessage).reverse();
434
+ const index = items.findIndex((message) => message.id === String(id));
435
+ if (index < 0)
436
+ throw new CliError("not_found", `no message ${id} in that chat`);
437
+ return items
438
+ .slice(Math.max(0, index - before), index + after + 1)
439
+ .map((message) => (message.id === String(id) ? { ...message, anchor: true } : message));
440
+ });
441
+ }
442
+ /** The chat a reference names — by title, id, `@username` or `me` — so a write can be checked before it goes. */
443
+ resolve(reference) {
444
+ return this.#call(async () => {
445
+ const peer = await this.#peerOf(reference);
446
+ return typeof peer === "object" && "kind" in peer ? peer : peerToChat(await this.#client.getPeer(peer));
447
+ });
448
+ }
449
+ sendAsIdentities(chatId) {
450
+ return this.#call(() => sendAsIdentities(this.#client, chatId));
451
+ }
452
+ savedSender(chatId) {
453
+ return this.#call(() => savedSenderOf(this.#client, chatId, this.self()));
454
+ }
455
+ permalink(chatId, messageId) {
456
+ const id = messageNumber(messageId, "a message id is a positive Telegram integer");
457
+ if (id <= 0 || id > 2147483647)
458
+ throw new CliError("validation_error", "a message id is a positive Telegram integer");
459
+ return this.#call(async () => {
460
+ const input = await this.#inputOf(chatId);
461
+ const [found] = await this.#client.getMessages(input, [id]);
462
+ if (!found || found.id !== id)
463
+ throw new CliError("not_found", "that message no longer exists in this chat");
464
+ const peer = await this.#client.getPeer(input);
465
+ if (peer.type !== "chat" || peer.raw._ !== "channel")
466
+ return { url: null, access: "unavailable", reason: "unsupported_chat" };
467
+ const { link } = await this.#client.call({
468
+ _: "channels.exportMessageLink",
469
+ channel: await this.#client.resolveChannel(input),
470
+ id,
471
+ thread: true,
472
+ });
473
+ const url = new URL(link);
474
+ const known = ["t.me", "telegram.me", "telegram.dog"].includes(url.hostname);
475
+ const access = !known
476
+ ? "unknown"
477
+ : url.pathname.startsWith("/c/")
478
+ ? "restricted"
479
+ : "public";
480
+ return { url: link, access, reason: null };
481
+ });
482
+ }
483
+ /**
484
+ * One logical send carries one `random_id`, made before the request and repeated by a retry:
485
+ * Telegram delivers one message for both (measured 2026-09-27, across two connections).
486
+ */
487
+ send(chatId, text, { sendId, replyTo, threadId, silent, noPreview, markup, formatting, at, attachments = [], sendAs, spoiler, captionAbove, }) {
488
+ const id = parseSendId(sendId);
489
+ const thread = threadId === undefined ? undefined : topicNumber(threadId);
490
+ const answering = replyTo === undefined ? undefined : messageNumber(replyTo, "a message id is a number");
491
+ const author = sendAs === undefined ? undefined : sendAsPeer(chatId, sendAs, this.self());
492
+ if (attachments.length > 1)
493
+ throw new CliError("validation_error", "tg sends one file or photo per message");
494
+ const spans = formatting ?? markup;
495
+ const body = spans ? toFormatted(text, spans) : text;
496
+ const common = {
497
+ randomId: id,
498
+ ...(thread === undefined || thread === 1 ? {} : { threadId: thread }),
499
+ ...(answering === undefined ? {} : { replyTo: answering }),
500
+ ...(silent ? { silent } : {}),
501
+ ...(at === undefined ? {} : { schedule: new Date(at) }),
502
+ ...(author === undefined ? {} : { sendAs: author }),
503
+ };
504
+ return this.#call(async () => {
505
+ const [attachment] = attachments;
506
+ if (spoiler && attachment)
507
+ checkSpoiler(attachment);
508
+ const uploaded = attachment ? await uploadAttachment(this.#client, attachment) : undefined;
509
+ try {
510
+ const message = attachment
511
+ ? await this.#client.sendMedia(Number(chatId), toInputMedia(attachment, body, uploaded, { spoiler }), {
512
+ ...common,
513
+ ...(captionAbove ? { invert: true } : {}),
514
+ })
515
+ : await this.#client.sendText(Number(chatId), body, {
516
+ ...common,
517
+ ...(noPreview ? { disableWebPreview: true } : {}),
518
+ });
519
+ return { message: toMessage(message), sendId };
520
+ }
521
+ catch (error) {
522
+ throw unknownIfUnanswered(error, `the message may have been sent. Repeat with ${repeatWith(sendId, sendAs)}, never without it`, retryDetails(sendId, sendAs));
523
+ }
524
+ });
525
+ }
526
+ /**
527
+ * New messages, edits, deletions and reaction changes as they arrive, until `signal` aborts. Only
528
+ * on an adapter opened with `listen`. `onReady` once the updates loop runs, not before.
529
+ */
530
+ async watch(onEvent, signal, onReady) {
531
+ const client = this.#client;
532
+ const message = (found) => onEvent({ event: "message", message: remoteHit(found, "remote_update") });
533
+ const edit = (found) => onEvent({ event: "edit", message: remoteHit(found, "remote_update") });
534
+ const deletion = (update) => {
535
+ for (const change of toDeletions(update))
536
+ onEvent(change);
537
+ };
538
+ const raw = (info) => {
539
+ const change = toReactionChange(info);
540
+ if (change?.event === "reaction")
541
+ onEvent({
542
+ ...change,
543
+ counterObservation: {
544
+ value: change.reactions.total,
545
+ observedAt: new Date().toISOString(),
546
+ source: "remote_update",
547
+ reactions: change.reactions,
548
+ },
549
+ });
550
+ else if (change)
551
+ onEvent(change);
552
+ };
553
+ client.onNewMessage.add(message);
554
+ client.onEditMessage.add(edit);
555
+ client.onDeleteMessage.add(deletion);
556
+ client.onRawUpdate.add(raw);
557
+ try {
558
+ await this.#call(async () => {
559
+ await client.connect();
560
+ // With catchUp, mtcute fetches the updates state in the background and, on a revoked login,
561
+ // quietly stops its loop — serve would sit "connected" forever. Asking first makes it fail here.
562
+ await client.call({ _: "updates.getState" });
563
+ await client.startUpdatesLoop();
564
+ });
565
+ onReady?.();
566
+ await this.#untilStopped(signal);
567
+ }
568
+ finally {
569
+ client.onNewMessage.remove(message);
570
+ client.onEditMessage.remove(edit);
571
+ client.onDeleteMessage.remove(deletion);
572
+ client.onRawUpdate.remove(raw);
573
+ }
574
+ }
575
+ /**
576
+ * Until `signal` aborts, or the login or mtcute's updates loop is found gone. mtcute stops the loop
577
+ * without a word on AUTH_KEY_UNREGISTERED (`highlevel/updates/manager.js`, `_fetchUpdatesState` and
578
+ * `_fetchDifferenceLater`), usually met by its own 15-minute keep-alive; the heartbeat asks itself
579
+ * at the same rate, in case that path never runs. A refused login ends the watch with exit 4; a
580
+ * stopped loop otherwise with exit 12, which a service unit restarts — never a process that looks
581
+ * connected and receives nothing. A heartbeat that fails for any other reason is let pass.
582
+ */
583
+ async #untilStopped(signal) {
584
+ const updates = this.#client._client
585
+ ?.updates;
586
+ for (let tick = 1;; tick += 1) {
587
+ await pause(LOOP_CHECK_MS, signal);
588
+ if (signal.aborted)
589
+ return;
590
+ const down = updates?.updatesLoopActive === false;
591
+ if (!down && tick % HEARTBEAT_TICKS !== 0)
592
+ continue;
593
+ const refusal = await this.#askState();
594
+ if (signal.aborted)
595
+ return;
596
+ if (refusal?.code === "authentication_error")
597
+ throw refusal;
598
+ if (down)
599
+ throw new CliError("provider_unavailable", LOOP_STOPPED, refusal ? { cause: refusal.code } : {});
600
+ }
601
+ }
602
+ /** `updates.getState` under a timer of its own: a request that never answers must not hold the watch. */
603
+ async #askState() {
604
+ let timer;
605
+ const late = new Promise((resolve) => {
606
+ timer = setTimeout(() => resolve(new CliError("timeout", "Telegram did not answer in time")), STATE_WAIT_MS);
607
+ });
608
+ const asked = this.#call(() => this.#client.call({ _: "updates.getState" })).then(() => undefined, (error) => (isCliError(error) ? error : new CliError("provider_error", "Telegram failed")));
609
+ try {
610
+ return await Promise.race([asked, late]);
611
+ }
612
+ finally {
613
+ clearTimeout(timer);
614
+ }
615
+ }
616
+ scheduled(reference) {
617
+ return this.#call(async () => {
618
+ const queued = await this.#client.getAllScheduledMessages(await this.#inputOf(reference));
619
+ return queued.map(toMessage).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
620
+ });
621
+ }
622
+ /** The message is fetched again, never taken from the store: Telegram's file references expire. */
623
+ download(reference, messageId) {
624
+ const id = messageNumber(messageId, "a message id is a number");
625
+ return this.#call(async () => {
626
+ const [found] = await this.#client.getMessages(await this.#inputOf(reference), id);
627
+ if (!found)
628
+ throw new CliError("not_found", `no message ${id} in that chat`);
629
+ const media = found.media;
630
+ if (!media)
631
+ return { files: [], skipped: [] };
632
+ if (!(media instanceof FileLocation))
633
+ return { files: [], skipped: [media.type] };
634
+ const [{ kind, name, mime, size }] = attachmentsOf(media);
635
+ const client = this.#client;
636
+ const login = this.#login;
637
+ async function* bytes() {
638
+ try {
639
+ yield* client.downloadAsIterable(media);
640
+ }
641
+ catch (error) {
642
+ throw toCliError(error, login);
643
+ }
644
+ }
645
+ return { files: [{ kind, name, mime, size, bytes }], skipped: [] };
646
+ });
647
+ }
648
+ /**
649
+ * Telegram's own speech recognition; mtcute has no high-level method for `messages.transcribeAudio`.
650
+ * A first answer is usually still pending. The finished text arrives as an update, which a one-shot
651
+ * connection does not receive, but asking again returns it — measured 2026-09-29 on a 19 s voice note.
652
+ */
653
+ transcribe(reference, messageId) {
654
+ const id = messageNumber(messageId, "a message id is a number");
655
+ return this.#call(async () => {
656
+ const peer = await this.#client.resolvePeer(await this.#inputOf(reference));
657
+ const deadline = Date.now() + TRANSCRIBE_WAIT_MS;
658
+ for (;;) {
659
+ const answer = await this.#client.call({ _: "messages.transcribeAudio", peer, msgId: id });
660
+ if (answer.pending !== true || Date.now() >= deadline) {
661
+ return { text: answer.text, pending: answer.pending === true };
662
+ }
663
+ await sleep(TRANSCRIBE_POLL_MS);
664
+ }
665
+ });
666
+ }
667
+ /** Forgets the session on Telegram's side too, so the device disappears from the account's list. */
668
+ logout() {
669
+ return this.#call(async () => {
670
+ await this.#client.logOut();
671
+ });
672
+ }
673
+ async close() {
674
+ await this.#client.destroy();
675
+ for (const suffix of ["", "-wal", "-shm"]) {
676
+ const path = `${this.#sessionPath}${suffix}`;
677
+ if (existsSync(path))
678
+ chmodSync(path, 0o600);
679
+ }
680
+ }
681
+ /**
682
+ * An edit has no `random_id`, but setting the same text twice is harmless: Telegram answers the
683
+ * repeat with MESSAGE_NOT_MODIFIED, taken here as done — so a retry after an unknown outcome is safe.
684
+ */
685
+ edit(chatId, messageId, text, { markup, formatting } = {}) {
686
+ const id = messageNumber(messageId, "a message id is a number");
687
+ const spans = formatting ?? markup;
688
+ const body = spans ? toFormatted(text, spans) : text;
689
+ return this.#call(async () => {
690
+ try {
691
+ return toMessage(await this.#client.editMessage({ chatId: Number(chatId), message: id, text: body }));
692
+ }
693
+ catch (error) {
694
+ if (tl.RpcError.is(error, "MESSAGE_NOT_MODIFIED")) {
695
+ const [current] = await this.#client.getMessages(Number(chatId), [id]);
696
+ if (current)
697
+ return toMessage(current);
698
+ }
699
+ throw unknownIfUnanswered(error, "the edit may have been made — repeating it is safe");
700
+ }
701
+ });
702
+ }
703
+ /**
704
+ * The raw call, because mtcute's `forwardMessagesById` draws the `random_id` itself: a retry has to
705
+ * repeat the first one for Telegram to keep one copy, as a send does.
706
+ */
707
+ forward(fromChatId, messageId, toChatId, { sendId, silent, sendAs, threadId }) {
708
+ const id = messageNumber(messageId, "a message id is a number");
709
+ const randomId = parseSendId(sendId);
710
+ const thread = threadId === undefined ? undefined : topicNumber(threadId);
711
+ const author = sendAs === undefined ? undefined : sendAsPeer(toChatId, sendAs, this.self());
712
+ return this.#call(async () => {
713
+ try {
714
+ const updates = await this.#client.call({
715
+ _: "messages.forwardMessages",
716
+ fromPeer: await this.#client.resolvePeer(Number(fromChatId)),
717
+ toPeer: await this.#client.resolvePeer(Number(toChatId)),
718
+ id: [id],
719
+ randomId: [randomId],
720
+ ...(silent ? { silent } : {}),
721
+ ...(thread === undefined || thread === 1 ? {} : { topMsgId: thread }),
722
+ ...(author === undefined ? {} : { sendAs: await this.#client.resolvePeer(author) }),
723
+ });
724
+ this.#client.handleClientUpdate(updates, true);
725
+ const copy = forwardedCopy(updates);
726
+ if (!copy)
727
+ throw new CliError("provider_error", "Telegram answered the forward without the new message");
728
+ return toMessage(copy);
729
+ }
730
+ catch (error) {
731
+ throw unknownIfUnanswered(error, `the message may have been forwarded. Repeat with ${repeatWith(sendId, sendAs)}, never without it`, retryDetails(sendId, sendAs));
732
+ }
733
+ });
734
+ }
735
+ /** In a one-to-one chat the pin is on the owner's side only; `notify` reaches groups alone, as Telegram has it. */
736
+ pin(chatId, messageId, { notify }) {
737
+ const id = messageNumber(messageId, "a message id is a number");
738
+ return this.#write("the pin may have been made — repeating it is safe", async () => {
739
+ await this.#client.pinMessage({ chatId: Number(chatId), message: id, notify });
740
+ });
741
+ }
742
+ unpin(chatId, messageId) {
743
+ const id = messageNumber(messageId, "a message id is a number");
744
+ return this.#write("the message may have been unpinned — repeating it is safe", async () => {
745
+ await this.#client.unpinMessage({ chatId: Number(chatId), message: id });
746
+ });
747
+ }
748
+ /**
749
+ * Telegram sets the owner's reactions as a whole, so one emoji replaces what was there. An emoji the
750
+ * chat does not allow, or a second one without Premium, comes back as Telegram's refusal.
751
+ */
752
+ react(chatId, messageId, emoji) {
753
+ const id = messageNumber(messageId, "a message id is a number");
754
+ return this.#write("the reaction may have been set — repeating it is safe", async () => {
755
+ await this.#client.sendReaction({ chatId: Number(chatId), message: id, emoji });
756
+ });
757
+ }
758
+ /** Up to `until`, or everything; mentions stay, as Telegram's own clients leave them until they are seen. */
759
+ markRead(chatId, until) {
760
+ const maxId = until === undefined ? undefined : messageNumber(until, "--until takes a message id");
761
+ return this.#write("the chat may have been marked read — repeating it is safe", async () => {
762
+ await this.#client.readHistory(Number(chatId), maxId === undefined ? {} : { maxId });
763
+ });
764
+ }
765
+ /** Telegram reads a topic as a discussion thread, up to a message id: without `until`, the topic's newest. */
766
+ markTopicRead(chatId, topicId, until) {
767
+ const id = topicNumber(topicId);
768
+ const maxId = until === undefined ? undefined : messageNumber(until, "--until takes a message id");
769
+ return this.#write("the topic may have been marked read — repeating it is safe", async () => {
770
+ let readMaxId = maxId;
771
+ if (readMaxId === undefined) {
772
+ const [topic] = await this.#client.getForumTopicsById(Number(chatId), id);
773
+ if (!topic)
774
+ throw new CliError("not_found", `no topic ${topicId} in that chat`);
775
+ readMaxId = topic.lastMessage.id;
776
+ }
777
+ await this.#client.call({
778
+ _: "messages.readDiscussion",
779
+ peer: await this.#client.resolvePeer(Number(chatId)),
780
+ msgId: id,
781
+ readMaxId,
782
+ });
783
+ });
784
+ }
785
+ /**
786
+ * mtcute deletes for everyone unless told otherwise, so `revoke` is always passed. In a supergroup or
787
+ * a channel Telegram has no "for me": a deletion there is for everyone, and without `forEveryone` it is refused.
788
+ *
789
+ * **Outside a channel the ids are checked against the chat first.** Telegram numbers private-chat and
790
+ * basic-group messages per account and `messages.deleteMessages` takes no chat, so an id from another
791
+ * chat — or the other side's number for the same message — deletes whatever this account has under it
792
+ * (SEC-30). `getMessages` answers null for an id that is not in the chat it was given.
793
+ */
794
+ async delete(chatId, messageIds, { forEveryone }) {
795
+ const ids = messageIds.map((id) => messageNumber(id, "a message id is a number"));
796
+ const peer = await this.#call(async () => {
797
+ const peer = await this.#client.resolvePeer(Number(chatId));
798
+ if (peer._ === "inputPeerChannel") {
799
+ if (!forEveryone) {
800
+ throw new CliError("validation_error", "in a supergroup or a channel Telegram deletes for everyone — add --for-everyone if that is what you want");
801
+ }
802
+ return peer;
803
+ }
804
+ const found = await this.#client.getMessages(peer, ids);
805
+ const missing = ids.filter((_, index) => found[index] == null);
806
+ if (missing.length > 0) {
807
+ throw new CliError("validation_error", `no message ${missing.join(", ")} in chat ${chatId} — not in this chat, or already deleted; nothing was deleted`);
808
+ }
809
+ return peer;
810
+ });
811
+ return this.#write("the messages may have been deleted — repeating it is safe", async () => {
812
+ await this.#client.deleteMessagesById(peer, ids, { revoke: forEveryone });
813
+ });
814
+ }
815
+ poll(chatId, messageId) {
816
+ return this.#call(async () => toPoll(chatId, messageId, await this.#pollOf(chatId, messageId)));
817
+ }
818
+ pollVoters(chatId, messageId, window) {
819
+ const id = messageNumber(messageId, "a message id is a number");
820
+ return this.#call(() => pollVotersOf(this.#client, Number(chatId), id, window));
821
+ }
822
+ /** Votes by the answers' own bytes, never by index: mtcute would fetch the poll and pick by position. */
823
+ vote(chatId, messageId, answerIds) {
824
+ const id = messageNumber(messageId, "a message id is a number");
825
+ return this.#call(async () => {
826
+ const current = await this.#pollOf(chatId, messageId);
827
+ const known = new Map(current.answers.map((answer) => [answerId(answer.data), answer.data]));
828
+ const unknown = answerIds.filter((answer) => !known.has(answer));
829
+ if (unknown.length > 0) {
830
+ throw new CliError("validation_error", `${unknown.join(", ")} ${unknown.length === 1 ? "is" : "are"} not an answer of this poll — its answers are ${[...known.keys()].join(", ")}`);
831
+ }
832
+ refuseVote(current, answerIds);
833
+ const options = answerIds.length === 0 ? null : answerIds.map((answer) => known.get(answer));
834
+ try {
835
+ return toPoll(chatId, messageId, await this.#client.sendVote({ chatId: Number(chatId), message: id, options }));
836
+ }
837
+ catch (error) {
838
+ throw unknownIfUnanswered(error, "the vote may have been cast — repeating it is safe");
839
+ }
840
+ });
841
+ }
842
+ closePoll(chatId, messageId) {
843
+ const id = messageNumber(messageId, "a message id is a number");
844
+ return this.#call(async () => {
845
+ refuseClose(await this.#pollOf(chatId, messageId));
846
+ try {
847
+ return toPoll(chatId, messageId, await this.#client.closePoll({ chatId: Number(chatId), message: id }));
848
+ }
849
+ catch (error) {
850
+ throw unknownIfUnanswered(error, "the poll may have been closed; check `tg polls show` before repeating");
851
+ }
852
+ });
853
+ }
854
+ /**
855
+ * One `random_id` per logical create, as a send has: a retry repeats it and Telegram keeps one poll
856
+ * (measured 2026-10-08: the second call answered the first poll's message id — unlike topic creation).
857
+ */
858
+ createPoll(chatId, poll, { sendId, silent, threadId, sendAs }) {
859
+ const randomId = parseSendId(sendId);
860
+ const thread = threadId === undefined ? undefined : topicNumber(threadId);
861
+ const author = sendAs === undefined ? undefined : sendAsPeer(chatId, sendAs, this.self());
862
+ return this.#call(async () => {
863
+ try {
864
+ const message = await this.#client.sendMedia(Number(chatId), toInputPoll(poll), {
865
+ randomId,
866
+ ...(thread === undefined || thread === 1 ? {} : { threadId: thread }),
867
+ ...(silent ? { silent } : {}),
868
+ ...(author === undefined ? {} : { sendAs: author }),
869
+ });
870
+ return { message: toMessage(message), sendId };
871
+ }
872
+ catch (error) {
873
+ throw unknownIfUnanswered(error, `the poll may have been sent. Repeat with ${repeatWith(sendId, sendAs)}, never without it`, retryDetails(sendId, sendAs));
874
+ }
875
+ });
876
+ }
877
+ async #pollOf(chatId, messageId) {
878
+ const id = messageNumber(messageId, "a message id is a number");
879
+ const [found] = await this.#client.getMessages(Number(chatId), [id]);
880
+ if (!found)
881
+ throw new CliError("not_found", `no message ${id} in that chat`);
882
+ if (found.media?.type !== "poll")
883
+ throw new CliError("not_found", `message ${id} carries no poll`);
884
+ return found.media;
885
+ }
886
+ /** A name is matched against the dialogs and answered as the chat it found; anything else goes to Telegram as it is. */
887
+ async #peerOf(reference) {
888
+ const trimmed = reference.trim();
889
+ if (SAVED.has(trimmed.toLowerCase()))
890
+ return "me";
891
+ if (/^-?\d+$/.test(trimmed))
892
+ return Number(trimmed);
893
+ if (trimmed.startsWith("@"))
894
+ return trimmed.slice(1);
895
+ return pickChat(trimmed, await this.#dialogs(Number.POSITIVE_INFINITY));
896
+ }
897
+ /**
898
+ * Each chat once, up to `wanted`. With archived chats kept, Telegram's dialog pages bring the
899
+ * pinned chats again further down: 8 of 1361 were listed twice on 2026-10-01, and a pinned chat's
900
+ * title then matched itself as two chats.
901
+ */
902
+ async #dialogs(wanted) {
903
+ const seen = new Set();
904
+ const chats = [];
905
+ for await (const dialog of this.#client.iterDialogs({ archived: "keep" })) {
906
+ const chat = toChat(dialog);
907
+ if (seen.has(chat.id))
908
+ continue;
909
+ seen.add(chat.id);
910
+ chats.push(chat);
911
+ if (chats.length >= wanted)
912
+ break;
913
+ }
914
+ return chats;
915
+ }
916
+ async #inputOf(reference) {
917
+ const peer = await this.#peerOf(reference);
918
+ return typeof peer === "object" && "kind" in peer ? Number(peer.id) : peer;
919
+ }
920
+ /** An invite is previewed; one the owner already joined, or a public link, is read as the chat. Nothing joins. */
921
+ inspect(link) {
922
+ const typed = link.trim();
923
+ const invite = /(t\.me|telegram\.me)\/(\+|joinchat\/)|^tg:\/\/join/.test(typed);
924
+ return this.#call(async () => {
925
+ if (invite) {
926
+ try {
927
+ return toInvitePreview(await this.#client.getChatPreview(typed));
928
+ }
929
+ catch (error) {
930
+ if (!(error instanceof MtPeerNotFoundError))
931
+ throw error;
932
+ }
933
+ }
934
+ return toLinkChat(await this.#client.getFullChat(invite ? typed : publicName(typed)));
935
+ });
936
+ }
937
+ forumState(chatId) {
938
+ return this.#call(() => this.#forumState(chatId));
939
+ }
940
+ async #forumState(chatId) {
941
+ let full = await this.#client.getFullChat(Number(chatId));
942
+ if (full.migratedToId != null)
943
+ full = await this.#client.getFullChat(full.migratedToId);
944
+ if (full.chatType !== "group" && full.chatType !== "supergroup") {
945
+ throw new CliError("validation_error", "forum topics require a group, not a channel or dialog");
946
+ }
947
+ return {
948
+ chat: peerToChat(full),
949
+ forum: full.isForum,
950
+ needsUpgrade: full.chatType === "group",
951
+ owner: full.isCreator,
952
+ linkedDiscussion: full.linkedChat !== null,
953
+ canCreate: full.isCreator ||
954
+ full.adminRights?.manageTopics === true ||
955
+ full.permissions?.canManageTopics === true ||
956
+ full.defaultPermissions?.canManageTopics === true,
957
+ };
958
+ }
959
+ upgradeForum(chatId) {
960
+ return this.#call(async () => {
961
+ const state = await this.#forumState(chatId);
962
+ if (!state.owner)
963
+ throw new CliError("permission_error", "only the owner can prepare this group for topics");
964
+ if (!state.needsUpgrade)
965
+ return state;
966
+ const peer = await this.#client.resolvePeer(Number(state.chat.id));
967
+ if (peer._ !== "inputPeerChat")
968
+ throw new CliError("validation_error", "only a basic group can be upgraded");
969
+ let accepted = false;
970
+ let migratedChatId;
971
+ try {
972
+ const updates = await this.#client.call({ _: "messages.migrateChat", chatId: peer.chatId }, { maxRetryCount: 0, floodSleepThreshold: 0 });
973
+ accepted = true;
974
+ this.#client.handleClientUpdate(updates, true);
975
+ const made = updates._ === "updates" || updates._ === "updatesCombined"
976
+ ? updates.chats.find((chat) => chat._ === "channel" && chat.megagroup)
977
+ : undefined;
978
+ if (made?._ !== "channel")
979
+ throw new CliError("outcome_unknown", "the group may have been upgraded; check its current chat id before repeating");
980
+ migratedChatId = String(getMarkedPeerId({ _: "peerChannel", channelId: made.id }));
981
+ return await this.#forumState(migratedChatId);
982
+ }
983
+ catch (error) {
984
+ if (accepted)
985
+ throw new CliError("outcome_unknown", "the upgrade was accepted but its state could not be read; check the current group before continuing", {
986
+ previousChatId: state.chat.id,
987
+ ...(migratedChatId === undefined ? {} : { chatId: migratedChatId, upgraded: true }),
988
+ stage: "upgrade",
989
+ });
990
+ throw unknownIfUnanswered(error, "the group may have been upgraded; check its current chat id before repeating");
991
+ }
992
+ });
993
+ }
994
+ enableForum(chatId) {
995
+ return this.#call(async () => {
996
+ const state = await this.#forumState(chatId);
997
+ if (!state.owner)
998
+ throw new CliError("permission_error", "only the group owner can enable forum topics");
999
+ if (state.needsUpgrade)
1000
+ throw new CliError("validation_error", "upgrade the basic group explicitly before enabling topics");
1001
+ if (state.linkedDiscussion)
1002
+ throw new CliError("validation_error", "a linked discussion group cannot enable forum topics");
1003
+ if (state.forum)
1004
+ return state;
1005
+ try {
1006
+ const full = await this.#client.getFullChat(Number(state.chat.id));
1007
+ await this.#client.updateForumSettings(Number(state.chat.id), {
1008
+ isForum: true,
1009
+ threadsMode: full.raw._ === "channel" && full.raw.forumTabs ? "tabs" : "list",
1010
+ });
1011
+ return await this.#forumState(state.chat.id);
1012
+ }
1013
+ catch (error) {
1014
+ if (tl.RpcError.is(error, "CHAT_NOT_MODIFIED"))
1015
+ return this.#forumState(state.chat.id);
1016
+ throw unknownIfUnanswered(error, "topics may have been enabled; check the forum state before repeating");
1017
+ }
1018
+ });
1019
+ }
1020
+ createTopic(chatId, title, { sendId }) {
1021
+ const randomId = parseSendId(sendId);
1022
+ return this.#call(async () => {
1023
+ const state = await this.#forumState(chatId);
1024
+ if (!state.forum || state.needsUpgrade)
1025
+ throw new CliError("validation_error", "enable topics before creating a topic");
1026
+ if (!state.canCreate)
1027
+ throw new CliError("permission_error", "creating a topic requires manage-topics permission");
1028
+ let accepted = false;
1029
+ try {
1030
+ const updates = await this.#client.call({
1031
+ _: "messages.createForumTopic",
1032
+ peer: await this.#client.resolvePeer(Number(state.chat.id)),
1033
+ title,
1034
+ randomId,
1035
+ }, { maxRetryCount: 0, floodSleepThreshold: 0 });
1036
+ accepted = true;
1037
+ this.#client.handleClientUpdate(updates, true);
1038
+ const message = forwardedCopy(updates);
1039
+ const mapped = updates._ === "updates" || updates._ === "updatesCombined"
1040
+ ? updates.updates.find((update) => update._ === "updateMessageID" && String(update.randomId) === String(randomId))
1041
+ : undefined;
1042
+ const topicId = mapped && mapped._ === "updateMessageID" ? mapped.id : message?.id;
1043
+ if (topicId === undefined)
1044
+ throw new CliError("outcome_unknown", "Telegram did not return the topic id; check topics list and do not repeat this creation", { sendId, retryable: false });
1045
+ const [topic] = await this.#client.getForumTopicsById(Number(state.chat.id), topicId);
1046
+ if (!topic)
1047
+ throw new CliError("outcome_unknown", "the topic may exist but could not be read; check topics list and do not repeat this creation", { sendId, retryable: false });
1048
+ return toTopic(topic);
1049
+ }
1050
+ catch (error) {
1051
+ if (accepted)
1052
+ throw new CliError("outcome_unknown", "topic creation was accepted but its result could not be read; check topics list and do not repeat", { sendId, retryable: false });
1053
+ throw unknownIfUnanswered(error, `the topic may have been created; check topics list and do not repeat this creation`, { sendId, retryable: false });
1054
+ }
1055
+ });
1056
+ }
1057
+ /**
1058
+ * A repeat answers TOPIC_NOT_MODIFIED: the change is already there, so the topic is read back as for a first.
1059
+ * Pinning is Telegram's own call, made after the edit.
1060
+ */
1061
+ editTopic(chatId, topicId, { title, closed, pinned, hidden }) {
1062
+ const id = topicNumber(topicId);
1063
+ if (hidden !== undefined && id !== 1)
1064
+ throw new CliError("validation_error", "only the General topic (id 1) can be hidden");
1065
+ return this.#call(async () => {
1066
+ const unchanged = (error) => {
1067
+ if (!tl.RpcError.is(error, "TOPIC_NOT_MODIFIED") && !tl.RpcError.is(error, "PINNED_TOPIC_NOT_MODIFIED"))
1068
+ throw unknownIfUnanswered(error, "the topic may have changed — repeating it is safe");
1069
+ };
1070
+ if (title !== undefined || closed !== undefined) {
1071
+ await this.#client
1072
+ .editForumTopic({
1073
+ chatId: Number(chatId),
1074
+ topicId: id,
1075
+ ...(title === undefined ? {} : { title }),
1076
+ ...(closed === undefined ? {} : { closed }),
1077
+ })
1078
+ .catch(unchanged);
1079
+ }
1080
+ if (pinned !== undefined)
1081
+ await this.#client.toggleForumTopicPinned({ chatId: Number(chatId), topicId: id, pinned }).catch(unchanged);
1082
+ if (hidden !== undefined)
1083
+ await this.#client.toggleGeneralTopicHidden({ chatId: Number(chatId), hidden }).catch(unchanged);
1084
+ const [topic] = await this.#client.getForumTopicsById(Number(chatId), id);
1085
+ if (!topic)
1086
+ throw new CliError("not_found", `no topic ${topicId} in that chat`);
1087
+ // Measured 2026-10-04: a read right after the edit can still show the title and state of edits ago.
1088
+ return {
1089
+ ...toTopic(topic),
1090
+ ...(title === undefined ? {} : { title }),
1091
+ ...(closed === undefined ? {} : { closed }),
1092
+ ...(pinned === undefined ? {} : { pinned }),
1093
+ ...(hidden === undefined ? {} : { hidden }),
1094
+ };
1095
+ });
1096
+ }
1097
+ /** Without `force` Telegram only reorders: a topic that is not pinned stays as it is. */
1098
+ orderPinnedTopics(chatId, topicIds) {
1099
+ const order = topicIds.map(topicNumber);
1100
+ return this.#call(async () => {
1101
+ try {
1102
+ await this.#client.reorderPinnedForumTopics({ chatId: Number(chatId), order });
1103
+ }
1104
+ catch (error) {
1105
+ throw unknownIfUnanswered(error, "the pinned topics may have been reordered — repeating it is safe");
1106
+ }
1107
+ });
1108
+ }
1109
+ /** Telegram deletes the topic's history with it; a topic that is gone answers TOPIC_ID_INVALID, `not_found`. */
1110
+ deleteTopic(chatId, topicId) {
1111
+ const topic = topicNumber(topicId);
1112
+ return this.#call(async () => {
1113
+ try {
1114
+ await this.#client.deleteForumTopicHistory(Number(chatId), topic);
1115
+ }
1116
+ catch (error) {
1117
+ throw unknownIfUnanswered(error, "the topic may have been deleted; check `tg topics list` before repeating");
1118
+ }
1119
+ });
1120
+ }
1121
+ async validateThread(chatId, threadId, { replyTo }) {
1122
+ const topicId = topicNumber(threadId);
1123
+ const replyId = replyTo === undefined ? undefined : messageNumber(replyTo, "--reply-to needs a message id");
1124
+ return this.#call(async () => {
1125
+ const peer = await this.#client.getPeer(Number(chatId));
1126
+ if (peer.type !== "chat" || !peer.isForum) {
1127
+ throw new CliError("validation_error", "--topic requires a Telegram forum group");
1128
+ }
1129
+ const [topic] = await this.#client.getForumTopicsById(Number(chatId), topicId);
1130
+ if (!topic)
1131
+ throw new CliError("not_found", "that forum topic does not exist; check `topics list`");
1132
+ if (topic.isClosed)
1133
+ throw new CliError("permission_error", "that forum topic is closed; choose an open topic");
1134
+ if (replyId !== undefined) {
1135
+ const [reply] = await this.#client.getMessages(Number(chatId), [replyId]);
1136
+ if (!reply)
1137
+ throw new CliError("not_found", "the message to reply to no longer exists in this chat");
1138
+ const replyThread = reply.isTopicMessage ? (reply.replyToMessage?.threadId ?? reply.id) : 1;
1139
+ if (reply.id !== topicId && replyThread !== topicId) {
1140
+ throw new CliError("validation_error", "--reply-to belongs to a different topic; choose a message in --topic");
1141
+ }
1142
+ }
1143
+ });
1144
+ }
1145
+ topic(reference, topicId) {
1146
+ const id = topicNumber(topicId);
1147
+ return this.#call(async () => {
1148
+ const input = await this.#inputOf(reference);
1149
+ const peer = await this.#client.getPeer(input);
1150
+ if (peer.type !== "chat" || !peer.isForum)
1151
+ throw new CliError("validation_error", "that chat has no topics");
1152
+ const [topic] = await this.#client.getForumTopicsById(input, id);
1153
+ if (!topic)
1154
+ throw new CliError("not_found", "that forum topic does not exist; check `topics list`");
1155
+ return toTopic(topic);
1156
+ });
1157
+ }
1158
+ /** A forum's topics, newest activity first; Telegram matches `search` against their titles. */
1159
+ topics(reference, { search, limit, offset }) {
1160
+ return this.#call(async () => {
1161
+ const peer = await this.#inputOf(reference);
1162
+ const items = [];
1163
+ const wanted = limit === undefined ? {} : { limit: offset + limit + 1 };
1164
+ for await (const topic of this.#client.iterForumTopics(peer, {
1165
+ ...wanted,
1166
+ ...(search ? { query: search } : {}),
1167
+ })) {
1168
+ items.push(toTopic(topic));
1169
+ }
1170
+ const end = limit === undefined ? items.length : offset + limit;
1171
+ return { items: items.slice(offset, end), hasMore: items.length > end };
1172
+ });
1173
+ }
1174
+ /** Telegram answers only where the person's privacy lets the owner find them by number. */
1175
+ lookup(phone) {
1176
+ return this.#call(async () => toMember(await this.#client.getPeer(await this.#client.resolvePhoneNumber(phone))));
1177
+ }
1178
+ /** The owner's Telegram contacts — the address book, not the chats. */
1179
+ addressBook() {
1180
+ return this.#call(async () => (await this.#client.getContacts()).map(toMember));
1181
+ }
1182
+ /** Every device and app logged in; the IP address Telegram also sends is left out. */
1183
+ sessions() {
1184
+ return this.#call(async () => {
1185
+ const { authorizations } = await this.#client.call({ _: "account.getAuthorizations" });
1186
+ return authorizations.map(toAccountSession);
1187
+ });
1188
+ }
1189
+ /**
1190
+ * A page of a group's members, 200 a request. Telegram gives at most `MEMBERS_MAX` of a big group,
1191
+ * and a group that hides its list answers only its admins or refuses.
1192
+ */
1193
+ members(reference, { limit, offset }) {
1194
+ return this.#call(async () => {
1195
+ const peer = await this.#inputOf(reference);
1196
+ const group = await this.#client.getPeer(peer);
1197
+ const wanted = Math.min(limit ?? MEMBERS_MAX, MEMBERS_MAX - offset);
1198
+ const found = [];
1199
+ let total = null;
1200
+ while (found.length < wanted) {
1201
+ const size = Math.min(200, wanted - found.length);
1202
+ const page = await this.#client.getChatMembers(peer, { offset: offset + found.length, limit: size });
1203
+ total = page.total;
1204
+ found.push(...page.map(toGroupMember));
1205
+ if (page.length < size)
1206
+ break;
1207
+ }
1208
+ return {
1209
+ chatId: String(group.id),
1210
+ items: found,
1211
+ hasMore: offset + found.length < Math.min(total ?? 0, MEMBERS_MAX),
1212
+ participantsCount: offset === 0 ? await this.#groupCount(group, total) : null,
1213
+ };
1214
+ });
1215
+ }
1216
+ /**
1217
+ * Service messages back to `since`, newest page first, at most `EVENT_PAGES` of them — a busy
1218
+ * group's week can be thousands. The people a message names only by id are looked up in one call.
1219
+ */
1220
+ chatEvents(reference, { since }) {
1221
+ return this.#call(async () => {
1222
+ const peer = await this.#inputOf(reference);
1223
+ const found = [];
1224
+ let offset;
1225
+ let more = false;
1226
+ for (let read = 1;; read++) {
1227
+ const page = await this.#client.getHistory(peer, { limit: 100, ...(offset ? { offset } : {}) });
1228
+ for (const message of page) {
1229
+ const change = message.date.getTime() > since ? eventOf(message) : null;
1230
+ if (change)
1231
+ found.push({ message, change });
1232
+ }
1233
+ const oldest = page.at(-1);
1234
+ if (!page.next || !oldest || oldest.date.getTime() <= since)
1235
+ break;
1236
+ if (read >= EVENT_PAGES) {
1237
+ more = true;
1238
+ break;
1239
+ }
1240
+ offset = page.next;
1241
+ }
1242
+ const names = new Map(found.map(({ message }) => [message.sender.id, message.sender.displayName || null]));
1243
+ const unknown = [...new Set(found.flatMap(({ change }) => [change.by, ...change.people]))].filter((id) => !names.has(id));
1244
+ if (unknown.length > 0) {
1245
+ for (const user of await this.#client.getUsers(unknown))
1246
+ if (user)
1247
+ names.set(user.id, user.displayName || null);
1248
+ }
1249
+ const person = (id) => ({ id: String(id), name: names.get(id) ?? null });
1250
+ return {
1251
+ chatId: String((await this.#client.getPeer(peer)).id),
1252
+ since: new Date(since).toISOString(),
1253
+ more,
1254
+ events: found.reverse().map(({ message, change }) => ({
1255
+ messageId: String(message.id),
1256
+ timestamp: message.date.toISOString(),
1257
+ event: change.event,
1258
+ by: person(change.by),
1259
+ people: change.people.map(person),
1260
+ ...(change.title === undefined ? {} : { title: change.title }),
1261
+ })),
1262
+ };
1263
+ });
1264
+ }
1265
+ /**
1266
+ * A group's admins and its creator, for `review --unanswered`; `null` when the group hides them.
1267
+ * A basic group ignores the `admins` filter and answers everyone, hence the status check.
1268
+ */
1269
+ admins(reference) {
1270
+ return this.#call(async () => {
1271
+ const peer = await this.#inputOf(reference);
1272
+ try {
1273
+ const members = await this.#client.getChatMembers(peer, { type: "admins", limit: 200 });
1274
+ return members
1275
+ .filter((member) => member.status === "creator" || member.status === "admin")
1276
+ .map((member) => String(member.user.id));
1277
+ }
1278
+ catch (error) {
1279
+ const known = toCliError(error, this.#login);
1280
+ if (known instanceof CliError && known.code === "permission_error")
1281
+ return null;
1282
+ throw known;
1283
+ }
1284
+ });
1285
+ }
1286
+ /** `null` when the group hides its member list from us: that is an answer about the group, not a failure. */
1287
+ /** Each reference as a user id, in order; a group or a channel is not a person and is refused. */
1288
+ people(references) {
1289
+ return this.#call(async () => {
1290
+ const ids = [];
1291
+ for (const reference of references) {
1292
+ const peer = await this.#client.getPeer(await this.#inputOf(reference));
1293
+ if (peer.type !== "user")
1294
+ throw new CliError("validation_error", `${reference} is a chat, not a person`);
1295
+ ids.push(String(peer.id));
1296
+ }
1297
+ return ids;
1298
+ });
1299
+ }
1300
+ /**
1301
+ * Always a supergroup, never a legacy group: a legacy group turns into a supergroup on some changes
1302
+ * and its id changes with it. The people are added after, so a group exists even if some cannot be.
1303
+ */
1304
+ createGroup(title, people, { channel }) {
1305
+ return this.#call(async () => {
1306
+ try {
1307
+ const made = channel
1308
+ ? await this.#client.createChannel({ title })
1309
+ : await this.#client.createSupergroup({ title });
1310
+ const missing = people.length > 0 ? await this.#client.addChatMembers(made.id, people.map(Number), {}) : [];
1311
+ const card = toGroupCard(await this.#client.getFullChat(made.id));
1312
+ if (missing.length === 0)
1313
+ return card;
1314
+ const notAdded = missing.map((one) => String(one.userId));
1315
+ return { ...card, providerMetadata: { ...card.providerMetadata, notAdded } };
1316
+ }
1317
+ catch (error) {
1318
+ throw unknownIfUnanswered(error, "the group may or may not have been made; check `tg chats list`");
1319
+ }
1320
+ });
1321
+ }
1322
+ /** An invite link, or a public one; a group that asks its admins first answers `requested`. */
1323
+ join(link) {
1324
+ const typed = link.trim();
1325
+ const invite = /(t\.me|telegram\.me)\/(\+|joinchat\/)|^tg:\/\/join/.test(typed);
1326
+ return this.#call(async () => {
1327
+ let joined;
1328
+ try {
1329
+ joined = await this.#client.joinChat(invite ? typed : publicName(typed));
1330
+ }
1331
+ catch (error) {
1332
+ throw unknownIfUnanswered(error, "you may or may not have joined; check `tg chats list`");
1333
+ }
1334
+ if (joined.status === "request_sent")
1335
+ return { requested: true };
1336
+ if (joined.status !== "ok") {
1337
+ throw new CliError("provider_error", "this group asks a bot to check who joins, which only the Telegram app can show");
1338
+ }
1339
+ return toGroupCard(await this.#client.getFullChat(joined.chat.id));
1340
+ });
1341
+ }
1342
+ leave(reference) {
1343
+ return this.#call(async () => {
1344
+ const peer = await this.#inputOf(reference);
1345
+ const chatId = String((await this.#client.getPeer(peer)).id);
1346
+ try {
1347
+ await this.#client.leaveChat(peer);
1348
+ }
1349
+ catch (error) {
1350
+ throw unknownIfUnanswered(error, "you may or may not have left; check `tg chats list`");
1351
+ }
1352
+ return { chatId };
1353
+ });
1354
+ }
1355
+ group(reference) {
1356
+ return this.#call(async () => toGroupCard(await this.#client.getFullChat(await this.#inputOf(reference))));
1357
+ }
1358
+ /**
1359
+ * Telegram computes these only on the chat's statistics server (`stats_dc`); mtcute opens that
1360
+ * connection and carries the login over itself, and `close` ends it with the others.
1361
+ */
1362
+ officialChatStats(reference) {
1363
+ return this.#call(async () => {
1364
+ let full = await this.#client.getFullChat(await this.#inputOf(reference));
1365
+ if (full.migratedToId != null)
1366
+ full = await this.#client.getFullChat(full.migratedToId);
1367
+ const broadcast = full.chatType === "channel";
1368
+ if (!broadcast && full.chatType !== "supergroup" && full.chatType !== "gigagroup") {
1369
+ throw new CliError("validation_error", full.chatType === "group"
1370
+ ? "Telegram keeps statistics only for supergroups and channels, not for a basic group"
1371
+ : "Telegram keeps statistics only for supergroups and channels");
1372
+ }
1373
+ if (!full.canViewStats) {
1374
+ throw new CliError("permission_error", "Telegram shows statistics only to admins of large enough groups and channels, and not for this one");
1375
+ }
1376
+ const statsDc = full.full._ === "channelFull" ? full.full.statsDc : undefined;
1377
+ const options = statsDc === undefined ? undefined : { dcId: statsDc };
1378
+ const channel = await this.#client.resolveChannel(full.id);
1379
+ const chat = { id: String(full.id), title: full.displayName };
1380
+ const graphOf = async (graph) => {
1381
+ if (graph._ === "statsGraph")
1382
+ return toOfficialGraph(graph.json.data);
1383
+ if (graph._ === "statsGraphError")
1384
+ return { error: graph.error };
1385
+ try {
1386
+ const loaded = await this.#client.call({ _: "stats.loadAsyncGraph", token: graph.token }, options);
1387
+ if (loaded._ === "statsGraph")
1388
+ return toOfficialGraph(loaded.json.data);
1389
+ return { error: loaded._ === "statsGraphError" ? loaded.error : "Telegram did not finish this graph" };
1390
+ }
1391
+ catch (error) {
1392
+ if (tl.RpcError.is(error))
1393
+ return { error: error.text };
1394
+ throw error;
1395
+ }
1396
+ };
1397
+ try {
1398
+ return broadcast
1399
+ ? await toOfficialChannelStats(chat, await this.#client.call({ _: "stats.getBroadcastStats", channel }, options), graphOf)
1400
+ : await toOfficialGroupStats(chat, await this.#client.call({ _: "stats.getMegagroupStats", channel }, options), graphOf);
1401
+ }
1402
+ catch (error) {
1403
+ // Reached the main server a moment ago, so the login stands; only the statistics server refused it.
1404
+ if (tl.RpcError.is(error, "AUTH_KEY_UNREGISTERED"))
1405
+ throw new CliError("provider_error", "Telegram's statistics server did not accept this login (AUTH_KEY_UNREGISTERED); the login itself works — try again later", { providerError: error.text, status: error.code });
1406
+ throw error;
1407
+ }
1408
+ });
1409
+ }
1410
+ /**
1411
+ * Telegram keeps what members may do as rights taken away, and sets them all at once: the current
1412
+ * ones are read first, and only the switches asked for change.
1413
+ */
1414
+ updateGroup(chatId, { title, description, settings = {} }) {
1415
+ const missing = Object.keys(settings).filter((key) => !GROUP_SETTINGS.includes(key));
1416
+ if (missing.length > 0) {
1417
+ throw new CliError("validation_error", `Telegram has no group setting ${missing.join(", ")}`);
1418
+ }
1419
+ const peer = Number(chatId);
1420
+ return this.#call(async () => {
1421
+ try {
1422
+ if (title !== undefined)
1423
+ await this.#client.setChatTitle(peer, title);
1424
+ if (description !== undefined)
1425
+ await this.#client.setChatDescription(peer, description);
1426
+ const { allCanPin, onlyAdminsAdd, joinApproval } = settings;
1427
+ if (typeof joinApproval === "boolean")
1428
+ await this.#client.toggleJoinRequests(peer, joinApproval);
1429
+ if (typeof allCanPin === "boolean" || typeof onlyAdminsAdd === "boolean") {
1430
+ const current = (await this.#client.getFullChat(peer)).defaultPermissions?.raw;
1431
+ const { _: _kind, untilDate: _until, ...taken } = current ?? { _: "chatBannedRights", untilDate: 0 };
1432
+ await this.#client.setChatDefaultPermissions(peer, {
1433
+ ...taken,
1434
+ ...(typeof allCanPin === "boolean" ? { pinMessages: !allCanPin } : {}),
1435
+ ...(typeof onlyAdminsAdd === "boolean" ? { inviteUsers: onlyAdminsAdd } : {}),
1436
+ });
1437
+ }
1438
+ }
1439
+ catch (error) {
1440
+ throw unknownIfUnanswered(error, "the group may have changed in part; check `tg chats show`");
1441
+ }
1442
+ return toGroupCard(await this.#client.getFullChat(peer));
1443
+ });
1444
+ }
1445
+ /** A new primary link for the owner; the old one stops working. */
1446
+ resetInviteLink(chatId) {
1447
+ const peer = Number(chatId);
1448
+ return this.#call(async () => {
1449
+ try {
1450
+ await this.#client.exportInviteLink(peer);
1451
+ }
1452
+ catch (error) {
1453
+ throw unknownIfUnanswered(error, "the link may or may not have been replaced; check `tg chats link show`");
1454
+ }
1455
+ return toGroupCard(await this.#client.getFullChat(peer));
1456
+ });
1457
+ }
1458
+ createInviteLink(chatId, { approval, expiresAt, maxUses }) {
1459
+ return this.#call(async () => {
1460
+ try {
1461
+ return toInviteLink(await this.#client.createInviteLink(Number(chatId), {
1462
+ withApproval: approval,
1463
+ ...(expiresAt === undefined ? {} : { expires: new Date(expiresAt) }),
1464
+ ...(maxUses === undefined ? {} : { usageLimit: maxUses }),
1465
+ }));
1466
+ }
1467
+ catch (error) {
1468
+ throw unknownIfUnanswered(error, "a link may or may not have been made; it works only once shared");
1469
+ }
1470
+ });
1471
+ }
1472
+ joinRequests(chatId, { limit, link, search }) {
1473
+ return this.#call(() => joinRequestsOf(this.#client, Number(chatId), limit, {
1474
+ ...(link ? { link } : {}),
1475
+ ...(search ? { search } : {}),
1476
+ }));
1477
+ }
1478
+ answerAllJoinRequests(chatId, accept, link) {
1479
+ return this.#call(async () => {
1480
+ try {
1481
+ await this.#client.hideAllJoinRequests({
1482
+ chatId: Number(chatId),
1483
+ action: accept ? "approve" : "decline",
1484
+ ...(link ? { link } : {}),
1485
+ });
1486
+ }
1487
+ catch (error) {
1488
+ throw unknownIfUnanswered(error, "the requests may or may not have been answered; check `tg chats requests list`");
1489
+ }
1490
+ });
1491
+ }
1492
+ /** Telegram shows an admin only their own links; the creator could ask for others', which we do not. */
1493
+ inviteLinks(chatId, { limit, revoked }) {
1494
+ return this.#call(async () => {
1495
+ const page = await this.#client.getInviteLinks(Number(chatId), { limit, revoked });
1496
+ return { items: page.map(toInviteLink), hasMore: page.total > page.length };
1497
+ });
1498
+ }
1499
+ revokeInviteLink(chatId, link) {
1500
+ return this.#call(async () => {
1501
+ try {
1502
+ return toInviteLink(await this.#client.revokeInviteLink(Number(chatId), link));
1503
+ }
1504
+ catch (error) {
1505
+ throw unknownIfUnanswered(error, "the link may or may not have been revoked; check `tg chats link list`");
1506
+ }
1507
+ });
1508
+ }
1509
+ updateInviteLink(chatId, link, { approval, expiresAt, maxUses }) {
1510
+ return this.#call(async () => {
1511
+ const peer = await this.#client.resolvePeer(Number(chatId));
1512
+ try {
1513
+ // Raw, because mtcute's editInviteLink drops an expiry of 0 — Telegram's "never expires".
1514
+ const answer = await this.#client.call({
1515
+ _: "messages.editExportedChatInvite",
1516
+ peer,
1517
+ link,
1518
+ ...(approval === undefined ? {} : { requestNeeded: approval }),
1519
+ ...(expiresAt === undefined ? {} : { expireDate: expiresAt === null ? 0 : Date.parse(expiresAt) / 1000 }),
1520
+ ...(maxUses === undefined ? {} : { usageLimit: maxUses }),
1521
+ });
1522
+ return toInviteLink(new ChatInviteLink(answer.invite, PeersIndex.from(answer)));
1523
+ }
1524
+ catch (error) {
1525
+ throw unknownIfUnanswered(error, "the link may or may not have been changed; check `tg chats link list`");
1526
+ }
1527
+ });
1528
+ }
1529
+ answerJoinRequest(chatId, personId, accept) {
1530
+ return this.#call(async () => {
1531
+ try {
1532
+ return await answerJoinRequestOf(this.#client, Number(chatId), Number(personId), accept);
1533
+ }
1534
+ catch (error) {
1535
+ throw unknownIfUnanswered(error, `the request of ${personId} may or may not have been answered; check \`tg chats requests list\``);
1536
+ }
1537
+ });
1538
+ }
1539
+ /** A supergroup shows new members its history by its own setting, never per person: `history` is refused. */
1540
+ addMembers(chatId, people, { history }) {
1541
+ if (history)
1542
+ throw new CliError("validation_error", "Telegram shows new members the history by the group's setting, not per person");
1543
+ return this.#call(async () => {
1544
+ try {
1545
+ const missing = await this.#client.addChatMembers(Number(chatId), people.map(Number), {});
1546
+ return { notAdded: missing.map((one) => String(one.userId)) };
1547
+ }
1548
+ catch (error) {
1549
+ throw unknownIfUnanswered(error, "the people may or may not have been added; check `tg chats members list`");
1550
+ }
1551
+ });
1552
+ }
1553
+ /** One request per person, in turn: Telegram rate-limits these hard. */
1554
+ removeMembers(chatId, people) {
1555
+ return this.#call(async () => {
1556
+ for (const person of people) {
1557
+ try {
1558
+ await this.#client.kickChatMember({ chatId: Number(chatId), userId: Number(person) });
1559
+ }
1560
+ catch (error) {
1561
+ throw unknownIfUnanswered(error, `${person} may or may not have been removed; check \`tg chats members list\``);
1562
+ }
1563
+ }
1564
+ });
1565
+ }
1566
+ addAdmin(chatId, person, rights) {
1567
+ const missing = rights.filter((right) => !(right in ADMIN_RIGHT_FIELDS));
1568
+ if (missing.length > 0)
1569
+ throw new CliError("validation_error", `Telegram has no admin right ${missing.join(", ")}`);
1570
+ const fields = Object.fromEntries(rights.map((right) => [ADMIN_RIGHT_FIELDS[right], true]));
1571
+ return this.#editAdmin(chatId, person, fields);
1572
+ }
1573
+ removeAdmin(chatId, person) {
1574
+ return this.#editAdmin(chatId, person, {});
1575
+ }
1576
+ #editAdmin(chatId, person, rights) {
1577
+ return this.#call(async () => {
1578
+ try {
1579
+ await this.#client.editAdminRights({ chatId: Number(chatId), userId: Number(person), rights });
1580
+ }
1581
+ catch (error) {
1582
+ throw unknownIfUnanswered(error, "the rights may or may not have changed; check `tg chats members list`");
1583
+ }
1584
+ });
1585
+ }
1586
+ folders() {
1587
+ return this.#call(async () => (await this.#filters()).map(toFolder).filter((one) => one !== null));
1588
+ }
1589
+ createFolder(title, chatIds, rules = {}) {
1590
+ return this.#write("the folder may have been made; check `tg chats folders list` before repeating — a repeat makes a second one", async () => {
1591
+ const peers = (ids = []) => Promise.all(ids.map((id) => this.#client.resolvePeer(Number(id))));
1592
+ const pinned = new Set(rules.pin);
1593
+ const made = await this.#client.createFolder({
1594
+ title: { _: "textWithEntities", text: title, entities: [] },
1595
+ includePeers: await peers(chatIds.filter((id) => !pinned.has(id))),
1596
+ pinnedPeers: await peers(rules.pin),
1597
+ excludePeers: await peers(rules.exclude),
1598
+ ...(rules.emoji === undefined ? {} : { emoticon: rules.emoji }),
1599
+ ...ruleFlags(rules),
1600
+ });
1601
+ return this.#storedFolder(made);
1602
+ });
1603
+ }
1604
+ /** Telegram replaces a folder's chats as a list, so the ones it has are read and only the asked ones change. */
1605
+ updateFolder(folderId, change) {
1606
+ const { title, add = [], remove = [], exclude = [], pin = [], emoji, include, skip } = change;
1607
+ return this.#write("the folder may have changed — repeating it is safe", async () => {
1608
+ const current = (await this.#filters()).find((one) => one._ !== "dialogFilterDefault" && String(one.id) === folderId);
1609
+ if (!current || current._ === "dialogFilterDefault")
1610
+ throw new CliError("not_found", `no folder ${folderId}`);
1611
+ const rulesAsked = include !== undefined || skip !== undefined || exclude.length > 0;
1612
+ if (rulesAsked && current._ === "dialogFilterChatlist")
1613
+ throw new CliError("validation_error", "a folder shared by a link holds only its chats; it takes no rules");
1614
+ // A chat sits on one list at a time: pinning or excluding it takes it off the others.
1615
+ const idOf = (peer) => String(getMarkedPeerId(peer));
1616
+ const off = (peers, ...ids) => {
1617
+ const gone = new Set(ids.flat());
1618
+ return peers.filter((peer) => !gone.has(idOf(peer)));
1619
+ };
1620
+ const resolved = (ids, have) => {
1621
+ const held = new Set(have.map(idOf));
1622
+ return Promise.all(ids.filter((id) => !held.has(id)).map((id) => this.#client.resolvePeer(Number(id))));
1623
+ };
1624
+ const include0 = off(current.includePeers, remove, pin, exclude);
1625
+ const pinned0 = off(current.pinnedPeers, remove, exclude);
1626
+ const listsChanged = add.length > 0 || remove.length > 0 || pin.length > 0 || exclude.length > 0;
1627
+ const excluded0 = current._ === "dialogFilter" ? off(current.excludePeers, remove, add, pin) : [];
1628
+ const changed = await this.#client.editFolder({
1629
+ folder: current._ === "dialogFilter" ? current : current.id,
1630
+ modification: {
1631
+ ...(title === undefined ? {} : { title: { _: "textWithEntities", text: title, entities: [] } }),
1632
+ ...(emoji === undefined ? {} : { emoticon: emoji }),
1633
+ ...ruleFlags({ ...(include === undefined ? {} : { include }), ...(skip === undefined ? {} : { skip }) }),
1634
+ ...(listsChanged
1635
+ ? {
1636
+ includePeers: [...include0, ...(await resolved(add, [...include0, ...pinned0]))],
1637
+ pinnedPeers: [...pinned0, ...(await resolved(pin, pinned0))],
1638
+ ...(current._ === "dialogFilter"
1639
+ ? { excludePeers: [...excluded0, ...(await resolved(exclude, excluded0))] }
1640
+ : {}),
1641
+ }
1642
+ : {}),
1643
+ },
1644
+ });
1645
+ return this.#storedFolder(changed);
1646
+ });
1647
+ }
1648
+ deleteFolder(folderId) {
1649
+ return this.#write("the folder may have been deleted — repeating it is safe", async () => {
1650
+ await this.#client.deleteFolder(Number(folderId));
1651
+ });
1652
+ }
1653
+ /** "All chats" (id 0) keeps its place: only Premium accounts may move it, and tg does not list it. */
1654
+ orderFolders(folderIds) {
1655
+ return this.#write("the folders may be in the new order already — repeating it is safe", async () => {
1656
+ const current = (await this.#filters()).map((one) => (one._ === "dialogFilterDefault" ? 0 : one.id));
1657
+ const order = folderIds.map(Number);
1658
+ const all = current.indexOf(0);
1659
+ if (all >= 0)
1660
+ order.splice(all, 0, 0);
1661
+ await this.#client.setFoldersOrder(order);
1662
+ });
1663
+ }
1664
+ joinFolder(link) {
1665
+ return this.#write("the folder may have been joined; check `tg chats folders list` before repeating — a repeat joins nothing new", async () => {
1666
+ try {
1667
+ return toFolder(await this.#client.joinChatlist(link));
1668
+ }
1669
+ catch (error) {
1670
+ if (tl.RpcError.is(error, "INVITE_SLUG_EXPIRED") || tl.RpcError.is(error, "INVITE_SLUG_INVALID"))
1671
+ throw new CliError("not_found", "this folder link is invalid or has expired");
1672
+ throw error;
1673
+ }
1674
+ });
1675
+ }
1676
+ /**
1677
+ * mtcute answers with the folder it sent, but Telegram drops what it does not take — an emoji that is not
1678
+ * one of its folder icons, measured live 2026-10-08 — so the answer is read back.
1679
+ */
1680
+ async #storedFolder(sent) {
1681
+ const stored = sent._ === "dialogFilterDefault"
1682
+ ? undefined
1683
+ : (await this.#filters()).find((one) => one._ !== "dialogFilterDefault" && one.id === sent.id);
1684
+ return toFolder(stored ?? sent);
1685
+ }
1686
+ async #filters() {
1687
+ return (await this.#client.getFolders()).filters;
1688
+ }
1689
+ /** Under the name they show; `renameContact` gives one of the owner's own. */
1690
+ addContact(personId) {
1691
+ return this.#write("the contact may have been added — repeating it is safe", async () => {
1692
+ const peer = await this.#client.getPeer(Number(personId));
1693
+ if (peer.type !== "user")
1694
+ throw new CliError("validation_error", `${personId} is a chat, not a person`);
1695
+ return toMember(await this.#client.addContact({
1696
+ userId: peer.id,
1697
+ firstName: peer.firstName,
1698
+ ...(peer.lastName ? { lastName: peer.lastName } : {}),
1699
+ }));
1700
+ });
1701
+ }
1702
+ removeContact(personId) {
1703
+ return this.#write("the contact may have been removed — repeating it is safe", async () => {
1704
+ await this.#client.deleteContacts([Number(personId)]);
1705
+ });
1706
+ }
1707
+ block(personId) {
1708
+ return this.#write("the person may have been blocked — repeating it is safe", async () => {
1709
+ await this.#client.blockUser(Number(personId));
1710
+ });
1711
+ }
1712
+ unblock(personId) {
1713
+ return this.#write("the person may have been unblocked — repeating it is safe", async () => {
1714
+ await this.#client.unblockUser(Number(personId));
1715
+ });
1716
+ }
1717
+ renameContact(personId, firstName, lastName) {
1718
+ return this.#write("the contact may have been renamed — repeating it is safe", async () => toMember(await this.#client.addContact({ userId: Number(personId), firstName, ...(lastName ? { lastName } : {}) })));
1719
+ }
1720
+ /** The name is split at its first space into Telegram's first and last name. */
1721
+ importContacts(entries) {
1722
+ return this.#write("the contacts may have been imported — repeating it is safe", async () => {
1723
+ const result = await this.#client.importContacts(entries.map(({ phone, name }) => {
1724
+ const [firstName = name, ...rest] = name.split(" ");
1725
+ return { phone: `+${phone}`, firstName, lastName: rest.join(" ") };
1726
+ }));
1727
+ const found = new Set(result.imported.map((one) => String(one.userId)));
1728
+ const users = await this.#client.getUsers([...found].map(Number));
1729
+ return users.filter((user) => user !== null).map(toMember);
1730
+ });
1731
+ }
1732
+ /** Telegram calls the description the bio. A photo goes up as a new profile photo. */
1733
+ updateProfile({ firstName, lastName, description, photo, }) {
1734
+ return this.#call(async () => {
1735
+ if (firstName !== undefined || lastName !== undefined || description !== undefined) {
1736
+ await this.#client.updateProfile({
1737
+ ...(firstName === undefined ? {} : { firstName }),
1738
+ ...(lastName === undefined ? {} : { lastName }),
1739
+ ...(description === undefined ? {} : { bio: description }),
1740
+ });
1741
+ }
1742
+ if (photo)
1743
+ await this.#client.setMyProfilePhoto({ type: "photo", media: photo.bytes });
1744
+ const user = await this.#client.getMe();
1745
+ return { ...toAccount(user), phone: user.phoneNumber };
1746
+ });
1747
+ }
1748
+ /** mtcute has no call of its own for it; this is Telegram's `auth.resetAuthorizations`. */
1749
+ endOtherSessions() {
1750
+ return this.#call(async () => {
1751
+ try {
1752
+ await this.#client.call({ _: "auth.resetAuthorizations" });
1753
+ }
1754
+ catch (error) {
1755
+ throw unknownIfUnanswered(error, "the other devices may or may not have been logged out; check `tg account sessions list`");
1756
+ }
1757
+ const { authorizations } = await this.#client.call({ _: "account.getAuthorizations" });
1758
+ return authorizations.map(toAccountSession);
1759
+ });
1760
+ }
1761
+ /**
1762
+ * A supergroup's own count is its member list's total: its chat object and its full info both said 1 for a
1763
+ * group of 2, seen live. A list hidden from non-admins answers only part of the group, so then the full info's
1764
+ * count stands, which the shorter list never reaches. A basic group's list total is only the page it read.
1765
+ */
1766
+ async #groupCount(group, listTotal) {
1767
+ if (group.type !== "chat" || group.raw?._ !== "channel")
1768
+ return groupMembersCount(group);
1769
+ const full = await this.#client.getFullChat(group.id);
1770
+ const hidden = full.full._ === "channelFull" && full.full.participantsHidden === true && !full.isAdmin;
1771
+ return hidden || listTotal === null ? groupMembersCount(full) : listTotal || null;
1772
+ }
1773
+ async #membersOf(peer) {
1774
+ try {
1775
+ return await this.#client.getChatMembers(peer, { limit: 200 });
1776
+ }
1777
+ catch (error) {
1778
+ const known = toCliError(error, this.#login);
1779
+ if (known instanceof CliError && known.code === "permission_error")
1780
+ return null;
1781
+ throw known;
1782
+ }
1783
+ }
1784
+ /** A write with no id to repeat it by: no answer means it may have happened, and `what` says what to do. */
1785
+ #write(what, work) {
1786
+ return this.#call(async () => {
1787
+ try {
1788
+ return await work();
1789
+ }
1790
+ catch (error) {
1791
+ throw unknownIfUnanswered(error, what);
1792
+ }
1793
+ });
1794
+ }
1795
+ async #call(work) {
1796
+ try {
1797
+ return await (this.#proxyFailed ? Promise.race([work(), this.#proxyFailed]) : work());
1798
+ }
1799
+ catch (error) {
1800
+ throw toCliError(error, this.#login);
1801
+ }
1802
+ }
1803
+ }
1804
+ export { GROUP_SETTINGS };
1805
+ const unixTime = (value) => typeof value === "number" && value > 0 ? new Date(value * 1000).toISOString() : undefined;
1806
+ /** Telegram sets `freeze_since_date` non-zero only on a frozen account; the dates are unix seconds. */
1807
+ export const frozenOf = (config) => {
1808
+ const since = unixTime(config.freeze_since_date);
1809
+ if (!since)
1810
+ return undefined;
1811
+ const until = unixTime(config.freeze_until_date);
1812
+ const appealUrl = typeof config.freeze_appeal_url === "string" && config.freeze_appeal_url ? config.freeze_appeal_url : undefined;
1813
+ return {
1814
+ state: "frozen",
1815
+ since,
1816
+ ...(until ? { until } : {}),
1817
+ ...(appealUrl ? { appealUrl } : {}),
1818
+ hint: "Telegram froze this account: it can read but not write" +
1819
+ (until ? `, and deletes it on ${until.slice(0, 10)}` : "") +
1820
+ (appealUrl ? ` unless an appeal is accepted — appeal at ${appealUrl}` : " unless an appeal is accepted"),
1821
+ };
1822
+ };
1823
+ /** The rights `tg chats admins add --can` offers: max's, less `read`. */
1824
+ export const ADMIN_RIGHTS = Object.keys(ADMIN_RIGHT_FIELDS);
1825
+ /**
1826
+ * How long mtcute sits out a FLOOD_WAIT before the wait becomes `rate_limited` with `retryAfterMs`, and
1827
+ * how many times. One-shot: 10 s, mtcute's own default, which covers the routine short waits; past it a
1828
+ * person or a script is better told the wait than held, and twice at most keeps a command under ~20 s
1829
+ * of announced waiting. Listening (`watch`, `serve`): nobody is waiting on a request, and giving up
1830
+ * costs a restart and a new connection — 120 s, tlgr's threshold. `store fetch` keeps the one-shot
1831
+ * value: cli-messaging's `patiently` sits out up to 5 min above it, and says so.
1832
+ */
1833
+ export const FLOOD_SLEEP = {
1834
+ oneShot: { maxWait: 10_000, maxRetries: 2 },
1835
+ listening: { maxWait: 120_000, maxRetries: 3 },
1836
+ };
1837
+ /** A look at a local flag, not a request. */
1838
+ export const LOOP_CHECK_MS = 30_000;
1839
+ /** Every 15 minutes, mtcute's own keep-alive rate: ~96 light requests a day. */
1840
+ export const HEARTBEAT_TICKS = 30;
1841
+ export const STATE_WAIT_MS = 30_000;
1842
+ const LOOP_STOPPED = "Telegram's updates stopped arriving although the connection is open — ending, so a service unit starts it again";
1843
+ export const TRANSCRIBE_POLL_MS = 2000;
1844
+ const TRANSCRIBE_WAIT_MS = 60_000;
1845
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1846
+ const pause = (ms, signal) => new Promise((resolve) => {
1847
+ const done = () => {
1848
+ clearTimeout(timer);
1849
+ signal.removeEventListener("abort", done);
1850
+ resolve();
1851
+ };
1852
+ const timer = setTimeout(done, ms);
1853
+ if (signal.aborted)
1854
+ done();
1855
+ else
1856
+ signal.addEventListener("abort", done, { once: true });
1857
+ });
1858
+ const messageNumber = (id, rule = "--before-id takes a message id") => {
1859
+ if (!/^\d+$/.test(id))
1860
+ throw new CliError("validation_error", `${rule}, got "${id}"`);
1861
+ return Number(id);
1862
+ };
1863
+ const forwardedCopy = (updates) => {
1864
+ if (updates._ !== "updates" && updates._ !== "updatesCombined")
1865
+ return undefined;
1866
+ const update = updates.updates.find((one) => one._ === "updateNewMessage" || one._ === "updateNewChannelMessage");
1867
+ return update && "message" in update ? new TgMessage(update.message, PeersIndex.from(updates)) : undefined;
1868
+ };
1869
+ const parseSendId = (typed) => {
1870
+ if (!/^-?\d{1,20}$/.test(typed))
1871
+ throw new CliError("validation_error", "--send-id is the number a failed send printed");
1872
+ return Long.fromString(typed);
1873
+ };
1874
+ /** No answer is not a refusal: the write may have reached Telegram. Anything else is left for `#call` to map. */
1875
+ const unknownIfUnanswered = (error, what, details = {}) => {
1876
+ const known = toCliError(error);
1877
+ if (known instanceof CliError && ["timeout", "network_error"].includes(known.code)) {
1878
+ return new CliError("outcome_unknown", `no answer from Telegram — ${what}`, { ...details, cause: known.code });
1879
+ }
1880
+ return error;
1881
+ };
1882
+ const repeatWith = (sendId, sendAs) => sendAs === undefined ? `--send-id ${sendId}` : `--send-id ${sendId} --send-as ${sendAs}`;
1883
+ const retryDetails = (sendId, sendAs) => ({
1884
+ sendId,
1885
+ ...(sendAs === undefined ? {} : { sendAs }),
1886
+ });
1887
+ const topicNumber = (id) => {
1888
+ if (!/^[1-9]\d*$/.test(id) || Number(id) > 2147483647) {
1889
+ throw new CliError("validation_error", "--topic needs a positive Telegram topic id");
1890
+ }
1891
+ return Number(id);
1892
+ };
1893
+ const remoteMessage = (message) => observedCounters(toMessage(message), new Date().toISOString(), ["views", "reactions", "comments"]);
1894
+ const remoteHit = (message, source) => ({
1895
+ ...toMessageHit(message),
1896
+ ...observedCounters(toMessage(message), new Date().toISOString(), ["views", "reactions", "comments"], source),
1897
+ });
1898
+ //# sourceMappingURL=adapter.js.map