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.
- package/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- 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
|
+
}
|