talon-agent 3.21.0 → 3.22.0

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.
@@ -9,9 +9,47 @@ import { expandFsPath } from "../../../util/fs-path.js";
9
9
  import { markdownToTelegramHtml } from "../formatting.js";
10
10
  import { withRetry } from "../../../core/engine/gateway.js";
11
11
  import { resolveStickerByEmoji } from "../sticker-library.js";
12
- import { replyParams } from "./shared.js";
12
+ import { replyParams, sendOpts } from "./shared.js";
13
13
  import type { TelegramActionHandlers } from "./types.js";
14
14
 
15
+ type MediaSource = {
16
+ file_path?: unknown;
17
+ url?: unknown;
18
+ file_id?: unknown;
19
+ };
20
+
21
+ /**
22
+ * Resolve one media input to what the Bot API accepts. Three sources, in
23
+ * priority order: a public URL or a Telegram file_id (both passed as a plain
24
+ * string — Telegram fetches/reuses server-side, no local bytes involved),
25
+ * else a workspace file path uploaded as multipart.
26
+ */
27
+ export function resolveMediaInput(
28
+ src: MediaSource,
29
+ label: string,
30
+ InputFileClass: typeof import("grammy").InputFile,
31
+ ): { file: string | import("grammy").InputFile } | { error: string } {
32
+ const remote = src.url ?? src.file_id;
33
+ if (remote) return { file: String(remote) };
34
+ // Fail with guidance the model can act on, not a raw ENOENT: a
35
+ // mistyped path is the most common media-send error, and naming
36
+ // the alternatives (url / file_id) teaches the recovery path.
37
+ if (!src.file_path)
38
+ return {
39
+ error: `${label}: provide file_path (workspace file), url (public), or file_id (seen in chat)`,
40
+ };
41
+ const filePath = expandFsPath(String(src.file_path));
42
+ if (!existsSync(filePath))
43
+ return {
44
+ error: `File not found: ${filePath} — check the workspace path, or send by url/file_id instead`,
45
+ };
46
+ const stat = statSync(filePath);
47
+ if (stat.size > 49 * 1024 * 1024)
48
+ return { error: "File too large (max 49MB)" };
49
+ const data = readFileSync(filePath);
50
+ return { file: new InputFileClass(data, basename(filePath)) };
51
+ }
52
+
15
53
  const sendMediaFile: TelegramActionHandlers[string] = async (
16
54
  body,
17
55
  chatId,
@@ -25,36 +63,13 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
25
63
  : undefined;
26
64
  const captionParseMode = caption ? ("HTML" as const) : undefined;
27
65
  gateway.incrementMessages(chatId);
28
- // Three media sources, in priority order: a public URL or a Telegram
29
- // file_id (both passed to the Bot API as a plain string — Telegram
30
- // fetches/reuses server-side, no local bytes involved), else a
31
- // workspace file path uploaded as multipart.
32
- const remote = body.url ?? body.file_id;
33
- let file: string | import("grammy").InputFile;
34
- if (remote) {
35
- file = String(remote);
36
- } else {
37
- // Fail with guidance the model can act on, not a raw ENOENT: a
38
- // mistyped path is the most common media-send error, and naming
39
- // the alternatives (url / file_id) teaches the recovery path.
40
- if (!body.file_path)
41
- return {
42
- ok: false,
43
- error: `${action}: provide file_path (workspace file), url (public), or file_id (seen in chat)`,
44
- };
45
- const filePath = expandFsPath(String(body.file_path));
46
- if (!existsSync(filePath))
47
- return {
48
- ok: false,
49
- error: `File not found: ${filePath} — check the workspace path, or send by url/file_id instead`,
50
- };
51
- const stat = statSync(filePath);
52
- if (stat.size > 49 * 1024 * 1024)
53
- return { ok: false, error: "File too large (max 49MB)" };
54
- const data = readFileSync(filePath);
55
- file = new InputFileClass(data, basename(filePath));
56
- }
66
+ const resolved = resolveMediaInput(body, action, InputFileClass);
67
+ if ("error" in resolved) return { ok: false, error: resolved.error };
68
+ const file = resolved.file;
57
69
  const rp = replyParams(body);
70
+ const opts = sendOpts(body, chatId);
71
+ // Spoiler blur only exists for visual media; Telegram rejects it elsewhere.
72
+ const spoiler = body.spoiler === true || undefined;
58
73
  let sent;
59
74
  switch (action) {
60
75
  case "send_file":
@@ -63,6 +78,7 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
63
78
  caption,
64
79
  parse_mode: captionParseMode,
65
80
  reply_parameters: rp,
81
+ ...opts,
66
82
  }),
