talon-agent 3.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.20.0",
3
+ "version": "3.22.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -102,7 +102,7 @@
102
102
  "@grammyjs/transformer-throttler": "^1.2.1",
103
103
  "@kilocode/sdk": "^7.2.22",
104
104
  "@modelcontextprotocol/sdk": "^1.29.0",
105
- "@openai/agents": "^0.14.0",
105
+ "@openai/agents": "^0.16.0",
106
106
  "@openai/codex-sdk": "^0.147.0",
107
107
  "@opencode-ai/sdk": "^1.17.4",
108
108
  "@playwright/mcp": "0.0.79",
@@ -113,11 +113,11 @@
113
113
  "cross-spawn": "^7.0.6",
114
114
  "discord.js": "^14.16.3",
115
115
  "file-type": "^22.0.1",
116
- "grammy": "^1.42.0",
116
+ "grammy": "^1.45.1",
117
117
  "liquidjs": "^10.27.0",
118
118
  "marked": "^18.0.0",
119
119
  "mem0ai": "^3.0.13",
120
- "openai": "^6.46.0",
120
+ "openai": "^7.5.0",
121
121
  "p-retry": "^8.0.0",
122
122
  "picocolors": "^1.1.1",
123
123
  "pino": "^10.3.1",
package/prompts/base.md CHANGED
@@ -1,12 +1,6 @@
1
1
  Be concise and direct. Lead with the substance, keep it as short as the moment needs, and stop when it's said.
2
2
 
3
- ## Conversation
4
-
5
- - Answer first; context and caveats after, and only when they change something for the reader.
6
- - Let depth follow the question: a quick ask gets a line or two, a real problem gets real work. When unsure, start small — people ask for more when they want it.
7
- - Chat is not a document. Plain sentences usually beat headings, bullet cascades, and closing summaries; reach for structure only when it genuinely clarifies.
8
- - Don't narrate your process or pad with filler — do the thing, then share what matters.
9
- - A short clarifying question beats a long answer to the wrong question, but only when the ambiguity is real.
3
+ How you talk — voice, stances, what never to say — is the Identity section above. It applies on every backend and isn't repeated here.
10
4
 
11
5
  ## Tools
12
6
 
@@ -1,6 +1,6 @@
1
1
  ## Who you are
2
2
 
3
- You're a Talon agent — a peer with tools, not a service desk. The model and tools available to you depend on the active backend; only the tools listed below this prompt actually exist for this run. Tools for talking to your current platform (send, react, and the rest) are always provided by the frontend.
3
+ You're a Talon agent — a peer with tools, not a service desk. People talk to you all day; be someone worth talking to. The model and tools available to you depend on the active backend; only the tools listed below this prompt actually exist for this run. Tools for talking to your current platform (send, react, and the rest) are always provided by the frontend.
4
4
 
5
5
  ## Voice
6
6
 
@@ -10,13 +10,15 @@ Length follows the question, not habit: a quick ask gets a line or two, a real p
10
10
 
11
11
  Have opinions and give reasons. "I'd use X, because Y" beats five options with no recommendation.
12
12
 
13
- Match the room. Casual chat gets casual replies, technical questions get precise answers, and a tense thread doesn't need you adding heat. Follow up on what's genuinely interesting — not out of habit.
13
+ Engage with what was actually said, not the generic shape of it. The detail someone mentions in passing is often the interesting part; picking it up is the difference between a conversation and a ticket.
14
14
 
15
- Be expressive where the platform allows — emoji, reactions, stickers, humour — as seasoning, not the meal.
15
+ Match the room. Casual chat gets casual replies, technical questions get precise answers, and a tense thread doesn't need you adding heat. Humour, emoji, reactions, stickers — wherever the platform has them, use them the way a person would: as seasoning, not the meal.
16
+
17
+ Sound like yourself. Plain words, contractions, the occasional aside. A reply that reads like a person thinking beats one that reads like a product performing.
16
18
 
17
19
  ## Stances
18
20
 
19
- Situations are what define a voice. Take these positions.
21
+ Voice shows up in the awkward moments. Take these positions.
20
22
 
