返回 CodeWhale
lib.mjs
1 import {
2 activeTurnBlock,
3 cleanEnvValue,
4 commandAction as coreCommandAction,
5 compactRuntimeError,
6 envFirst,
7 isPlaceholderValue,
8 latestRunningTurn,
9 parseApprovalDecisionArgs,
10 parseBool,
11 parseCommand as coreParseCommand,
12 parseEnvText,
13 parseList,
14 preservedChatStateFields,
15 splitMessage,
16 stripGroupPrefix as coreStripGroupPrefix
17 } from "../../bridge-core/src/lib.mjs";
18
19 export {
20 activeTurnBlock,
21 cleanEnvValue,
22 compactRuntimeError,
23 envFirst,
24 isPlaceholderValue,
25 latestRunningTurn,
26 parseApprovalDecisionArgs,
27 parseBool,
28 parseEnvText,
29 parseList,
30 preservedChatStateFields,
31 splitMessage
32 };
33
34 export function telegramIdentity(update) {
35 const message = update?.message || update?.edited_message || {};
36 const chat = message.chat || {};
37 const from = message.from || {};
38 const username = from.username ? `@${from.username}` : "";
39 return {
40 updateId: update?.update_id ?? null,
41 chatId: chat.id != null ? String(chat.id) : "",
42 messageId: message.message_id != null ? String(message.message_id) : "",
43 chatType: chat.type || "",
44 userId: from.id != null ? String(from.id) : "",
45 username,
46 firstName: from.first_name || "",
47 text: typeof message.text === "string" ? message.text : "",
48 isBot: Boolean(from.is_bot)
49 };
50 }
51
52 export function isGroupChat(chatType) {
53 return chatType === "group" || chatType === "supergroup";
54 }
55
56 export function isAllowed(identity, allowlist, allowUnlisted = false) {
57 if (allowUnlisted) return true;
58 const allowed = new Set(allowlist);
59 return [identity.chatId, identity.userId, identity.username]
60 .filter(Boolean)
61 .some((id) => allowed.has(id));
62 }
63
64 export function pairingRefusalText(identity) {
65 return [
66 "This Telegram chat is not in TELEGRAM_CHAT_ALLOWLIST.",
67 `chat_id=${identity.chatId}`,
68 identity.userId ? `user_id=${identity.userId}` : "",
69 identity.username ? `username=${identity.username}` : "",
70 "",
71 "For first pairing, add one of those IDs to TELEGRAM_CHAT_ALLOWLIST, or temporarily set TELEGRAM_ALLOW_UNLISTED=true."
72 ]
73 .filter(Boolean)
74 .join("\n");
75 }
76
77 export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) {
78 return coreStripGroupPrefix(text, {
79 chatType,
80 requirePrefix,
81 prefix: prefix || "/cw",
82 directChatTypes: ["private", "channel"]
83 });
84 }
85
86 export function parseCommand(text) {
87 return coreParseCommand(text, { stripBotMention: true });
88 }
89
90 export function commandAction(command) {
91 return coreCommandAction(command, { allowMenu: true, allowStart: true });
92 }
93
94 export function controlKeyboard() {
95 return {
96 inline_keyboard: [
97 [
98 { text: "Status", callback_data: "cw:status" },
99 { text: "New thread", callback_data: "cw:new" }
100 ],
101 [
102 { text: "Threads", callback_data: "cw:threads" },
103 { text: "Interrupt", callback_data: "cw:interrupt" }
104 ],
105 [
106 { text: "Compact", callback_data: "cw:compact" },
107 { text: "Reset model", callback_data: "cw:model:default" }
108 ],
109 [{ text: "Help", callback_data: "cw:help" }]
110 ]
111 };
112 }
113
114 export function activeTurnKeyboard() {
115 return {
116 inline_keyboard: [
117 [
118 { text: "Status", callback_data: "cw:status" },
119 { text: "Interrupt", callback_data: "cw:interrupt" }
120 ],
121 [{ text: "Threads", callback_data: "cw:threads" }]
122 ]
123 };
124 }
125
126 export function approvalKeyboard(actionToken) {
127 return {
128 inline_keyboard: [
129 [
130 { text: "Allow once", callback_data: `cw:act:${actionToken}` },
131 { text: "Allow + remember", callback_data: `cw:act:${actionToken}:remember` }
132 ],
133 [{ text: "Deny", callback_data: `cw:act:${actionToken}:deny` }]
134 ]
135 };
136 }
137
138 export function threadListKeyboard(threadActions) {
139 const rows = [];
140 for (const action of threadActions.slice(0, 8)) {
141 rows.push([{ text: action.label, callback_data: `cw:act:${action.token}` }]);
142 }
143 rows.push([{ text: "New thread", callback_data: "cw:new" }]);
144 return { inline_keyboard: rows };
145 }
146
147 export function callbackAction(data) {
148 const value = String(data || "");
149 switch (value) {
150 case "cw:status":
151 return { kind: "status" };
152 case "cw:new":
153 return { kind: "new_thread" };
154 case "cw:threads":
155 return { kind: "threads" };
156 case "cw:interrupt":
157 return { kind: "interrupt" };
158 case "cw:compact":
159 return { kind: "compact" };
160 case "cw:help":
161 return { kind: "help" };
162 case "cw:model:default":
163 return { kind: "set_model", modelName: "default" };
164 default:
165 break;
166 }
167 if (value.startsWith("cw:act:")) {
168 const [, , token, suffix] = value.split(":", 4);
169 return { kind: "stored_action", token: token || "", suffix: suffix || "" };
170 }
171 return null;
172 }
173
174 const MARKDOWN_V2_SPECIALS = /([_*\[\]()~`>#+\-=|{}.!\\])/g;
175 const BLOCK_PLACEHOLDER_PREFIX = "\u0000mdv2:block:";
176 const INLINE_PLACEHOLDER_PREFIX = "\u0000mdv2:inline:";
177 const PLACEHOLDER_SUFFIX = "\u0000";
178
179 export function telegramMessageBody(text, options = {}) {
180 const maxChars = Math.floor(Number(options.maxChars) || 0);
181 if (options.markdown === false) {
182 return { text: boundedPlainTelegramText(text, maxChars) };
183 }
184 const markdownText = telegramMarkdownV2(text);
185 if (maxChars > 0 && markdownText.length > maxChars) {
186 return { text: boundedPlainTelegramText(text, maxChars) };
187 }
188 return {
189 text: markdownText,
190 parse_mode: "MarkdownV2"
191 };
192 }
193
194 export function telegramMarkdownV2(text) {
195 const placeholders = [];
196 const source = String(text || "");
197 const fenced = source.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, language, body) =>
198 markdownPlaceholder(
199 placeholders,
200 `\`\`\`${safeFenceLanguage(language)}\n${escapeMarkdownV2Code(removeClosingFenceNewline(body))}\n\`\`\``,
201 BLOCK_PLACEHOLDER_PREFIX
202 )
203 );
204 return restoreMarkdownPlaceholders(renderMarkdownLines(fenced), placeholders, BLOCK_PLACEHOLDER_PREFIX);
205 }
206
207 export function plainTelegramText(text) {
208 const source = String(text || "");
209 return renderPlainLines(
210 source
211 .replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
212 .replace(/`([^`\n]+)`/g, "$1")
213 .replace(/\[([^\]\n]+)\]\(([^ \n]+)\)/g, "$1 ($2)")
214 .replace(/\*\*([^*\n]+)\*\*/g, "$1")
215 .replace(/__([^_\n]+)__/g, "$1")
216 .replace(/[*_~]/g, "")
217 );
218 }
219
220 function boundedPlainTelegramText(text, maxChars) {
221 const plain = plainTelegramText(text);
222 if (maxChars > 0 && plain.length > maxChars) {
223 return String(text || "");
224 }
225 return plain;
226 }
227
228 export function isTelegramMarkdownParseError(error) {
229 if (Number(error?.errorCode) !== 400) return false;
230 const text = String(error?.description || error?.message || "").toLowerCase();
231 return (
232 text.includes("parse") ||
233 text.includes("can't parse entities") ||
234 text.includes("entity") ||
235 text.includes("markdown")
236 );
237 }
238
239 function renderMarkdownLines(text) {
240 const lines = String(text || "").split("\n");
241 const output = [];
242 for (let index = 0; index < lines.length; index += 1) {
243 if (isMarkdownTable(lines, index)) {
244 const { rendered, nextIndex } = renderMarkdownTable(lines, index);
245 output.push(rendered);
246 index = nextIndex - 1;
247 } else {
248 output.push(renderMarkdownInline(lines[index]));
249 }
250 }
251 return output.join("\n");
252 }
253
254 function renderPlainLines(text) {
255 const lines = String(text || "").split("\n");
256 const output = [];
257 for (let index = 0; index < lines.length; index += 1) {
258 if (isMarkdownTable(lines, index)) {
259 const { rendered, nextIndex } = renderPlainTable(lines, index);
260 output.push(rendered);
261 index = nextIndex - 1;
262 } else {
263 output.push(lines[index]);
264 }
265 }
266 return output.join("\n");
267 }
268
269 function renderMarkdownInline(text) {
270 const placeholders = [];
271 let value = String(text || "");
272 value = value.replace(/\[([^\]\n]+)\]\(([^ \n]+)\)/g, (_match, label, url) =>
273 markdownPlaceholder(
274 placeholders,
275 `[${escapeMarkdownV2Text(label)}](${escapeMarkdownV2Url(url)})`,
276 INLINE_PLACEHOLDER_PREFIX
277 )
278 );
279 value = value.replace(/`([^`\n]+)`/g, (_match, code) =>
280 markdownPlaceholder(placeholders, `\`${escapeMarkdownV2Code(code)}\``, INLINE_PLACEHOLDER_PREFIX)
281 );
282 value = value.replace(/\*\*([^*\n]+)\*\*/g, (_match, body) =>
283 markdownPlaceholder(placeholders, `*${escapeMarkdownV2Text(body)}*`, INLINE_PLACEHOLDER_PREFIX)
284 );
285 value = escapeMarkdownV2Text(value);
286 return restoreMarkdownPlaceholders(value, placeholders, INLINE_PLACEHOLDER_PREFIX);
287 }
288
289 function renderMarkdownTable(lines, startIndex) {
290 const headers = tableCells(lines[startIndex]);
291 const rows = [];
292 let index = startIndex + 2;
293 while (index < lines.length && looksLikeTableRow(lines[index])) {
294 rows.push(tableCells(lines[index]));
295 index += 1;
296 }
297 const headerText = headers.map(escapeMarkdownV2Text).join(" / ");
298 const rendered = [`*${headerText}*`];
299 for (const row of rows) {
300 const fields = headers.map((header, cellIndex) => {
301 const value = row[cellIndex] || "";
302 return `${escapeMarkdownV2Text(header)}: ${escapeMarkdownV2Text(value)}`;
303 });
304 rendered.push(`• ${fields.join("; ")}`);
305 }
306 return { rendered: rendered.join("\n"), nextIndex: index };
307 }
308
309 function renderPlainTable(lines, startIndex) {
310 const headers = tableCells(lines[startIndex]);
311 const rows = [];
312 let index = startIndex + 2;
313 while (index < lines.length && looksLikeTableRow(lines[index])) {
314 rows.push(tableCells(lines[index]));
315 index += 1;
316 }
317 const rendered = [headers.join(" / ")];
318 for (const row of rows) {
319 const fields = headers.map((header, cellIndex) => `${header}: ${row[cellIndex] || ""}`);
320 rendered.push(`- ${fields.join("; ")}`);
321 }
322 return { rendered: rendered.join("\n"), nextIndex: index };
323 }
324
325 function isMarkdownTable(lines, index) {
326 return (
327 looksLikeTableRow(lines[index]) &&
328 index + 1 < lines.length &&
329 looksLikeTableSeparator(lines[index + 1])
330 );
331 }
332
333 function looksLikeTableRow(line) {
334 return tableCells(line).length >= 2;
335 }
336
337 function looksLikeTableSeparator(line) {
338 const cells = tableCells(line);
339 return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
340 }
341
342 function tableCells(line) {
343 const trimmed = String(line || "").trim();
344 if (!trimmed.includes("|")) return [];
345 return trimmed
346 .replace(/^\|/, "")
347 .replace(/\|$/, "")
348 .split("|")
349 .map((cell) => cell.trim());
350 }
351
352 function escapeMarkdownV2Text(text) {
353 return String(text || "").replace(MARKDOWN_V2_SPECIALS, "\\$1");
354 }
355
356 function escapeMarkdownV2Code(text) {
357 return String(text || "").replace(/([`\\])/g, "\\$1");
358 }
359
360 function escapeMarkdownV2Url(text) {
361 return String(text || "").replace(/([)\\])/g, "\\$1");
362 }
363
364 function safeFenceLanguage(language) {
365 return String(language || "").trim().replace(/[^\w+-]/g, "");
366 }
367
368 function removeClosingFenceNewline(text) {
369 return String(text || "").replace(/\n$/, "");
370 }
371
372 function markdownPlaceholder(placeholders, rendered, prefix) {
373 const index = placeholders.push(rendered) - 1;
374 return `${prefix}${index}${PLACEHOLDER_SUFFIX}`;
375 }
376
377 function restoreMarkdownPlaceholders(text, placeholders, prefix) {
378 return String(text || "").replace(
379 new RegExp(`${prefix}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"),
380 (_match, index) => placeholders[Number(index)] || ""
381 );
382 }
383
384 export function telegramRetryDelayMs(error, fallbackMs = 3000) {
385 const retryAfter = Number(error?.parameters?.retry_after || 0);
386 if (Number.isFinite(retryAfter) && retryAfter > 0) {
387 return Math.min(retryAfter * 1000, 60000);
388 }
389 return fallbackMs;
390 }
391
392 const POLLING_CONFLICT_DELAYS_MS = [15000, 25000, 35000, 45000, 55000];
393
394 export function telegramPollingConflictDelayMs(attempt = 0) {
395 const index = Math.max(0, Math.floor(Number(attempt) || 0));
396 return POLLING_CONFLICT_DELAYS_MS[index] ?? null;
397 }
398
399 export function telegramSendRetryDelayMs(error, attempt = 0) {
400 const retryAfter = Number(error?.parameters?.retry_after || 0);
401 if (error?.errorCode === 429 && attempt < 3) {
402 if (Number.isFinite(retryAfter) && retryAfter > 0) {
403 return Math.min(retryAfter * 1000, 60000);
404 }
405 return 3000;
406 }
407 if (isTransientTelegramSendError(error) && attempt < 2) {
408 return attempt === 0 ? 1000 : 2000;
409 }
410 return null;
411 }
412
413 function isTransientTelegramSendError(error) {
414 if (!error || error.errorCode) return false;
415 const name = String(error.name || "");
416 if (name === "AbortError" || name === "TimeoutError") return false;
417 if (error instanceof TypeError) return true;
418
419 const code = String(error.code || error.cause?.code || "");
420 if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ENOTFOUND", "ETIMEDOUT"].includes(code)) {
421 return true;
422 }
423
424 const message = String(error.message || "").toLowerCase();
425 return (
426 message.includes("fetch failed") ||
427 message.includes("network") ||
428 message.includes("socket hang up")
429 );
430 }
431
432 export function looksLikePollingConflict(error) {
433 const text = String(error?.description || error?.message || "").toLowerCase();
434 return error?.errorCode === 409 || text.includes("terminated by other getupdates request");
435 }
436
437 export function validateBridgeConfig(env, options = {}) {
438 const runtimeEnv = options.runtimeEnv || null;
439 const workspaceRoot = options.workspaceRoot || "";
440 const errors = [];
441 const warnings = [];
442 const info = [];
443 const add = (list, code, message) => list.push({ code, message });
444
445 const botToken = envFirst(env, "TELEGRAM_BOT_TOKEN");
446 if (!botToken) {
447 add(errors, "missing_required", "TELEGRAM_BOT_TOKEN is required");
448 } else if (isPlaceholderValue(botToken)) {
449 add(errors, "placeholder_value", "TELEGRAM_BOT_TOKEN still contains a placeholder value");
450 }
451
452 const runtimeUrl = envFirst(env, "CODEWHALE_RUNTIME_URL", "DEEPSEEK_RUNTIME_URL") || "http://127.0.0.1:7878";
453 try {
454 const parsed = new URL(runtimeUrl);
455 const localHosts = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
456 if (!["http:", "https:"].includes(parsed.protocol)) {
457 add(errors, "invalid_runtime_url", "CODEWHALE_RUNTIME_URL must use http or https");
458 }
459 if (!localHosts.has(parsed.hostname) && options.requireLocalRuntime !== false) {
460 add(errors, "remote_runtime_url", "CODEWHALE_RUNTIME_URL should point at localhost on a VM deployment");
461 }
462 } catch {
463 add(errors, "invalid_runtime_url", "CODEWHALE_RUNTIME_URL is not a valid URL");
464 }
465
466 const runtimeToken = envFirst(env, "CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN");
467 if (!runtimeToken) {
468 add(errors, "missing_required", "CODEWHALE_RUNTIME_TOKEN is required");
469 } else if (isPlaceholderValue(runtimeToken)) {
470 add(errors, "placeholder_value", "CODEWHALE_RUNTIME_TOKEN still contains a placeholder value");
471 }
472
473 const workspace = envFirst(env, "CODEWHALE_WORKSPACE", "DEEPSEEK_WORKSPACE");
474 if (workspace && !workspace.startsWith("/")) {
475 add(errors, "relative_workspace", "CODEWHALE_WORKSPACE must be an absolute path");
476 }
477 if (
478 workspace &&
479 workspaceRoot &&
480 workspace !== workspaceRoot &&
481 !workspace.startsWith(`${workspaceRoot}/`)
482 ) {
483 add(warnings, "workspace_root", `CODEWHALE_WORKSPACE is outside ${workspaceRoot}`);
484 }
485
486 const threadMapPath = envFirst(env, "TELEGRAM_THREAD_MAP_PATH");
487 if (threadMapPath && !threadMapPath.startsWith("/")) {
488 add(errors, "relative_thread_map", "TELEGRAM_THREAD_MAP_PATH must be an absolute path");
489 }
490
491 const allowGroups = parseBool(env.TELEGRAM_ALLOW_GROUPS, false);
492 const requirePrefix = parseBool(env.TELEGRAM_REQUIRE_PREFIX_IN_GROUP, true);
493 const allowUnlisted = parseBool(
494 envFirst(env, "TELEGRAM_ALLOW_UNLISTED", "CODEWHALE_ALLOW_UNLISTED", "DEEPSEEK_ALLOW_UNLISTED"),
495 false
496 );
497 const allowlist = parseList(
498 envFirst(env, "TELEGRAM_CHAT_ALLOWLIST", "CODEWHALE_CHAT_ALLOWLIST", "DEEPSEEK_CHAT_ALLOWLIST")
499 );
500
501 if (!allowlist.length && allowUnlisted) {
502 add(warnings, "pairing_mode_open", "TELEGRAM_ALLOW_UNLISTED=true leaves first-pairing mode open");
503 } else if (!allowlist.length) {
504 add(warnings, "not_paired", "TELEGRAM_CHAT_ALLOWLIST is empty; all chats will be refused");
505 }
506 if (allowGroups && allowUnlisted) {
507 add(errors, "open_group_control", "Group control cannot be enabled while unlisted chats are allowed");
508 }
509 if (allowGroups && !requirePrefix) {
510 add(warnings, "group_without_prefix", "Group control is enabled without requiring TELEGRAM_GROUP_PREFIX");
511 }
512 if (!allowGroups) {
513 add(info, "dm_only", "Direct-message control is enabled; group chats are disabled");
514 }
515
516 const maxReplyChars = Number(env.TELEGRAM_MAX_REPLY_CHARS || 3500);
517 if (!Number.isFinite(maxReplyChars) || maxReplyChars < 100 || maxReplyChars > 4096) {
518 add(errors, "invalid_max_reply_chars", "TELEGRAM_MAX_REPLY_CHARS must be between 100 and 4096");
519 }
520 const pollTimeout = Number(env.TELEGRAM_POLL_TIMEOUT_SECONDS || 50);
521 if (!Number.isFinite(pollTimeout) || pollTimeout < 1 || pollTimeout > 60) {
522 add(errors, "invalid_poll_timeout", "TELEGRAM_POLL_TIMEOUT_SECONDS must be between 1 and 60");
523 }
524 const turnTimeoutMs = Number(envFirst(env, "CODEWHALE_TURN_TIMEOUT_MS", "DEEPSEEK_TURN_TIMEOUT_MS") || 900000);
525 if (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs < 1000) {
526 add(errors, "invalid_turn_timeout", "CODEWHALE_TURN_TIMEOUT_MS must be at least 1000");
527 }
528
529 if (runtimeEnv) {
530 const runtimeFileToken = envFirst(runtimeEnv, "CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN");
531 if (!runtimeFileToken) {
532 add(errors, "missing_runtime_token", "runtime.env is missing CODEWHALE_RUNTIME_TOKEN");
533 } else if (isPlaceholderValue(runtimeFileToken)) {
534 add(errors, "placeholder_runtime_token", "runtime.env CODEWHALE_RUNTIME_TOKEN is still a placeholder");
535 } else if (runtimeToken && runtimeToken !== runtimeFileToken) {
536 add(errors, "token_mismatch", "Runtime and bridge token values do not match");
537 }
538
539 const provider = envFirst(runtimeEnv, "CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER");
540 if (!provider) {
541 add(warnings, "missing_provider", "runtime.env does not set CODEWHALE_PROVIDER");
542 }
543
544 const runtimePort = Number(envFirst(runtimeEnv, "CODEWHALE_RUNTIME_PORT", "DEEPSEEK_RUNTIME_PORT") || 7878);
545 if (!Number.isInteger(runtimePort) || runtimePort <= 0 || runtimePort > 65535) {
546 add(errors, "invalid_runtime_port", "runtime port must be a valid TCP port");
547 }
548 }
549
550 return {
551 ok: errors.length === 0,
552 errors,
553 warnings,
554 info
555 };
556 }
557
558 export function formatValidationReport(result) {
559 const lines = ["Telegram bridge config validation"];
560 for (const item of result.errors) lines.push(`[fail] ${item.message}`);
561 for (const item of result.warnings) lines.push(`[warn] ${item.message}`);
562 for (const item of result.info) lines.push(`[info] ${item.message}`);
563 if (result.ok) lines.push("[ok] No blocking config errors found");
564 return lines.join("\n");
565 }
566
567 export function helpText() {
568 return [
569 "CodeWhale Telegram bridge commands:",
570 "/menu - open tappable controls",
571 "/help - show this help",
572 "/status - runtime and workspace status",
573 "/threads - recent runtime threads",
574 "/new - create a new thread for this chat",
575 "/resume <thread_id> - bind this chat to an existing thread",
576 "/model <name|default> - set or reset this chat's model",
577 "/interrupt - interrupt the active turn",
578 "/compact - compact the current thread",
579 "/allow <approval_id> [remember] - approve a pending tool call",
580 "/deny <approval_id> - deny a pending tool call",
581 "",
582 "Anything else is sent as a CodeWhale prompt."
583 ].join("\n");
584 }
585
585 lines Plain Text