67
83
  );
68
84
  break;
@@ -72,6 +88,8 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
72
88
  caption,
73
89
  parse_mode: captionParseMode,
74
90
  reply_parameters: rp,
91
+ has_spoiler: spoiler,
92
+ ...opts,
75
93
  }),
76
94
  );
77
95
  break;
@@ -81,6 +99,8 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
81
99
  caption,
82
100
  parse_mode: captionParseMode,
83
101
  reply_parameters: rp,
102
+ has_spoiler: spoiler,
103
+ ...opts,
84
104
  }),
85
105
  );
86
106
  break;
@@ -90,6 +110,8 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
90
110
  caption,
91
111
  parse_mode: captionParseMode,
92
112
  reply_parameters: rp,
113
+ has_spoiler: spoiler,
114
+ ...opts,
93
115
  }),
94
116
  );
95
117
  break;
@@ -101,6 +123,7 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
101
123
  reply_parameters: rp,
102
124
  title: body.title as string | undefined,
103
125
  performer: body.performer as string | undefined,
126
+ ...opts,
104
127
  }),
105
128
  );
106
129
  break;
@@ -110,6 +133,7 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
110
133
  caption,
111
134
  parse_mode: captionParseMode,
112
135
  reply_parameters: rp,
136
+ ...opts,
113
137
  }),
114
138
  );
115
139
  break;
