talon-agent 1.0.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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Typed error classification.
3
+ *
4
+ * One place to classify errors. Every module reads `err.reason` instead
5
+ * of regex-matching error messages. Inspired by OpenClaw's FailoverError.
6
+ */
7
+
8
+ // ── Error reasons ───────────────────────────────────────────────────────────
9
+
10
+ type ErrorReason =
11
+ | "rate_limit"
12
+ | "overloaded"
13
+ | "network"
14
+ | "auth"
15
+ | "context_length"
16
+ | "session_expired"
17
+ | "bad_request"
18
+ | "forbidden"
19
+ | "telegram_api"
20
+ | "unknown";
21
+
22
+ // ── TalonError class ────────────────────────────────────────────────────────
23
+
24
+ export class TalonError extends Error {
25
+ readonly reason: ErrorReason;
26
+ readonly retryable: boolean;
27
+ readonly status?: number;
28
+ readonly retryAfterMs?: number;
29
+
30
+ constructor(
31
+ message: string,
32
+ params: {
33
+ reason: ErrorReason;
34
+ retryable?: boolean;
35
+ status?: number;
36
+ retryAfterMs?: number;
37
+ cause?: unknown;
38
+ },
39
+ ) {
40
+ super(message, { cause: params.cause });
41
+ this.name = "TalonError";
42
+ this.reason = params.reason;
43
+ this.retryable = params.retryable ?? false;
44
+ this.status = params.status;
45
+ this.retryAfterMs = params.retryAfterMs;
46
+ }
47
+ }
48
+
49
+ // ── Classify any error ──────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Wrap or classify any thrown value into a TalonError.
53
+ * Call this at module boundaries (backend catch, bridge catch) to convert
54
+ * raw errors into typed ones that callers can switch on.
55
+ */
56
+ export function classify(err: unknown): TalonError {
57
+ if (err instanceof TalonError) return err;
58
+
59
+ let msg: string;
60
+ if (err instanceof Error) msg = err.message;
61
+ else if (typeof err === "string") msg = err;
62
+ else { try { msg = String(err); } catch { msg = "[non-stringifiable error]"; } }
63
+ const cause = err instanceof Error ? err : undefined;
64
+
65
+ // Extract HTTP status if present
66
+ const statusMatch = msg.match(/\b([2-5]\d{2})\b/);
67
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
68
+
69
+ // Rate limit
70
+ if (/rate.?limit|429|too many requests/i.test(msg)) {
71
+ const retryMatch = msg.match(/retry.?after[:\s]*(\d+)/i);
72
+ const retryAfterMs = retryMatch
73
+ ? Math.min(parseInt(retryMatch[1], 10) * 1000, 300_000)
74
+ : 60_000;
75
+ return new TalonError(msg, {
76
+ reason: "rate_limit",
77
+ retryable: true,
78
+ status: status ?? 429,
79
+ retryAfterMs,
80
+ cause,
81
+ });
82
+ }
83
+
84
+ // Overloaded / capacity
85
+ if (/overloaded|503|capacity/i.test(msg)) {
86
+ return new TalonError(msg, {
87
+ reason: "overloaded",
88
+ retryable: true,
89
+ status: status ?? 503,
90
+ retryAfterMs: 5_000,
91
+ cause,
92
+ });
93
+ }
94
+
95
+ // Network errors
96
+ if (/network|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed/i.test(msg)) {
97
+ return new TalonError(msg, {
98
+ reason: "network",
99
+ retryable: true,
100
+ retryAfterMs: 2_000,
101
+ cause,
102
+ });
103
+ }
104
+
105
+ // Session expired
106
+ if (/session|expired|invalid.*resume/i.test(msg)) {
107
+ return new TalonError(msg, {
108
+ reason: "session_expired",
109
+ retryable: false,
110
+ cause,
111
+ });
112
+ }
113
+
114
+ // Context length / overflow
115
+ if (/context.*length|too.*long|token.*limit|overflow/i.test(msg)) {
116
+ return new TalonError(msg, {
117
+ reason: "context_length",
118
+ retryable: false,
119
+ cause,
120
+ });
121
+ }
122
+
123
+ // Auth
124
+ if (/authentication|unauthorized|401|api.?key/i.test(msg)) {
125
+ return new TalonError(msg, {
126
+ reason: "auth",
127
+ retryable: false,
128
+ status: status ?? 401,
129
+ cause,
130
+ });
131
+ }
132
+
133
+ // Bad request (don't retry)
134
+ if (status === 400) {
135
+ return new TalonError(msg, {
136
+ reason: "bad_request",
137
+ retryable: false,
138
+ status: 400,
139
+ cause,
140
+ });
141
+ }
142
+
143
+ // Forbidden (don't retry)
144
+ if (status === 403) {
145
+ return new TalonError(msg, {
146
+ reason: "forbidden",
147
+ retryable: false,
148
+ status: 403,
149
+ cause,
150
+ });
151
+ }
152
+
153
+ // Server errors (5xx) are generally retryable
154
+ if (status && status >= 500) {
155
+ return new TalonError(msg, {
156
+ reason: "overloaded",
157
+ retryable: true,
158
+ status,
159
+ retryAfterMs: 2_000,
160
+ cause,
161
+ });
162
+ }
163
+
164
+ // Unknown
165
+ return new TalonError(msg, {
166
+ reason: "unknown",
167
+ retryable: false,
168
+ status,
169
+ cause,
170
+ });
171
+ }
172
+
173
+ // ── User-friendly messages ──────────────────────────────────────────────────
174
+
175
+ const FRIENDLY_MESSAGES: Record<ErrorReason, string> = {
176
+ rate_limit: "Rate limited. Try again in a moment.",
177
+ overloaded: "Claude is busy right now. Retrying with a faster model...",
178
+ network: "Connection issue. Retrying shortly.",
179
+ auth: "API key error. Bot operator: check your Claude credentials.",
180
+ context_length:
181
+ "Conversation too long for the context window. Use /reset to start fresh.",
182
+ session_expired: "Session expired. Retrying automatically...",
183
+ bad_request: "Something went wrong. Try /reset if this keeps happening.",
184
+ forbidden: "Permission denied for this action.",
185
+ telegram_api: "Telegram API error. Try again.",
186
+ unknown: "Something went wrong. Try again or /reset.",
187
+ };
188
+
189
+ /**
190
+ * Get a user-friendly error message. For rate limits, includes retry timing.
191
+ */
192
+ export function friendlyMessage(err: unknown): string {
193
+ const classified = err instanceof TalonError ? err : classify(err);
194
+
195
+ if (classified.reason === "rate_limit" && classified.retryAfterMs) {
196
+ const seconds = Math.ceil(classified.retryAfterMs / 1000);
197
+ return `Rate limited. Try again in ${seconds} seconds.`;
198
+ }
199
+
200
+ // Session expired messages are already user-friendly from the backend
201
+ if (classified.reason === "session_expired") {
202
+ return classified.message;
203
+ }
204
+
205
+ return FRIENDLY_MESSAGES[classified.reason];
206
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Shared gateway actions — platform-agnostic handlers that work with any frontend.
3
+ *
4
+ * Handles: cron CRUD, fetch_url, in-memory history queries.
5
+ * Returns null if the action isn't recognized (so the gateway delegates to the frontend).
6
+ */
7
+
8
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import * as cheerio from "cheerio";
11
+ import { dirs } from "../util/paths.js";
12
+ import {
13
+ getRecentFormatted,
14
+ searchHistory,
15
+ getMessagesByUser,
16
+ getKnownUsers,
17
+ } from "../storage/history.js";
18
+ import { formatMediaIndex } from "../storage/media-index.js";
19
+ import {
20
+ addCronJob,
21
+ getCronJob,
22
+ getCronJobsForChat,
23
+ updateCronJob,
24
+ deleteCronJob,
25
+ validateCronExpression,
26
+ generateCronId,
27
+ type CronJobType,
28
+ } from "../storage/cron-store.js";
29
+ import { log } from "../util/log.js";
30
+ import type { ActionResult } from "./types.js";
31
+
32
+ /** Extract readable text from HTML using cheerio (proper DOM parser). */
33
+ function extractText(html: string, maxLength = 8000): string {
34
+ const $ = cheerio.load(html);
35
+ // Remove non-content elements
36
+ $("script, style, noscript, iframe, svg, nav, footer, header").remove();
37
+ // Get text content, normalize whitespace
38
+ const text = $("body").text().replace(/\s+/g, " ").trim();
39
+ return text.slice(0, maxLength);
40
+ }
41
+
42
+ export async function handleSharedAction(
43
+ body: Record<string, unknown>,
44
+ chatId: number,
45
+ ): Promise<ActionResult | null> {
46
+ const action = body.action as string;
47
+
48
+ switch (action) {
49
+ // ── History (in-memory fallback) ──────────────────────────────────────
50
+ // These are only used when userbot is not available.
51
+ // Frontends can override these with richer implementations.
52
+
53
+ case "read_history": {
54
+ const limit = Math.min(100, Number(body.limit ?? 30));
55
+ return { ok: true, text: getRecentFormatted(String(chatId), limit) };
56
+ }
57
+
58
+ case "search_history": {
59
+ const limit = Math.min(100, Number(body.limit ?? 20));
60
+ return { ok: true, text: searchHistory(String(chatId), String(body.query ?? ""), limit) };
61
+ }
62
+
63
+ case "get_user_messages": {
64
+ const limit = Math.min(50, Number(body.limit ?? 20));
65
+ return { ok: true, text: getMessagesByUser(String(chatId), String(body.user_name ?? ""), limit) };
66
+ }
67
+
68
+ case "list_known_users":
69
+ return { ok: true, text: getKnownUsers(String(chatId)) };
70
+
71
+ case "list_media":
72
+ return { ok: true, text: formatMediaIndex(String(chatId), Math.min(20, Number(body.limit ?? 10))) };
73
+
74
+ // ── Web search (SearXNG) ────────────────────────────────────────────
75
+
76
+ case "web_search": {
77
+ const query = String(body.query ?? "");
78
+ if (!query) return { ok: false, error: "Missing query" };
79
+ const limit = Math.min(10, Number(body.limit ?? 5));
80
+
81
+ // Try Brave first (if API key configured), fall back to SearXNG
82
+ const braveKey = process.env.TALON_BRAVE_API_KEY;
83
+ const searxUrl = process.env.TALON_SEARXNG_URL || "http://localhost:8080";
84
+
85
+ type SearchResult = { title: string; url: string; snippet: string };
86
+ let results: SearchResult[] = [];
87
+ let provider = "";
88
+
89
+ // Brave Search API
90
+ if (braveKey) {
91
+ try {
92
+ const resp = await fetch(
93
+ `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`,
94
+ { signal: AbortSignal.timeout(8_000), headers: { "X-Subscription-Token": braveKey, Accept: "application/json" } },
95
+ );
96
+ if (resp.ok) {
97
+ const data = await resp.json() as { web?: { results?: Array<{ title: string; url: string; description: string }> } };
98
+ results = (data.web?.results ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.description ?? "" }));
99
+ provider = "Brave";
100
+ }
101
+ } catch { /* fall through to SearXNG */ }
102
+ }
103
+
104
+ // SearXNG fallback
105
+ if (results.length === 0) {
106
+ try {
107
+ const resp = await fetch(
108
+ `${searxUrl}/search?q=${encodeURIComponent(query)}&format=json`,
109
+ { signal: AbortSignal.timeout(10_000) },
110
+ );
111
+ if (resp.ok) {
112
+ const data = await resp.json() as { results?: Array<{ title: string; url: string; content: string }> };
113
+ results = (data.results ?? []).slice(0, limit).map((r) => ({ title: r.title, url: r.url, snippet: r.content ?? "" }));
114
+ provider = "SearXNG";
115
+ }
116
+ } catch { /* both failed */ }
117
+ }
118
+
119
+ if (results.length === 0) return { ok: true, text: `No results for "${query}".` };
120
+ const formatted = results.map((r, i) =>
121
+ `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 200)}`
122
+ ).join("\n\n");
123
+ return { ok: true, text: `Search results for "${query}" (via ${provider}):\n\n${formatted}` };
124
+ }
125
+
126
+ // ── Web fetch ────────────────────────────────────────────────────────
127
+
128
+ case "fetch_url": {
129
+ const url = String(body.url ?? "");
130
+ if (!url) return { ok: false, error: "Missing URL" };
131
+ try {
132
+ const parsed = new URL(url);
133
+ if (!["http:", "https:"].includes(parsed.protocol)) {
134
+ return { ok: false, error: "URL must use http or https protocol" };
135
+ }
136
+ } catch {
137
+ return { ok: false, error: "Invalid URL" };
138
+ }
139
+ try {
140
+ const resp = await fetch(url, {
141
+ signal: AbortSignal.timeout(15_000),
142
+ headers: { "User-Agent": "Talon/1.0" },
143
+ redirect: "follow",
144
+ });
145
+ if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
146
+ const ct = resp.headers.get("content-type") ?? "";
147
+
148
+ // Binary content: download and save to workspace
149
+ const mimeType = ct.split(";")[0].trim().toLowerCase();
150
+ const isText = mimeType.startsWith("text/") || mimeType === "application/json";
151
+ if (!isText) {
152
+ const buffer = Buffer.from(await resp.arrayBuffer());
153
+ if (buffer.length > 20 * 1024 * 1024) return { ok: false, error: "File too large (max 20MB)" };
154
+ if (buffer.length === 0) return { ok: false, error: "Empty response (0 bytes)" };
155
+
156
+ // Validate magic bytes — prevent saving HTML error pages as images
157
+ // (servers can return error pages with image content-type headers)
158
+ const magic = buffer.subarray(0, 16);
159
+ const isRealImage =
160
+ (magic[0] === 0xFF && magic[1] === 0xD8) || // JPEG
161
+ (magic[0] === 0x89 && magic[1] === 0x50 && magic[2] === 0x4E && magic[3] === 0x47) || // PNG
162
+ (magic[0] === 0x47 && magic[1] === 0x49 && magic[2] === 0x46) || // GIF
163
+ (magic[0] === 0x52 && magic[1] === 0x49 && magic[2] === 0x46 && magic[3] === 0x46 &&
164
+ magic[8] === 0x57 && magic[9] === 0x45 && magic[10] === 0x42 && magic[11] === 0x50); // WebP
165
+
166
+ // If content-type says image but bytes say otherwise, treat as text
167
+ if (ct.startsWith("image/") && !isRealImage) {
168
+ const text = extractText(buffer.toString("utf-8"), 500);
169
+ return { ok: false, error: `Server returned an error page instead of an image. Content: ${text}` };
170
+ }
171
+
172
+ const ext = isRealImage
173
+ ? ((magic[0] === 0xFF) ? "jpg" : (magic[0] === 0x89) ? "png" : (magic[0] === 0x47) ? "gif" : "webp")
174
+ : ct.includes("pdf") ? "pdf" : ct.includes("zip") ? "zip" : "bin";
175
+ const uploadsDir = dirs.uploads;
176
+ if (!existsSync(uploadsDir)) mkdirSync(uploadsDir, { recursive: true });
177
+ const filePath = resolve(uploadsDir, `${Date.now()}-fetched.${ext}`);
178
+ writeFileSync(filePath, buffer);
179
+ const typeLabel = isRealImage ? "image" : ct.split("/")[1]?.split(";")[0] ?? "file";
180
+ return { ok: true, text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").` };
181
+ }
182
+ const raw = await resp.text();
183
+ const text = extractText(raw);
184
+ if (text.length < 20) return { ok: true, text: "(Page has no readable content)" };
185
+ return { ok: true, text };
186
+ } catch (err) {
187
+ return { ok: false, error: `Fetch failed: ${err instanceof Error ? err.message : err}` };
188
+ }
189
+ }
190
+
191
+ // ── Cron CRUD ────────────────────────────────────────────────────────
192
+
193
+ case "create_cron_job": {
194
+ const name = String(body.name ?? "Unnamed job");
195
+ const schedule = String(body.schedule ?? "");
196
+ const jobType = (body.type as CronJobType) ?? "message";
197
+ const content = String(body.content ?? "");
198
+ const timezone = body.timezone ? String(body.timezone) : undefined;
199
+
200
+ if (!schedule) return { ok: false, error: "Missing schedule expression" };
201
+ if (!content) return { ok: false, error: "Missing content" };
202
+ if (content.length > 10_000) return { ok: false, error: "Content too long (max 10,000 chars)" };
203
+
204
+ const validation = validateCronExpression(schedule, timezone);
205
+ if (!validation.valid) return { ok: false, error: `Invalid cron expression: ${validation.error}` };
206
+
207
+ const id = generateCronId();
208
+ addCronJob({ id, chatId: String(chatId), schedule, type: jobType, content, name, enabled: true, createdAt: Date.now(), runCount: 0, timezone });
209
+ log("gateway", `create_cron_job: "${name}" [${schedule}]`);
210
+ return { ok: true, text: `Created cron job "${name}" (id: ${id})\nSchedule: ${schedule}\nType: ${jobType}\nNext run: ${validation.next ?? "unknown"}` };
211
+ }
212
+
213
+ case "list_cron_jobs": {
214
+ const jobs = getCronJobsForChat(String(chatId));
215
+ if (jobs.length === 0) return { ok: true, text: "No cron jobs in this chat." };
216
+ const lines = jobs.map((j) => {
217
+ const status = j.enabled ? "enabled" : "disabled";
218
+ const lastRun = j.lastRunAt ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
219
+ const v = validateCronExpression(j.schedule, j.timezone);
220
+ const nextRun = v.next ? new Date(v.next).toISOString().slice(0, 16).replace("T", " ") : "unknown";
221
+ return [
222
+ `- ${j.name} (${status})`, ` ID: ${j.id}`,
223
+ ` Schedule: ${j.schedule}${j.timezone ? ` (${j.timezone})` : ""}`,
224
+ ` Type: ${j.type}`, ` Content: ${j.content.slice(0, 100)}${j.content.length > 100 ? "..." : ""}`,
225
+ ` Runs: ${j.runCount} | Last: ${lastRun} | Next: ${nextRun}`,
226
+ ].join("\n");
227
+ });
228
+ return { ok: true, text: `Cron jobs (${jobs.length}):\n\n${lines.join("\n\n")}` };
229
+ }
230
+
231
+ case "edit_cron_job": {
232
+ const jobId = String(body.job_id ?? "");
233
+ if (!jobId) return { ok: false, error: "Missing job_id" };
234
+ const job = getCronJob(jobId);
235
+ if (!job) return { ok: false, error: `Job ${jobId} not found` };
236
+ if (job.chatId !== String(chatId)) return { ok: false, error: "Job belongs to a different chat" };
237
+
238
+ const updates: Record<string, unknown> = {};
239
+ if (body.name !== undefined) updates.name = String(body.name);
240
+ if (body.content !== undefined) updates.content = String(body.content);
241
+ if (body.enabled !== undefined) updates.enabled = Boolean(body.enabled);
242
+ if (body.type !== undefined) updates.type = String(body.type);
243
+ if (body.timezone !== undefined) updates.timezone = body.timezone ? String(body.timezone) : undefined;
244
+ if (body.schedule !== undefined) {
245
+ const v = validateCronExpression(String(body.schedule), (updates.timezone as string | undefined) ?? job.timezone);
246
+ if (!v.valid) return { ok: false, error: `Invalid cron expression: ${v.error}` };
247
+ updates.schedule = String(body.schedule);
248
+ }
249
+
250
+ const updated = updateCronJob(jobId, updates);
251
+ return { ok: true, text: `Updated job "${updated?.name ?? jobId}". Fields changed: ${Object.keys(updates).join(", ")}` };
252
+ }
253
+
254
+ case "delete_cron_job": {
255
+ const jobId = String(body.job_id ?? "");
256
+ if (!jobId) return { ok: false, error: "Missing job_id" };
257
+ const job = getCronJob(jobId);
258
+ if (!job) return { ok: false, error: `Job ${jobId} not found` };
259
+ if (job.chatId !== String(chatId)) return { ok: false, error: "Job belongs to a different chat" };
260
+ deleteCronJob(jobId);
261
+ return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
262
+ }
263
+
264
+ default:
265
+ return null; // not a shared action — delegate to frontend
266
+ }
267
+ }