21
23
  **Their plan is bad.** Say what's wrong in a sentence or two, then do the work as asked. Don't refuse to engage, don't lecture, and don't quietly do it a different way instead.
22
24
 
@@ -30,7 +32,7 @@ Situations are what define a voice. Take these positions.
30
32
 
31
33
  **The request is ambiguous.** Make the call a careful colleague would make, and say which call you made. Ask only when different readings would mean materially different work.
32
34
 
33
- **You have nothing to add.** Then don't add it. "ok", "thanks", "lol" want a reaction or silence, not a reply. In groups you're a participant, not a host — don't answer for other people, and let conversations that aren't about you flow past.
35
+ **You have nothing to add.** Then don't. "ok", "thanks", "lol" want a reaction or silence, not a reply. In groups you're a participant, not a host — don't answer for other people, and let conversations that aren't about you flow past.
34
36
 
35
37
  ## Never
36
38
 
package/src/bootstrap.ts CHANGED
@@ -415,6 +415,14 @@ export async function initBackendAndDispatcher(
415
415
  );
416
416
  return { model, backendId: beId };
417
417
  },
418
+ // ...and when that ambient backend turns out to be one that can't host an
419
+ // isolated run (e.g. the chat was switched to a provider with no background
420
+ // capability), the job reruns on the heartbeat role backend instead of
421
+ // being skipped.
422
+ resolveJobFallback: () => ({
423
+ backendId: config.heartbeatBackend ?? config.backend,
424
+ model: config.heartbeatModel ?? config.model ?? null,
425
+ }),
418
426
  });
419
427
  initTriggers({ execute: dispatcherExecute });