@@ -125,6 +149,90 @@ export const mediaHandlers: TelegramActionHandlers = {
125
149
  send_voice: sendMediaFile,
126
150
  send_audio: sendMediaFile,
127
151
 
152
+ send_media_group: async (body, chatId, { bot, InputFileClass, gateway }) => {
153
+ const items = Array.isArray(body.media) ? body.media : [];
154
+ if (items.length < 2 || items.length > 10)
155
+ return {
156
+ ok: false,
157
+ error: `Albums need 2–10 media items (got ${items.length}).`,
158
+ };
159
+ const spoiler = body.spoiler === true || undefined;
160
+ type AlbumItem = {
161
+ type: "photo" | "video" | "document" | "audio";
162
+ media: string | import("grammy").InputFile;
163
+ caption?: string;
164
+ parse_mode?: "HTML";
165
+ has_spoiler?: boolean;
166
+ };
167
+ const media: AlbumItem[] = [];
168
+ for (const [i, raw] of items.entries()) {
169
+ const item = raw as MediaSource & { type?: unknown; caption?: unknown };
170
+ // Telegram albums mix photos and videos; documents/audio group only
171
+ // with their own kind. Pass the declared type through and let the API
172
+ // reject invalid mixes with its own (clear) error.
173
+ const type = String(item.type ?? "photo");
174
+ if (!["photo", "video", "document", "audio"].includes(type))
175
+ return {
176
+ ok: false,
177
+ error: `media[${i}]: type must be photo, video, document, or audio`,
178
+ };
179
+ const resolved = resolveMediaInput(item, `media[${i}]`, InputFileClass);
180
+ if ("error" in resolved) return { ok: false, error: resolved.error };
181
+ const caption = item.caption
182
+ ? markdownToTelegramHtml(String(item.caption))
183
+ : undefined;
184
+ media.push({
185
+ type: type as AlbumItem["type"],
186
+ media: resolved.file,
187
+ caption,
188
+ parse_mode: caption ? ("HTML" as const) : undefined,
189
+ ...(type === "photo" || type === "video"
190
+ ? { has_spoiler: spoiler }
191
+ : {}),
192
+ });
193
+ }
194
+ gateway.incrementMessages(chatId);
195
+ // The wrapper types each album as homogeneous; runtime validation above
196
+ // plus Telegram's own mixed-group errors cover what the cast waives.
197
+ const group = media as unknown as Parameters<
198
+ import("grammy").Bot["api"]["sendMediaGroup"]
199
+ >[1];
200
+ const sent = await withRetry(() =>
201
+ bot.api.sendMediaGroup(chatId, group, {
202
+ reply_parameters: replyParams(body),
203
+ ...sendOpts(body, chatId),
204
+ }),
205
+ );
206
+ return { ok: true, message_ids: sent.map((m) => m.message_id) };
207
+ },
208
+
209
+ send_video_note: async (body, chatId, { bot, InputFileClass, gateway }) => {
210
+ const resolved = resolveMediaInput(body, "send_video_note", InputFileClass);
211
+ if ("error" in resolved) return { ok: false, error: resolved.error };
212
+ gateway.incrementMessages(chatId);
213
+ // Round video bubbles take no caption; Telegram wants square video ≤60s.
214
+ const sent = await withRetry(() =>
215
+ bot.api.sendVideoNote(chatId, resolved.file, {
216
+ reply_parameters: replyParams(body),
217
+ ...sendOpts(body, chatId),
218
+ }),
219
+ );
220
+ return { ok: true, message_id: sent.message_id };
221
+ },
222
+
223
+ send_venue: async (body, chatId, { bot, gateway }) => {
224
+ gateway.incrementMessages(chatId);
225
+ const sent = await bot.api.sendVenue(
226
+ chatId,
227
+ Number(body.latitude),
228
+ Number(body.longitude),
229
+ String(body.title ?? ""),
230
+ String(body.address ?? ""),
231
+ { reply_parameters: replyParams(body), ...sendOpts(body, chatId) },
232
+ );
233
+ return { ok: true, message_id: sent.message_id };
234
+ },
235
+
128
236
  send_sticker: async (body, chatId, { bot, gateway }) => {
129
237
  // Three addressing modes: a concrete file_id, a public URL of a
130
238
  // .webp (Telegram fetches it server-side, like other media), or an
@@ -155,6 +263,7 @@ export const mediaHandlers: TelegramActionHandlers = {
155
263
  gateway.incrementMessages(chatId);
156
264
  const sent = await bot.api.sendSticker(chatId, fileId, {
157
265
  reply_parameters: replyParams(body),
266
+ ...sendOpts(body, chatId),
158
267
  });
159
268
  return { ok: true, message_id: sent.message_id };
160
269
  },
@@ -176,6 +285,7 @@ export const mediaHandlers: TelegramActionHandlers = {
176
285
  : undefined,
177
286
  explanation: body.explanation as string | undefined,
178
287
  reply_parameters: replyParams(body),
288
+ ...sendOpts(body, chatId),
179
289
  },
180
290
  );
181
291
  return { ok: true, message_id: sent.message_id };
@@ -187,7 +297,7 @@ export const mediaHandlers: TelegramActionHandlers = {
187
297
  chatId,
188
298
  Number(body.latitude),
189
299
  Number(body.longitude),
190
- { reply_parameters: replyParams(body) },
300
+ { reply_parameters: replyParams(body), ...sendOpts(body, chatId) },
191
301
  );
192
302
  return { ok: true, message_id: sent.message_id };
193
303
  },
@@ -201,6 +311,7 @@ export const mediaHandlers: TelegramActionHandlers = {
201
311
  {
202
312
  last_name: body.last_name as string | undefined,
203
313
  reply_parameters: replyParams(body),
314
+ ...sendOpts(body, chatId),
204
315
  },
205
316
  );
206
317
  return { ok: true, message_id: sent.message_id };
@@ -213,6 +324,7 @@ export const mediaHandlers: TelegramActionHandlers = {
213
324
  (body.emoji as string) || "🎲",
214
325
  {
215
326
  reply_parameters: replyParams(body),
327
+ ...sendOpts(body, chatId),
216
328
  },
217
329
  );
218
330
  return {
@@ -18,9 +18,11 @@ import {
18
18
  import {
19
19
  noteRichMessageFailure,
20
20
  richMessagesAvailable,
21
+ sendOpts,
21
22
  sendText,
22
23
  toPositiveId,
23
24
  } from "./shared.js";
25
+ import { resolveThreadId } from "../topics.js";
24
26
  import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
25
27
 
26
28
  // ── Inline keyboards ─────────────────────────────────────────────────────────
@@ -201,7 +203,14 @@ export const messagingHandlers: TelegramActionHandlers = {
201
203
  const text = String(body.text ?? "");
202
204
  const replyTo = toPositiveId(body.reply_to_message_id);
203
205
  gateway.incrementMessages(chatId);
204
- const msgId = await withRetry(() => sendText(bot, chatId, text, replyTo));
206
+ const extra = {
207
+ ...sendOpts(body, chatId),
208
+ link_preview_options:
209
+ body.no_link_preview === true ? { is_disabled: true } : undefined,
210
+ };
211
+ const msgId = await withRetry(() =>
212
+ sendText(bot, chatId, text, replyTo, undefined, extra),
213
+ );
205
214
  return { ok: true, message_id: msgId };
206
215
  },
207
216
 
@@ -249,6 +258,23 @@ export const messagingHandlers: TelegramActionHandlers = {
249
258
  ok: false,
250
259
  error: `Text too long (max ${TELEGRAM_MAX_TEXT})`,
251
260
  };
261
+ // Media messages have captions, not text — editMessageText on them fails
262
+ // with "there is no text in the message to edit".
263
+ if (body.is_caption === true) {
264
+ await withRetry(async () => {
265
+ try {
266
+ await bot.api.editMessageCaption(chatId, Number(body.message_id), {
267
+ caption: markdownToTelegramHtml(text),
268
+ parse_mode: "HTML",
269
+ });
270
+ } catch {
271
+ await bot.api.editMessageCaption(chatId, Number(body.message_id), {
272
+ caption: text,
273
+ });
274
+ }
275
+ });
276
+ return { ok: true };
277
+ }
252
278
  await withRetry(async () => {
253
279
  if (richMessagesAvailable()) {
254
280
  try {
@@ -275,6 +301,19 @@ export const messagingHandlers: TelegramActionHandlers = {
275
301
  },
276
302
 
277
303
  delete_message: async (body, chatId, { bot }) => {
304
+ // Bulk form: deleteMessages takes up to 100 ids per call and skips ids
305
+ // it can't delete instead of failing the batch.
306
+ if (Array.isArray(body.message_ids) && body.message_ids.length > 0) {
307
+ const ids = body.message_ids
308
+ .map(toPositiveId)
309
+ .filter((n): n is number => n !== undefined);
310
+ if (ids.length === 0)
311
+ return { ok: false, error: "message_ids contains no valid ids" };
312
+ for (let i = 0; i < ids.length; i += 100) {
313
+ await bot.api.deleteMessages(chatId, ids.slice(i, i + 100));
314
+ }
315
+ return { ok: true, deleted: ids.length };
316
+ }
278
317
  await bot.api.deleteMessage(chatId, Number(body.message_id));
279
318
  return { ok: true };
280
319
  },
@@ -293,22 +332,42 @@ export const messagingHandlers: TelegramActionHandlers = {
293
332
  },
294
333
 
295
334
  forward_message: async (body, chatId, { bot }) => {
296
- if (body.to_chat_id && Number(body.to_chat_id) !== chatId)
297
- return { ok: false, error: "Cross-chat forwarding not allowed." };
335
+ // Cross-chat by explicit override; both ends default to the current
336
+ // chat. The bot can only ever read from / deliver to chats it is in —
337
+ // Telegram enforces membership on both ids.
338
+ const from = Number(body.from_chat_id ?? chatId);
339
+ const to = Number(body.to_chat_id ?? chatId);
340
+ const ids = Array.isArray(body.message_ids)
341
+ ? body.message_ids
342
+ .map(toPositiveId)
343
+ .filter((n): n is number => n !== undefined)
344
+ : [];
345
+ if (ids.length > 0) {
346
+ // forwardMessages keeps album grouping and skips unfindable ids.
347
+ const sent = await bot.api.forwardMessages(to, from, ids);
348
+ return { ok: true, message_ids: sent.map((m) => m.message_id) };
349
+ }
298
350
  const sent = await bot.api.forwardMessage(
299
- chatId,
300
- chatId,
351
+ to,
352
+ from,
301
353
  Number(body.message_id),
302
354
  );
303
355
  return { ok: true, message_id: sent.message_id };
304
356
  },
305
357
 
306
358
  copy_message: async (body, chatId, { bot }) => {
307
- const sent = await bot.api.copyMessage(
308
- chatId,
309
- chatId,
310
- Number(body.message_id),
311
- );
359
+ const from = Number(body.from_chat_id ?? chatId);
360
+ const to = Number(body.to_chat_id ?? chatId);
361
+ const ids = Array.isArray(body.message_ids)
362
+ ? body.message_ids
363
+ .map(toPositiveId)
364
+ .filter((n): n is number => n !== undefined)
365
+ : [];
366
+ if (ids.length > 0) {
367
+ const sent = await bot.api.copyMessages(to, from, ids);
368
+ return { ok: true, message_ids: sent.map((m) => m.message_id) };
369
+ }
370
+ const sent = await bot.api.copyMessage(to, from, Number(body.message_id));
312
371
  return { ok: true, message_id: sent.message_id };
313
372
  },
314
373
 
@@ -316,6 +375,7 @@ export const messagingHandlers: TelegramActionHandlers = {
316
375
  await bot.api.sendChatAction(
317
376
  chatId,
318
377
  String(body.chat_action ?? "typing") as "typing",
378
+ { message_thread_id: resolveThreadId(body, chatId) },
319
379
  );
320
380
  return { ok: true };
321
381
  },
@@ -329,9 +389,14 @@ export const messagingHandlers: TelegramActionHandlers = {
329
389
  if ("error" in built) return { ok: false, error: built.error };
330
390
  const keyboard = built.keyboard;
331
391
  gateway.incrementMessages(chatId);
332
- const messageId = await sendText(bot, chatId, text, undefined, {
333
- inline_keyboard: keyboard,
334
- });
392
+ const messageId = await sendText(
393
+ bot,
394
+ chatId,
395
+ text,
396
+ toPositiveId(body.reply_to_message_id),
397
+ { inline_keyboard: keyboard },
398
+ sendOpts(body, chatId),
399
+ );
335
400
  return { ok: true, message_id: messageId };
336
401
  },
337
402
 
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Moderation actions — the `moderate` tool's member/chat/topic operations,
3
+ * plus profile-photo lookup.
4
+ *
5
+ * Handlers call the Bot API directly and let Telegram's own errors surface
6
+ * (missing admin rights come back as descriptive 400s the model can read);
7
+ * pre-checking rights here would just race the real check.
8
+ */
9
+
10
+ import type { ChatPermissions } from "grammy/types";
11
+ import { toPositiveId } from "./shared.js";
12
+ import { resolveMediaInput } from "./media.js";
13
+ import { clearJoinRequest, listJoinRequests } from "../join-requests.js";
14
+ import type { TelegramActionHandlers } from "./types.js";
15
+
16
+ /** Seconds-from-now → Telegram until_date, honouring "omit = forever". */
17
+ function untilDate(minutes: unknown): number | undefined {
18
+ const n = Number(minutes);
19
+ if (!Number.isFinite(n) || n <= 0) return undefined;
20
+ return Math.floor(Date.now() / 1000) + Math.round(n * 60);
21
+ }
22
+
23
+ /**
24
+ * Friendly permission names → ChatPermissions. `send_media` fans out to all
25
+ * the per-kind media flags; raw `can_*` keys pass through untouched so the
26
+ * full API surface stays reachable.
27
+ */
28
+ function toChatPermissions(raw: Record<string, boolean>): ChatPermissions {
29
+ const out: Record<string, boolean> = {};
30
+ const alias: Record<string, string[]> = {
31
+ send_messages: ["can_send_messages"],
32
+ send_media: [
33
+ "can_send_audios",
34
+ "can_send_documents",
35
+ "can_send_photos",
36
+ "can_send_videos",
37
+ "can_send_video_notes",
38
+ "can_send_voice_notes",
39
+ ],
40
+ send_polls: ["can_send_polls"],
41
+ send_other: ["can_send_other_messages"],
42
+ web_previews: ["can_add_web_page_previews"],
43
+ change_info: ["can_change_info"],
44
+ invite_users: ["can_invite_users"],
45
+ pin_messages: ["can_pin_messages"],
46
+ manage_topics: ["can_manage_topics"],
47
+ };
48
+ for (const [key, value] of Object.entries(raw)) {
49
+ if (typeof value !== "boolean") continue;
50
+ for (const target of alias[key] ?? (key.startsWith("can_") ? [key] : [])) {
51
+ out[target] = value;
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Everything a mute takes away; unmute grants it back. */
58
+ const FULL_MEMBER_PERMISSIONS: ChatPermissions = toChatPermissions({
59
+ send_messages: true,
60
+ send_media: true,
61
+ send_polls: true,
62
+ send_other: true,
63
+ web_previews: true,
64
+ });
65
+
66
+ /** The workaday admin kit `promote` grants; demote sets all of these false. */
67
+ const DEFAULT_ADMIN_RIGHTS = {
68
+ can_manage_chat: true,
69
+ can_delete_messages: true,
70
+ can_restrict_members: true,
71
+ can_pin_messages: true,
72
+ can_invite_users: true,
73
+ can_change_info: true,
74
+ can_manage_topics: true,
75
+ can_manage_video_chats: true,
76
+ };
77
+
78
+ export const moderationHandlers: TelegramActionHandlers = {
79
+ moderate: async (body, chatId, ctx) => {
80
+ const { bot, InputFileClass } = ctx;
81
+ const op = String(body.op ?? "");
82
+ const userId = toPositiveId(body.user_id);
83
+ const needsUser = [
84
+ "ban",
85
+ "unban",
86
+ "mute",
87
+ "unmute",
88
+ "promote",
89
+ "demote",
90
+ "set_admin_title",
91
+ "approve_join_request",
92
+ "decline_join_request",
93
+ ];
94
+ if (needsUser.includes(op) && userId === undefined)
95
+ return { ok: false, error: `${op}: user_id is required` };
96
+
97
+ switch (op) {
98
+ case "ban":
99
+ await bot.api.banChatMember(chatId, userId!, {
100
+ until_date: untilDate(body.minutes),
101
+ revoke_messages: body.delete_messages === true || undefined,
102
+ });
103
+ return { ok: true };
104
+ case "unban":
105
+ // only_if_banned keeps this from kicking a current member.
106
+ await bot.api.unbanChatMember(chatId, userId!, {
107
+ only_if_banned: true,
108
+ });
109
+ return { ok: true };
110
+ case "mute":
111
+ await bot.api.restrictChatMember(
112
+ chatId,
113
+ userId!,
114
+ { can_send_messages: false },
115
+ { until_date: untilDate(body.minutes) },
116
+ );
117
+ return { ok: true };
118
+ case "unmute":
119
+ await bot.api.restrictChatMember(
120
+ chatId,
121
+ userId!,
122
+ FULL_MEMBER_PERMISSIONS,
123
+ );
124
+ return { ok: true };
125
+ case "promote":
126
+ await bot.api.promoteChatMember(chatId, userId!, DEFAULT_ADMIN_RIGHTS);
127
+ return { ok: true };
128
+ case "demote": {
129
+ const revoked = Object.fromEntries(
130
+ Object.keys(DEFAULT_ADMIN_RIGHTS).map((k) => [k, false]),
131
+ );
132
+ await bot.api.promoteChatMember(chatId, userId!, revoked);
133
+ return { ok: true };
134
+ }
135
+ case "set_admin_title":
136
+ await bot.api.setChatAdministratorCustomTitle(
137
+ chatId,
138
+ userId!,
139
+ String(body.title ?? ""),
140
+ );
141
+ return { ok: true };
142
+ case "set_permissions": {
143
+ const raw = body.permissions;
144
+ if (!raw || typeof raw !== "object")
145
+ return { ok: false, error: "set_permissions: permissions required" };
146
+ await bot.api.setChatPermissions(
147
+ chatId,
148
+ toChatPermissions(raw as Record<string, boolean>),
149
+ );
150
+ return { ok: true };
151
+ }
152
+ case "create_invite_link": {
153
+ const expireMin = Number(body.expire_minutes);
154
+ const link = await bot.api.createChatInviteLink(chatId, {
155
+ name: body.title ? String(body.title) : undefined,
156
+ expire_date:
157
+ Number.isFinite(expireMin) && expireMin > 0
158
+ ? Math.floor(Date.now() / 1000) + Math.round(expireMin * 60)
159
+ : undefined,
160
+ member_limit: toPositiveId(body.member_limit),
161
+ });
162
+ return { ok: true, invite_link: link.invite_link };
163
+ }
164
+ case "revoke_invite_link": {
165
+ if (!body.link)
166
+ return { ok: false, error: "revoke_invite_link: link required" };
167
+ await bot.api.revokeChatInviteLink(chatId, String(body.link));
168
+ return { ok: true };
169
+ }
170
+ case "approve_join_request":
171
+ await bot.api.approveChatJoinRequest(chatId, userId!);
172
+ clearJoinRequest(chatId, userId!);
173
+ return { ok: true };
174
+ case "decline_join_request":
175
+ await bot.api.declineChatJoinRequest(chatId, userId!);
176
+ clearJoinRequest(chatId, userId!);
177
+ return { ok: true };
178
+ case "list_join_requests":
179
+ return { ok: true, requests: listJoinRequests(chatId) };
180
+ case "set_chat_photo": {
181
+ const resolved = resolveMediaInput(
182
+ body,
183
+ "set_chat_photo",
184
+ InputFileClass,
185
+ );
186
+ if ("error" in resolved) return { ok: false, error: resolved.error };
187
+ if (typeof resolved.file === "string")
188
+ return {
189
+ ok: false,
190
+ error: "set_chat_photo needs a local file_path (no url/file_id)",
191
+ };
192
+ await bot.api.setChatPhoto(chatId, resolved.file);
193
+ return { ok: true };
194
+ }
195
+ case "delete_chat_photo":
196
+ await bot.api.deleteChatPhoto(chatId);
197
+ return { ok: true };
198
+ case "unpin_all":
199
+ await bot.api.unpinAllChatMessages(chatId);
200
+ return { ok: true };
201
+ case "leave_chat":
202
+ await bot.api.leaveChat(chatId);
203
+ return { ok: true };
204
+ case "create_topic": {
205
+ if (!body.title)
206
+ return { ok: false, error: "create_topic: title required" };
207
+ const topic = await bot.api.createForumTopic(
208
+ chatId,
209
+ String(body.title),
210
+ );
211
+ return { ok: true, thread_id: topic.message_thread_id };
212
+ }
213
+ case "edit_topic": {
214
+ const threadId = toPositiveId(body.thread_id);
215
+ if (threadId === undefined)
216
+ return { ok: false, error: "edit_topic: thread_id required" };
217
+ await bot.api.editForumTopic(chatId, threadId, {
218
+ name: body.title ? String(body.title) : undefined,
219
+ });
220
+ return { ok: true };
221
+ }
222
+ case "close_topic":
223
+ case "reopen_topic":
224
+ case "delete_topic": {
225
+ const threadId = toPositiveId(body.thread_id);
226
+ if (threadId === undefined)
227
+ return { ok: false, error: `${op}: thread_id required` };
228
+ if (op === "close_topic")
229
+ await bot.api.closeForumTopic(chatId, threadId);
230
+ else if (op === "reopen_topic")
231
+ await bot.api.reopenForumTopic(chatId, threadId);
232
+ else await bot.api.deleteForumTopic(chatId, threadId);
233
+ return { ok: true };
234
+ }
235
+ default:
236
+ return { ok: false, error: `Unknown moderation op: ${op}` };
237
+ }
238
+ },
239
+
240
+ get_user_profile_photos: async (body, _chatId, { bot }) => {
241
+ const userId = toPositiveId(body.user_id);
242
+ if (userId === undefined)
243
+ return { ok: false, error: "user_id is required" };
244
+ const limit = toPositiveId(body.limit) ?? 5;
245
+ const photos = await bot.api.getUserProfilePhotos(userId, {
246
+ limit: Math.min(limit, 100),
247
+ });
248
+ return {
249
+ ok: true,
250
+ total: photos.total_count,
251
+ // Largest size of each photo; its file_id sends/downloads like media.
252
+ file_ids: photos.photos.map((sizes) => sizes[sizes.length - 1].file_id),
253
+ };
254
+ },
255
+ };