420
428
  resumeTriggersAfterRestart().catch((err) =>
@@ -62,6 +62,13 @@ type CronDeps = {
62
62
  resolveChatModel: (
63
63
  chatId: string,
64
64
  ) => Promise<{ model: string | null; backendId: string }>;
65
+ /**
66
+ * Resolve the deployment's background-capable role backend (heartbeat, else
67
+ * the configured default). Used as a fallback for query jobs that inherited
68
+ * the chat's ambient backend and found it can't run isolated jobs — a
69
+ * `/model` switch in the chat shouldn't silently disable the schedule.
70
+ */
71
+ resolveJobFallback?: () => { model: string | null; backendId: string };
65
72
  };
66
73
 
67
74
  let deps: CronDeps | null = null;
@@ -473,6 +480,10 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
473
480
  // chat's backend + active model.
474
481
  let backendId: string;
475
482
  let model: string | null;
483
+ // A job that pinned its own provider is honoured as written — no fallback.
484
+ // One that inherited the chat's ambient backend gets a safety net, because
485
+ // that backend can change under it at any time (`/model`, a rebind).
486
+ let fallback: { backendId: string; model: string } | undefined;
476
487
  if (job.provider) {
477
488
  backendId = job.provider;
478
489
  model = job.model ?? null;
@@ -480,6 +491,10 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
480
491
  const chat = await deps.resolveChatModel(job.chatId);
481
492
  backendId = chat.backendId;
482
493
  model = job.model ?? chat.model;
494
+ const candidate = deps.resolveJobFallback?.();
495
+ if (candidate?.model) {
496
+ fallback = { backendId: candidate.backendId, model: candidate.model };
497
+ }
483
498
  }
484
499
  if (!model) {
485
500
  throw new Error(
@@ -502,6 +517,7 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
502
517
  label: job.name,
503
518
  kind: "cron",
504
519
  timeoutMs: CRON_JOB_TIMEOUT_MS,
520
+ ...(fallback ? { fallback } : {}),
505
521
  });
506
522
  if (result.status === "skipped") {
507
523
  await deps.sendMessage(
@@ -51,6 +51,19 @@ export interface JobOneShotParams {
51
51
  readonly kind: JobKind;
52
52
  /** Optional override of the hard timeout. */
53
53
  readonly timeoutMs?: number;
54
+ /**
55
+ * Backend to retry on when the primary one can't host an isolated run.
56
+ *
57
+ * A cron `query` job with no provider override inherits the chat's *ambient*
58
+ * backend, which any `/model` switch can change out from under it — including
59
+ * to a provider with no background capability (or one where the chat's model
60
+ * isn't selectable). That has nothing to do with the job, so rather than skip
61
+ * it, fall back to the deployment's background-capable role backend
62
+ * (heartbeat, else the configured default). Omitted for jobs that pinned
63
+ * their own provider: an explicit choice is honoured or skipped, never
64
+ * silently rerouted.
65
+ */
66
+ readonly fallback?: { readonly backendId: string; readonly model: string };
54
67
  }
55
68
 
56
69
  export type JobOneShotResult =
@@ -75,7 +88,17 @@ async function openJobLog(
75
88
  return appendLog;
76
89
  }
77
90
 
78
- function skipJob(params: JobOneShotParams, reason: string): JobOneShotResult {
91
+ // The warning is deferred to runJobOneShot: an attempt that fails here may
92
+ // still succeed on the fallback backend, and a run that ultimately ran must
93
+ // not leave a "skipped" warning in the logs.
94
+ function skipJob(reason: string): JobOneShotResult {
95
+ return { status: "skipped", reason };
96
+ }
97
+
98
+ function reportSkip(
99
+ params: JobOneShotParams,
100
+ reason: string,
101
+ ): JobOneShotResult {
79
102
  logWarn(
80
103
  params.kind === "cron" ? "cron" : "triggers",
81
104
  `isolated job "${params.label}" skipped: ${reason}`,
@@ -84,19 +107,20 @@ function skipJob(params: JobOneShotParams, reason: string): JobOneShotResult {
84
107
  }
85
108
 
86
109
  /**
87
- * Run a trigger/cron wake-up as an isolated one-shot on a different backend.
88
- * Acquires the target backend on demand and always releases it afterwards.
110
+ * One attempt on one backend: acquire it, check it can actually host an
111
+ * isolated run, execute, and always release it afterwards.
89
112
  */
90
- export async function runJobOneShot(
113
+ async function attemptJobOneShot(
91
114
  params: JobOneShotParams,
115
+ backendId: string,
116
+ model: string,
92
117
  ): Promise<JobOneShotResult> {
93
118
  let acquired: Awaited<ReturnType<typeof acquireBackendInstance>>;
94
119
  try {
95
- acquired = await acquireBackendInstance(params.backendId);
120
+ acquired = await acquireBackendInstance(backendId);
96
121
  } catch (err) {
97
122
  return skipJob(
98
- params,
99
- `provider "${params.backendId}" is unavailable: ${err instanceof Error ? err.message : String(err)}`,
123
+ `provider "${backendId}" is unavailable: ${err instanceof Error ? err.message : String(err)}`,
100
124
  );
101
125
  }
102
126
 
@@ -105,32 +129,29 @@ export async function runJobOneShot(
105
129
  const background = backend.background;
106
130
  if (!background) {
107
131
  return skipJob(
108
- params,
109
- `provider "${params.backendId}" can't run isolated jobs (no background capability).`,
132
+ `provider "${backendId}" can't run isolated jobs (no background capability).`,
110
133
  );
111
134
  }
112
135
 
113
136
  let modelValid = false;
114
137
  try {
115
- modelValid = await isModelValidForBackend(backend, params.model);
138
+ modelValid = await isModelValidForBackend(backend, model);
116
139
  } catch (err) {
117
140
  return skipJob(
118
- params,
119
- `could not validate model "${params.model}" on provider "${params.backendId}": ${err instanceof Error ? err.message : String(err)}`,
141
+ `could not validate model "${model}" on provider "${backendId}": ${err instanceof Error ? err.message : String(err)}`,
120
142
  );
121
143
  }
122
144
  if (!modelValid) {
123
145
  return skipJob(
124
- params,
125
- `model "${params.model}" is not selectable on provider "${params.backendId}".`,
146
+ `model "${model}" is not selectable on provider "${backendId}".`,
126
147
  );
127
148
  }
128
149
 
129
150
  const appendLog = await openJobLog(
130
151
  params.kind,
131
152
  params.label,
132
- params.backendId,
133
- params.model,
153
+ backendId,
154
+ model,
134
155
  );
135
156
 
136
157
  const abortController = new AbortController();
@@ -140,7 +161,7 @@ export async function runJobOneShot(
140
161
  chatId: params.chatId,
141
162
  abort: () => abortController.abort(),
142
163
  });
143
- task.bind({ model: params.model, backendId: params.backendId });
164
+ task.bind({ model, backendId });
144
165
 
145
166
  const oneShot: OneShotAgentParams = {
146
167
  prompt: params.payload,
@@ -151,7 +172,7 @@ export async function runJobOneShot(
151
172
  ),
152
173
  contextLabel: JOB_CONTEXT_LABEL,
153
174
  workspace: dirs.workspace,
154
- model: params.model,
175
+ model,
155
176
  abortController,
156
177
  appendLog,
157
178
  };
@@ -172,10 +193,49 @@ export async function runJobOneShot(
172
193
  }
173
194
  log(
174
195
  params.kind === "cron" ? "cron" : "triggers",
175
- `isolated job "${params.label}" ran on ${params.backendId}/${params.model}`,
196
+ `isolated job "${params.label}" ran on ${backendId}/${model}`,
176
197
  );
177
198
  return { status: "ran" };
178
199
  } finally {
179
200
  await release();
180
201
  }
181
202
  }
203
+
204
+ /**
205
+ * Run a trigger/cron wake-up as an isolated one-shot.
206
+ *
207
+ * Tries the requested backend first. When that backend can't host the run at
208
+ * all — no background capability, unavailable, or the inherited model isn't
209
+ * selectable on it — and a distinct {@link JobOneShotParams.fallback} was
210
+ * supplied, the job is retried there instead of being skipped. A job is only
211
+ * reported as skipped once every candidate has been ruled out.
212
+ */
213
+ export async function runJobOneShot(
214
+ params: JobOneShotParams,
215
+ ): Promise<JobOneShotResult> {
216
+ const first = await attemptJobOneShot(params, params.backendId, params.model);
217
+ if (first.status === "ran") return first;
218
+
219
+ const { fallback } = params;
220
+ if (
221
+ !fallback ||
222
+ (fallback.backendId === params.backendId && fallback.model === params.model)
223
+ ) {
224
+ return reportSkip(params, first.reason);
225
+ }
226
+
227
+ log(
228
+ params.kind === "cron" ? "cron" : "triggers",
229
+ `isolated job "${params.label}": ${params.backendId}/${params.model} can't host it (${first.reason}) — retrying on ${fallback.backendId}/${fallback.model}`,
230
+ );
231
+ const second = await attemptJobOneShot(
232
+ params,
233
+ fallback.backendId,
234
+ fallback.model,
235
+ );
236
+ if (second.status === "ran") return second;
237
+ return reportSkip(
238
+ params,
239
+ `${first.reason} Fallback ${fallback.backendId}/${fallback.model} also unusable: ${second.reason}`,
240
+ );
241
+ }
@@ -22,6 +22,7 @@ import { webTools } from "./web.js";
22
22
  import { adminTools } from "./admin.js";
23
23
  import { modelTools } from "./models.js";
24
24
  import { meshTools } from "./mesh.js";
25
+ import { moderationTools } from "./moderation.js";
25
26
  import { nativeTools } from "./native.js";
26
27
 
27
28
  /** All built-in tool definitions. */
@@ -41,6 +42,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
41
42
  ...adminTools,
42
43
  ...modelTools,
43
44
  ...meshTools,
45
+ ...moderationTools,
44
46
  ];
45
47
 
46
48
  /**
@@ -135,7 +135,10 @@ Examples:
135
135
  Dice: send(type="dice")
136
136
  Location: send(type="location", latitude=37.7749, longitude=-122.4194)
137
137
  Sticker by feeling: send(type="sticker", emoji="😂") — picks a matching sticker from your saved packs (add set_name to pin one pack)
138
- Sticker by id: send(type="sticker", file_id="CAACAgI...")`,
138
+ Sticker by id: send(type="sticker", file_id="CAACAgI...")
139
+ Album: send(type="album", media=[{"type":"photo","file_path":"a.jpg"},{"type":"photo","url":"https://…/b.jpg","caption":"the good one"}]) — 2-10 photos/videos as one grouped message
140
+ Round video: send(type="video_note", file_path="/path/clip.mp4") — circular video bubble (square video, ≤60s)
141
+ Venue: send(type="venue", latitude=53.34, longitude=-6.26, title="The Long Hall", address="51 South Great George's St")`,
139
142
  schema: {
140
143
  type: z
141
144
  .enum([
@@ -151,6 +154,9 @@ Examples:
151
154
  "location",
152
155
  "contact",
153
156
  "dice",
157
+ "album",
158
+ "video_note",
159
+ "venue",
154
160
  ])
155
161
  .describe("Content type to send"),
156
162
  text: z
@@ -208,7 +214,30 @@ Examples:
208
214
  phone_number: z.string().optional().describe("Contact phone"),
209
215
  first_name: z.string().optional().describe("Contact first name"),
210
216
  last_name: z.string().optional().describe("Contact last name"),
211
- title: z.string().optional().describe("Audio title (for type=audio)"),
217
+ title: z
218
+ .string()
219
+ .optional()
220
+ .describe("Audio title (type=audio) or venue name (type=venue)"),
221
+ address: z
222
+ .string()
223
+ .optional()
224
+ .describe("Venue street address (for type=venue)"),
225
+ media: z
226
+ .array(
227
+ z.object({
228
+ type: z
229
+ .enum(["photo", "video", "document", "audio"])
230
+ .describe("Kind of this album item"),
231
+ file_path: z.string().optional(),
232
+ url: z.string().optional(),
233
+ file_id: z.string().optional(),
234
+ caption: z.string().optional(),
235
+ }),
236
+ )
237
+ .optional()
238
+ .describe(
239
+ "Album items (for type=album): 2-10 entries, each sourced from file_path, url, or file_id. Photos and videos mix; documents/audio group only with their own kind.",
240
+ ),
212
241
  performer: z
213
242
  .string()
214
243
  .optional()
@@ -230,9 +259,41 @@ Examples:
230
259
  .describe(
231
260
  "Target chat ID. Omit to send to the current chat (chat mode). Required from heartbeat mode where there is no ambient chat — use list_chats or known IDs from memory. Telegram supergroup/channel IDs are negative (e.g. -1001426819337); user DMs are positive.",
232
261
  ),
262
+ silent: z
263
+ .boolean()
264
+ .optional()
265
+ .describe("Send without a notification sound (Telegram)"),
266
+ protect: z
267
+ .boolean()
268
+ .optional()
269
+ .describe("Protect content from forwarding and saving (Telegram)"),
270
+ spoiler: z
271
+ .boolean()
272
+ .optional()
273
+ .describe(
274
+ "Blur photo/video/animation behind a spoiler tap-to-reveal (Telegram)",
275
+ ),
276
+ no_link_preview: z
277
+ .boolean()
278
+ .optional()
279
+ .describe("Disable the link preview for type=text (Telegram)"),
280
+ thread_id: z
281
+ .union([z.number(), z.literal("general")])
282
+ .optional()
283
+ .describe(
284
+ 'Forum topic to post into (Telegram supergroups with topics). Defaults to the topic the conversation is happening in; pass "general" to force the General topic.',
285
+ ),
233
286
  },
234
287
  execute: async (params, bridge) => {
235
288
  const { type } = params;
289
+ // Delivery modifiers every Telegram send action understands. Harmless
290
+ // on frontends that don't (handlers read only the fields they know).
291
+ const mods = {
292
+ silent: params.silent,
293
+ protect: params.protect,
294
+ spoiler: params.spoiler,
295
+ thread_id: params.thread_id,
296
+ };
236
297
  // Thread chat_id through to every bridge call so heartbeat / dream
237
298
  // outbound (no ambient chat) gets routed by the explicit chat_id.
238
299
  // `createBridge` at src/core/tools/bridge.ts:29 reads
@@ -253,6 +314,7 @@ Examples:
253
314
  delay_seconds: params.delay_seconds,
254
315
  rows: params.buttons,
255
316
  reply_to_message_id: params.reply_to,
317
+ ...mods,
256
318
  chat_id,
257
319
  });
258
320
  }
@@ -261,12 +323,15 @@ Examples:
261
323
  text: params.text,
262
324
  rows: params.buttons,
263
325
  reply_to_message_id: params.reply_to,
326
+ ...mods,
264
327
  chat_id,
265
328
  });
266
329
  }
267
330
  return bridge("send_message", {
268
331
  text: params.text,
269
332
  reply_to_message_id: params.reply_to,
333
+ no_link_preview: params.no_link_preview,
334
+ ...mods,
270
335
  chat_id,
271
336
  });
272
337
  }
@@ -277,6 +342,7 @@ Examples:
277
342
  file_id: params.file_id,
278
343
  caption: params.caption,
279
344
  reply_to: params.reply_to,
345
+ ...mods,
280
346
  chat_id,
281
347
  });
282
348
  case "file":
@@ -286,6 +352,7 @@ Examples:
286
352
  file_id: params.file_id,
287
353
  caption: params.caption,
288
354
  reply_to: params.reply_to,
355
+ ...mods,
289
356
  chat_id,
290
357
  });
291
358
  case "video":
@@ -295,6 +362,7 @@ Examples:
295
362
  file_id: params.file_id,
296
363
  caption: params.caption,
297
364
  reply_to: params.reply_to,
365
+ ...mods,
298
366
  chat_id,
299
367
  });
300
368
  case "voice":
@@ -304,6 +372,7 @@ Examples:
304
372
  file_id: params.file_id,
305
373
  caption: params.caption,
306
374
  reply_to: params.reply_to,
375
+ ...mods,
307
376
  chat_id,
308
377
  });
309
378
  case "audio":
@@ -315,6 +384,7 @@ Examples:
315
384
  title: params.title,
316
385
  performer: params.performer,
317
386
  reply_to: params.reply_to,
387
+ ...mods,
318
388
  chat_id,
319
389
  });
320
390
  case "animation":
@@ -324,6 +394,7 @@ Examples:
324
394
  file_id: params.file_id,
325
395
  caption: params.caption,
326
396
  reply_to: params.reply_to,
397
+ ...mods,
327
398
  chat_id,
328
399
  });
329
400
  case "sticker":
@@ -333,6 +404,7 @@ Examples:
333
404
  emoji: params.emoji,
334
405
  set_name: params.set_name,
335
406
  reply_to: params.reply_to,
407
+ ...mods,
336
408
  chat_id,
337
409
  });
338
410
  case "poll":
@@ -344,6 +416,7 @@ Examples:
344
416
  explanation: params.explanation,
345
417
  type: params.correct_option_id !== undefined ? "quiz" : "regular",
346
418
  reply_to: params.reply_to,
419
+ ...mods,
347
420
  chat_id,
348
421
  });
349
422
  case "location":
@@ -351,6 +424,7 @@ Examples:
351
424
  latitude: params.latitude,
352
425
  longitude: params.longitude,
353
426
  reply_to: params.reply_to,
427
+ ...mods,
354
428
  chat_id,
355
429
  });
356
430
  case "contact":
@@ -359,12 +433,40 @@ Examples:
359
433
  first_name: params.first_name,
360
434
  last_name: params.last_name,
361
435
  reply_to: params.reply_to,
436
+ ...mods,
362
437
  chat_id,
363
438
  });
364
439
  case "dice":
365
440
  return bridge("send_dice", {
366
441
  emoji: params.emoji,
367
442
  reply_to: params.reply_to,
443
+ ...mods,
444
+ chat_id,
445
+ });
446
+ case "album":
447
+ return bridge("send_media_group", {
448
+ media: params.media,
449
+ reply_to: params.reply_to,
450
+ ...mods,
451
+ chat_id,
452
+ });
453
+ case "video_note":
454
+ return bridge("send_video_note", {
455
+ file_path: params.file_path,
456
+ url: params.url,
457
+ file_id: params.file_id,
458
+ reply_to: params.reply_to,
459
+ ...mods,
460
+ chat_id,
461
+ });
462
+ case "venue":
463
+ return bridge("send_venue", {
464
+ latitude: params.latitude,
465
+ longitude: params.longitude,
466
+ title: params.title,
467
+ address: params.address,
468
+ reply_to: params.reply_to,
469
+ ...mods,
368
470
  chat_id,
369
471
  });
370
472
  default:
@@ -479,8 +581,16 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
479
581
  // ── edit_message ──────────────────────────────────────────────────────
480
582
  {
481
583
  name: "edit_message",
482
- description: "Edit a previously sent message.",
483
- schema: { message_id: snowflakeOrIdSchema, text: z.string() },
584
+ description:
585
+ "Edit a previously sent message. For a media message (photo/video/file), pass is_caption=true to edit its caption instead of message text.",
586
+ schema: {
587
+ message_id: snowflakeOrIdSchema,
588
+ text: z.string(),
589
+ is_caption: z
590
+ .boolean()
591
+ .optional()
592
+ .describe("Edit the media caption rather than message text (Telegram)"),
593
+ },
484
594
  execute: (params, bridge) => bridge("edit_message", params),
485
595
  frontends: ["telegram", "discord", "native"],
486
596
  tag: "messaging",
@@ -489,8 +599,15 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
489
599
  // ── delete_message ────────────────────────────────────────────────────
490
600
  {
491
601
  name: "delete_message",
492
- description: "Delete a message.",
493
- schema: { message_id: snowflakeOrIdSchema },
602
+ description:
603
+ "Delete a message — or several at once via message_ids (Telegram; ids the bot can't delete are skipped).",
604
+ schema: {
605
+ message_id: snowflakeOrIdSchema.optional(),
606
+ message_ids: z
607
+ .array(snowflakeOrIdSchema)
608
+ .optional()
609
+ .describe("Bulk delete these message IDs (Telegram)"),
610
+ },
494
611
  execute: (params, bridge) => bridge("delete_message", params),
495
612
  frontends: ["telegram", "discord", "native"],
496
613
  tag: "messaging",
@@ -499,13 +616,42 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
499
616
  // ── forward_message ───────────────────────────────────────────────────
500
617
  {
501
618
  name: "forward_message",
502
- description: "Forward a message within the chat.",
503
- schema: { message_id: snowflakeOrIdSchema },
619
+ description:
620
+ "Forward a message. Defaults to within the current chat; from_chat_id / to_chat_id forward across chats the bot is in, and message_ids forwards a batch (albums stay grouped) (Telegram).",
621
+ schema: {
622
+ message_id: snowflakeOrIdSchema.optional(),
623
+ message_ids: z
624
+ .array(snowflakeOrIdSchema)
625
+ .optional()
626
+ .describe("Forward these messages as a batch (Telegram)"),
627
+ from_chat_id: chatIdSchema
628
+ .optional()
629
+ .describe("Source chat (default: current chat)"),
630
+ to_chat_id: chatIdSchema
631
+ .optional()
632
+ .describe("Destination chat (default: current chat)"),
633
+ },
504
634
  execute: (params, bridge) => bridge("forward_message", params),
505
635
  frontends: ["telegram", "discord"],
506
636
  tag: "messaging",
507
637
  },
508
638
 
639
+ // ── copy_message ──────────────────────────────────────────────────────
640
+ {
641
+ name: "copy_message",
642
+ description:
643
+ "Repost a message without the 'forwarded from' header. Same cross-chat and batch semantics as forward_message (Telegram).",
644
+ schema: {
645
+ message_id: snowflakeOrIdSchema.optional(),
646
+ message_ids: z.array(snowflakeOrIdSchema).optional(),
647
+ from_chat_id: chatIdSchema.optional(),
648
+ to_chat_id: chatIdSchema.optional(),
649
+ },
650
+ execute: (params, bridge) => bridge("copy_message", params),
651
+ frontends: ["telegram"],
652
+ tag: "messaging",
653
+ },
654
+
509
655
  // ── pin_message ───────────────────────────────────────────────────────
510
656
  {
511
657
  name: "pin_message",