@nordbyte/nordrelay 0.2.1

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 (45) hide show
  1. package/.env.example +88 -0
  2. package/Dockerfile +19 -0
  3. package/LICENSE +21 -0
  4. package/README.md +749 -0
  5. package/dist/access-control.js +146 -0
  6. package/dist/agent-factory.js +22 -0
  7. package/dist/agent.js +57 -0
  8. package/dist/artifacts.js +515 -0
  9. package/dist/attachments.js +69 -0
  10. package/dist/bot-preferences.js +146 -0
  11. package/dist/bot-ui.js +161 -0
  12. package/dist/bot.js +4520 -0
  13. package/dist/codex-auth.js +150 -0
  14. package/dist/codex-cli.js +79 -0
  15. package/dist/codex-config.js +50 -0
  16. package/dist/codex-launch.js +109 -0
  17. package/dist/codex-session.js +591 -0
  18. package/dist/codex-state.js +573 -0
  19. package/dist/config.js +385 -0
  20. package/dist/context-key.js +23 -0
  21. package/dist/error-messages.js +73 -0
  22. package/dist/format.js +121 -0
  23. package/dist/index.js +140 -0
  24. package/dist/logger.js +27 -0
  25. package/dist/operations.js +133 -0
  26. package/dist/persistence.js +65 -0
  27. package/dist/pi-cli.js +19 -0
  28. package/dist/pi-rpc.js +158 -0
  29. package/dist/pi-session.js +573 -0
  30. package/dist/pi-state.js +226 -0
  31. package/dist/prompt-store.js +241 -0
  32. package/dist/redaction.js +47 -0
  33. package/dist/session-format.js +191 -0
  34. package/dist/session-registry.js +195 -0
  35. package/dist/telegram-rate-limit.js +136 -0
  36. package/dist/voice.js +373 -0
  37. package/dist/workspace-policy.js +41 -0
  38. package/docker-compose.yml +17 -0
  39. package/launchd/start.sh +8 -0
  40. package/package.json +69 -0
  41. package/plugins/nordrelay/.codex-plugin/plugin.json +48 -0
  42. package/plugins/nordrelay/assets/nordrelay.svg +5 -0
  43. package/plugins/nordrelay/commands/remote.md +33 -0
  44. package/plugins/nordrelay/scripts/nordrelay.mjs +396 -0
  45. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +26 -0
package/dist/config.js ADDED
@@ -0,0 +1,385 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { createBuiltinLaunchProfiles, createDefaultLaunchProfile, findLaunchProfile, isCodexApprovalPolicy, isCodexSandboxMode, parseLaunchProfilesJson, } from "./codex-launch.js";
4
+ import { isAgentId, PI_THINKING_LEVELS } from "./agent.js";
5
+ import { parseRolePoliciesJson, } from "./access-control.js";
6
+ import { parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
7
+ export function loadConfig() {
8
+ loadEnvFile(path.resolve(process.cwd(), ".env"));
9
+ const telegramBotToken = requireEnv("TELEGRAM_BOT_TOKEN");
10
+ const telegramAllowAnyChat = parseBooleanEnv(optionalString(process.env.TELEGRAM_ALLOW_ANY_CHAT), false);
11
+ const configuredAllowedUserIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_ALLOWED_USER_IDS), "TELEGRAM_ALLOWED_USER_IDS", { positiveOnly: true });
12
+ const telegramAllowedChatIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_ALLOWED_CHAT_IDS), "TELEGRAM_ALLOWED_CHAT_IDS", { positiveOnly: false });
13
+ const configuredAdminUserIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_ADMIN_USER_IDS), "TELEGRAM_ADMIN_USER_IDS", { positiveOnly: true });
14
+ ensureTelegramAdminIds(configuredAdminUserIds);
15
+ const telegramAllowedUserIds = mergeUniqueIds(configuredAllowedUserIds, configuredAdminUserIds);
16
+ const telegramReadOnlyUserIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_READONLY_USER_IDS), "TELEGRAM_READONLY_USER_IDS", { positiveOnly: true });
17
+ const telegramRolePolicies = parseRolePoliciesJson(optionalString(process.env.TELEGRAM_ROLE_POLICIES_JSON));
18
+ ensureTelegramAllowlist(telegramAllowedUserIds, telegramAllowedChatIds, telegramAllowAnyChat);
19
+ const telegramAdminUserIds = configuredAdminUserIds;
20
+ const telegramRateLimitMinIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS), 80, "TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS");
21
+ const telegramEditMinIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_EDIT_MIN_INTERVAL_MS), 1_200, "TELEGRAM_EDIT_MIN_INTERVAL_MS");
22
+ const telegramMirrorMode = parseMirrorMode(optionalString(process.env.TELEGRAM_CLI_MIRROR_MODE), "status");
23
+ const telegramMirrorMinUpdateMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS), 4_000, "TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS");
24
+ const telegramNotifyMode = parseNotifyMode(optionalString(process.env.TELEGRAM_NOTIFY_MODE), "minimal");
25
+ const telegramQuietHours = parseQuietHours(optionalString(process.env.TELEGRAM_QUIET_HOURS));
26
+ const telegramRedactPatterns = parseOptionalStringList(optionalString(process.env.TELEGRAM_REDACT_PATTERNS));
27
+ const workspace = resolveWorkspace();
28
+ const workspaceAllowedRoots = parsePathList(optionalString(process.env.WORKSPACE_ALLOWED_ROOTS));
29
+ const workspaceWarnRoots = parsePathList(optionalString(process.env.WORKSPACE_WARN_ROOTS));
30
+ const maxFileSize = parseMaxFileSize(optionalString(process.env.MAX_FILE_SIZE));
31
+ const artifactRetentionDays = parsePositiveNumberEnv(optionalString(process.env.ARTIFACT_RETENTION_DAYS), 7, "ARTIFACT_RETENTION_DAYS");
32
+ const artifactMaxTurnDirs = parsePositiveIntegerEnv(optionalString(process.env.ARTIFACT_MAX_TURNS), 30, "ARTIFACT_MAX_TURNS");
33
+ const artifactMaxInboxDirs = parsePositiveIntegerEnv(optionalString(process.env.ARTIFACT_MAX_INBOX_DIRS), 30, "ARTIFACT_MAX_INBOX_DIRS");
34
+ const artifactIgnoreDirs = parseOptionalStringList(optionalString(process.env.ARTIFACT_IGNORE_DIRS));
35
+ const artifactIgnoreGlobs = parseOptionalStringList(optionalString(process.env.ARTIFACT_IGNORE_GLOBS));
36
+ const telegramAutoSendArtifacts = parseBooleanEnv(optionalString(process.env.TELEGRAM_AUTO_SEND_ARTIFACTS), false);
37
+ const codexEnabled = parseBooleanEnv(optionalString(process.env.NORDRELAY_CODEX_ENABLED), true);
38
+ const codexApiKey = optionalString(process.env.CODEX_API_KEY);
39
+ const codexModel = optionalString(process.env.CODEX_MODEL);
40
+ const codexSyncIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.CODEX_SYNC_INTERVAL_MS), 10_000, "CODEX_SYNC_INTERVAL_MS");
41
+ const codexExternalBusyCheckMs = parsePositiveIntegerEnv(optionalString(process.env.CODEX_EXTERNAL_BUSY_CHECK_MS), 5_000, "CODEX_EXTERNAL_BUSY_CHECK_MS");
42
+ const codexExternalBusyStaleMs = parsePositiveIntegerEnv(optionalString(process.env.CODEX_EXTERNAL_BUSY_STALE_MS), 5 * 60 * 1000, "CODEX_EXTERNAL_BUSY_STALE_MS");
43
+ const codexSandboxMode = parseSandboxMode(optionalString(process.env.CODEX_SANDBOX_MODE));
44
+ const codexApprovalPolicy = parseApprovalPolicy(optionalString(process.env.CODEX_APPROVAL_POLICY));
45
+ const enableUnsafeLaunchProfiles = parseBooleanEnv(optionalString(process.env.ENABLE_UNSAFE_LAUNCH_PROFILES), false);
46
+ const launchProfiles = parseLaunchProfiles(optionalString(process.env.CODEX_LAUNCH_PROFILES_JSON), codexSandboxMode, codexApprovalPolicy, enableUnsafeLaunchProfiles);
47
+ const defaultLaunchProfileId = parseDefaultLaunchProfileId(optionalString(process.env.CODEX_DEFAULT_LAUNCH_PROFILE), launchProfiles);
48
+ const piEnabled = parseBooleanEnv(optionalString(process.env.NORDRELAY_PI_ENABLED), false);
49
+ ensureAtLeastOneAgentEnabled(codexEnabled, piEnabled);
50
+ const piCliPath = optionalString(process.env.PI_CLI_PATH);
51
+ const piSessionDir = optionalString(process.env.PI_SESSION_DIR);
52
+ const piDefaultModel = optionalString(process.env.PI_DEFAULT_MODEL);
53
+ const piDefaultThinking = parsePiThinkingLevel(optionalString(process.env.PI_DEFAULT_THINKING));
54
+ const defaultAgent = parseDefaultAgent(optionalString(process.env.NORDRELAY_DEFAULT_AGENT), codexEnabled, piEnabled);
55
+ const toolVerbosity = parseToolVerbosity(optionalString(process.env.TOOL_VERBOSITY));
56
+ const logFormat = parseLogFormat(optionalString(process.env.CONNECTOR_LOG_FORMAT));
57
+ const showTurnTokenUsage = parseBooleanEnv(optionalString(process.env.SHOW_TURN_TOKEN_USAGE), false);
58
+ const enableTelegramLogin = parseBooleanEnv(optionalString(process.env.ENABLE_TELEGRAM_LOGIN), true);
59
+ const enableTelegramReactions = parseBooleanEnv(optionalString(process.env.ENABLE_TELEGRAM_REACTIONS), false);
60
+ const voicePreferredBackend = parseVoiceBackendPreference(optionalString(process.env.VOICE_PREFERRED_BACKEND));
61
+ const voiceDefaultLanguage = optionalString(process.env.VOICE_DEFAULT_LANGUAGE);
62
+ const voiceTranscribeOnly = parseBooleanEnv(optionalString(process.env.VOICE_TRANSCRIBE_ONLY), false);
63
+ return {
64
+ telegramBotToken,
65
+ telegramAllowedUserIds,
66
+ telegramAllowedUserIdSet: new Set(telegramAllowedUserIds),
67
+ telegramAllowedChatIds,
68
+ telegramAllowedChatIdSet: new Set(telegramAllowedChatIds),
69
+ telegramAdminUserIds,
70
+ telegramAdminUserIdSet: new Set(telegramAdminUserIds),
71
+ telegramReadOnlyUserIds,
72
+ telegramReadOnlyUserIdSet: new Set(telegramReadOnlyUserIds),
73
+ telegramRolePolicies,
74
+ telegramAllowAnyChat,
75
+ telegramRateLimitMinIntervalMs,
76
+ telegramEditMinIntervalMs,
77
+ telegramMirrorMode,
78
+ telegramMirrorMinUpdateMs,
79
+ telegramNotifyMode,
80
+ telegramQuietHours,
81
+ telegramRedactPatterns,
82
+ workspace,
83
+ workspaceAllowedRoots,
84
+ workspaceWarnRoots,
85
+ maxFileSize,
86
+ artifactRetentionDays,
87
+ artifactMaxTurnDirs,
88
+ artifactMaxInboxDirs,
89
+ artifactIgnoreDirs,
90
+ artifactIgnoreGlobs,
91
+ telegramAutoSendArtifacts,
92
+ codexEnabled,
93
+ codexApiKey,
94
+ codexModel,
95
+ codexSyncIntervalMs,
96
+ codexExternalBusyCheckMs,
97
+ codexExternalBusyStaleMs,
98
+ codexSandboxMode,
99
+ codexApprovalPolicy,
100
+ launchProfiles,
101
+ defaultLaunchProfileId,
102
+ enableUnsafeLaunchProfiles,
103
+ piEnabled,
104
+ piCliPath,
105
+ piSessionDir,
106
+ piDefaultModel,
107
+ piDefaultThinking,
108
+ defaultAgent,
109
+ toolVerbosity,
110
+ logFormat,
111
+ showTurnTokenUsage,
112
+ enableTelegramLogin,
113
+ enableTelegramReactions,
114
+ voicePreferredBackend,
115
+ voiceDefaultLanguage,
116
+ voiceTranscribeOnly,
117
+ };
118
+ }
119
+ /**
120
+ * Workspace is derived automatically:
121
+ * - In Docker: /workspace (the mount point)
122
+ * - Outside Docker: process.cwd()
123
+ */
124
+ function resolveWorkspace() {
125
+ if (isRunningInDocker()) {
126
+ return "/workspace";
127
+ }
128
+ return process.cwd();
129
+ }
130
+ function isRunningInDocker() {
131
+ return existsSync("/.dockerenv") || process.env.container === "docker";
132
+ }
133
+ function loadEnvFile(envPath) {
134
+ if (!existsSync(envPath)) {
135
+ return;
136
+ }
137
+ const contents = readFileSync(envPath, "utf8");
138
+ for (const rawLine of contents.split(/\r?\n/)) {
139
+ const line = rawLine.trim();
140
+ if (!line || line.startsWith("#")) {
141
+ continue;
142
+ }
143
+ const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
144
+ const separatorIndex = normalized.indexOf("=");
145
+ if (separatorIndex === -1) {
146
+ continue;
147
+ }
148
+ const key = normalized.slice(0, separatorIndex).trim();
149
+ let value = normalized.slice(separatorIndex + 1).trim();
150
+ if (!key || process.env[key] !== undefined) {
151
+ continue;
152
+ }
153
+ if ((value.startsWith('"') && value.endsWith('"')) ||
154
+ (value.startsWith("'") && value.endsWith("'"))) {
155
+ value = value.slice(1, -1);
156
+ }
157
+ process.env[key] = value.replace(/\\n/g, "\n");
158
+ }
159
+ }
160
+ function requireEnv(name) {
161
+ const value = optionalString(process.env[name]);
162
+ if (!value) {
163
+ throw new Error(`Missing required environment variable: ${name}`);
164
+ }
165
+ return value;
166
+ }
167
+ function optionalString(value) {
168
+ const trimmed = value?.trim();
169
+ return trimmed ? trimmed : undefined;
170
+ }
171
+ function parseOptionalIdList(raw, envName, options) {
172
+ if (!raw) {
173
+ return [];
174
+ }
175
+ const ids = raw
176
+ .split(",")
177
+ .map((value) => value.trim())
178
+ .filter(Boolean)
179
+ .map((value) => {
180
+ const parsed = Number(value);
181
+ if (!Number.isInteger(parsed) || (options.positiveOnly ? parsed <= 0 : parsed === 0)) {
182
+ throw new Error(`Invalid Telegram id in ${envName}: ${value}`);
183
+ }
184
+ return parsed;
185
+ });
186
+ if (raw.trim() && ids.length === 0) {
187
+ throw new Error(`${envName} must contain at least one id`);
188
+ }
189
+ return ids;
190
+ }
191
+ function parseOptionalStringList(raw) {
192
+ if (!raw) {
193
+ return [];
194
+ }
195
+ return raw
196
+ .split(",")
197
+ .map((value) => value.trim())
198
+ .filter(Boolean);
199
+ }
200
+ function parsePathList(raw) {
201
+ return parseOptionalStringList(raw).map((value) => path.resolve(value));
202
+ }
203
+ function ensureTelegramAllowlist(userIds, chatIds, allowAnyChat) {
204
+ if (allowAnyChat) {
205
+ return;
206
+ }
207
+ if (userIds.length > 0 || chatIds.length > 0) {
208
+ return;
209
+ }
210
+ throw new Error("TELEGRAM_ALLOWED_USER_IDS or TELEGRAM_ALLOWED_CHAT_IDS must contain at least one id");
211
+ }
212
+ function ensureTelegramAdminIds(userIds) {
213
+ if (userIds.length === 0) {
214
+ throw new Error("TELEGRAM_ADMIN_USER_IDS must contain at least one id");
215
+ }
216
+ }
217
+ function mergeUniqueIds(...groups) {
218
+ return Array.from(new Set(groups.flat()));
219
+ }
220
+ function parseBooleanEnv(raw, defaultValue) {
221
+ if (!raw) {
222
+ return defaultValue;
223
+ }
224
+ const lower = raw.toLowerCase();
225
+ if (lower === "true" || lower === "1" || lower === "yes") {
226
+ return true;
227
+ }
228
+ if (lower === "false" || lower === "0" || lower === "no") {
229
+ return false;
230
+ }
231
+ console.warn(`Invalid boolean env value: "${raw}". Falling back to ${defaultValue}.`);
232
+ return defaultValue;
233
+ }
234
+ function parseMaxFileSize(raw) {
235
+ if (!raw) {
236
+ return 20 * 1024 * 1024;
237
+ }
238
+ const parsed = Number(raw);
239
+ if (Number.isNaN(parsed) || parsed <= 0) {
240
+ console.warn(`Invalid MAX_FILE_SIZE value: "${raw}". Falling back to 20 MB.`);
241
+ return 20 * 1024 * 1024;
242
+ }
243
+ return parsed;
244
+ }
245
+ function parsePositiveNumberEnv(raw, defaultValue, envName) {
246
+ if (!raw) {
247
+ return defaultValue;
248
+ }
249
+ const parsed = Number(raw);
250
+ if (!Number.isFinite(parsed) || parsed <= 0) {
251
+ console.warn(`Invalid ${envName} value: "${raw}". Falling back to ${defaultValue}.`);
252
+ return defaultValue;
253
+ }
254
+ return parsed;
255
+ }
256
+ function parsePositiveIntegerEnv(raw, defaultValue, envName) {
257
+ const parsed = parsePositiveNumberEnv(raw, defaultValue, envName);
258
+ return Math.floor(parsed);
259
+ }
260
+ function parseNonNegativeIntegerEnv(raw, defaultValue, envName) {
261
+ if (!raw) {
262
+ return defaultValue;
263
+ }
264
+ const parsed = Number(raw);
265
+ if (!Number.isFinite(parsed) || parsed < 0) {
266
+ console.warn(`Invalid ${envName} value: "${raw}". Falling back to ${defaultValue}.`);
267
+ return defaultValue;
268
+ }
269
+ return Math.floor(parsed);
270
+ }
271
+ function parseSandboxMode(raw) {
272
+ if (!raw) {
273
+ return "workspace-write";
274
+ }
275
+ if (!isCodexSandboxMode(raw)) {
276
+ console.warn(`Invalid CODEX_SANDBOX_MODE value: "${raw}". Expected one of: read-only, workspace-write, danger-full-access. Falling back to "workspace-write".`);
277
+ return "workspace-write";
278
+ }
279
+ return raw;
280
+ }
281
+ function parseApprovalPolicy(raw) {
282
+ if (!raw) {
283
+ return "never";
284
+ }
285
+ if (!isCodexApprovalPolicy(raw)) {
286
+ console.warn(`Invalid CODEX_APPROVAL_POLICY value: "${raw}". Expected one of: never, on-request, on-failure, untrusted. Falling back to "never".`);
287
+ return "never";
288
+ }
289
+ return raw;
290
+ }
291
+ function parseToolVerbosity(raw) {
292
+ if (!raw) {
293
+ return "summary";
294
+ }
295
+ switch (raw) {
296
+ case "all":
297
+ case "summary":
298
+ case "errors-only":
299
+ case "none":
300
+ return raw;
301
+ default:
302
+ console.warn(`Invalid TOOL_VERBOSITY value: "${raw}". Expected one of: all, summary, errors-only, none. Falling back to "summary".`);
303
+ return "summary";
304
+ }
305
+ }
306
+ function parseLogFormat(raw) {
307
+ if (!raw) {
308
+ return "text";
309
+ }
310
+ if (raw === "text" || raw === "json") {
311
+ return raw;
312
+ }
313
+ console.warn(`Invalid CONNECTOR_LOG_FORMAT value: "${raw}". Expected text or json. Falling back to "text".`);
314
+ return "text";
315
+ }
316
+ function parseLaunchProfiles(raw, codexSandboxMode, codexApprovalPolicy, enableUnsafeLaunchProfiles) {
317
+ const defaultProfile = createDefaultLaunchProfile(codexSandboxMode, codexApprovalPolicy);
318
+ const profiles = createBuiltinLaunchProfiles(defaultProfile, {
319
+ includeFullAccess: enableUnsafeLaunchProfiles,
320
+ });
321
+ if (!raw) {
322
+ return profiles;
323
+ }
324
+ const parsedProfiles = parseLaunchProfilesJson(raw);
325
+ const profileIndexes = new Map(profiles.map((profile, index) => [profile.id, index]));
326
+ const explicitIds = new Set();
327
+ for (const profile of parsedProfiles) {
328
+ if (profile.id === defaultProfile.id || explicitIds.has(profile.id)) {
329
+ throw new Error(`Duplicate launch profile id: ${profile.id}`);
330
+ }
331
+ if (profile.unsafe && !enableUnsafeLaunchProfiles) {
332
+ throw new Error(`Unsafe launch profile "${profile.id}" requires ENABLE_UNSAFE_LAUNCH_PROFILES=true`);
333
+ }
334
+ const existingIndex = profileIndexes.get(profile.id);
335
+ if (existingIndex === undefined) {
336
+ profiles.push(profile);
337
+ profileIndexes.set(profile.id, profiles.length - 1);
338
+ }
339
+ else {
340
+ profiles[existingIndex] = profile;
341
+ }
342
+ explicitIds.add(profile.id);
343
+ }
344
+ return profiles;
345
+ }
346
+ function parseDefaultLaunchProfileId(raw, launchProfiles) {
347
+ if (!raw) {
348
+ return launchProfiles[0].id;
349
+ }
350
+ const profile = findLaunchProfile(launchProfiles, raw);
351
+ if (!profile) {
352
+ throw new Error(`Unknown CODEX_DEFAULT_LAUNCH_PROFILE: ${raw}`);
353
+ }
354
+ return profile.id;
355
+ }
356
+ function ensureAtLeastOneAgentEnabled(codexEnabled, piEnabled) {
357
+ if (!codexEnabled && !piEnabled) {
358
+ throw new Error("At least one agent must be enabled: set NORDRELAY_CODEX_ENABLED=true or NORDRELAY_PI_ENABLED=true");
359
+ }
360
+ }
361
+ function parseDefaultAgent(raw, codexEnabled, piEnabled) {
362
+ if (!raw) {
363
+ return codexEnabled ? "codex" : "pi";
364
+ }
365
+ if (!isAgentId(raw)) {
366
+ throw new Error(`Invalid NORDRELAY_DEFAULT_AGENT: ${raw}. Expected codex or pi`);
367
+ }
368
+ if (raw === "codex" && !codexEnabled) {
369
+ throw new Error("NORDRELAY_DEFAULT_AGENT=codex requires NORDRELAY_CODEX_ENABLED=true");
370
+ }
371
+ if (raw === "pi" && !piEnabled) {
372
+ throw new Error("NORDRELAY_DEFAULT_AGENT=pi requires NORDRELAY_PI_ENABLED=true");
373
+ }
374
+ return raw;
375
+ }
376
+ function parsePiThinkingLevel(raw) {
377
+ if (!raw) {
378
+ return "medium";
379
+ }
380
+ if (PI_THINKING_LEVELS.includes(raw)) {
381
+ return raw;
382
+ }
383
+ console.warn(`Invalid PI_DEFAULT_THINKING value: "${raw}". Expected one of: ${PI_THINKING_LEVELS.join(", ")}. Falling back to "medium".`);
384
+ return "medium";
385
+ }
@@ -0,0 +1,23 @@
1
+ export function contextKeyFromMessage(chatId, messageThreadId) {
2
+ if (messageThreadId !== undefined) {
3
+ return `${chatId}:${messageThreadId}`;
4
+ }
5
+ return `${chatId}`;
6
+ }
7
+ export function contextKeyFromCtx(ctx) {
8
+ const chatId = ctx.chat?.id;
9
+ if (chatId === undefined) {
10
+ return null;
11
+ }
12
+ const threadId = ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id;
13
+ return contextKeyFromMessage(chatId, threadId);
14
+ }
15
+ export function parseContextKey(key) {
16
+ const parts = key.split(":");
17
+ const chatId = Number(parts[0]);
18
+ const messageThreadId = parts[1] ? Number(parts[1]) : undefined;
19
+ return { chatId, messageThreadId };
20
+ }
21
+ export function isTopicContextKey(key) {
22
+ return key.includes(":");
23
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Translate raw errors into user-friendly Telegram messages.
3
+ * Raw details are preserved for console logging only.
4
+ */
5
+ const ERROR_PATTERNS = [
6
+ {
7
+ pattern: /ECONNREFUSED|ENOTFOUND|ENETUNREACH|fetch failed/i,
8
+ message: "Cannot reach the Codex API. Check your network connection.",
9
+ },
10
+ {
11
+ pattern: /429|rate.?limit|too many requests/i,
12
+ message: "Rate limited by the API. Wait a moment and try again.",
13
+ },
14
+ {
15
+ pattern: /401|unauthorized|authentication|invalid.*api.?key/i,
16
+ message: "Authentication failed. Use /login to re-authenticate or check your API key.",
17
+ },
18
+ {
19
+ pattern: /403|forbidden|permission/i,
20
+ message: "Access denied. Check your API key permissions.",
21
+ },
22
+ {
23
+ pattern: /404.*model|model.*not.*found|invalid.*model|model.*does not exist/i,
24
+ message: "Model not available. Use /model to pick a different one.",
25
+ },
26
+ {
27
+ pattern: /timeout|ETIMEDOUT|ESOCKETTIMEDOUT/i,
28
+ message: "Request timed out. Try a shorter prompt or use /retry.",
29
+ },
30
+ {
31
+ pattern: /500|internal.?server.?error/i,
32
+ message: "The API returned a server error. Try again in a moment.",
33
+ },
34
+ {
35
+ pattern: /502|503|504|bad.?gateway|service.?unavailable/i,
36
+ message: "The API is temporarily unavailable. Try again shortly.",
37
+ },
38
+ {
39
+ pattern: /context.?length|token.?limit|too.?long/i,
40
+ message: "The conversation is too long for this model. Start a /new thread.",
41
+ },
42
+ {
43
+ pattern: /^(?:AbortError|The operation was aborted)/i,
44
+ message: "⏹ Aborted",
45
+ },
46
+ ];
47
+ export function translateError(error) {
48
+ const raw = extractRawMessage(error);
49
+ const logMessage = raw;
50
+ for (const { pattern, message } of ERROR_PATTERNS) {
51
+ if (pattern.test(raw)) {
52
+ return { userMessage: message, logMessage };
53
+ }
54
+ }
55
+ const cleaned = stripStackTrace(raw);
56
+ return { userMessage: cleaned, logMessage };
57
+ }
58
+ export function friendlyErrorText(error) {
59
+ return translateError(error).userMessage;
60
+ }
61
+ function extractRawMessage(error) {
62
+ if (error instanceof Error) {
63
+ const cause = error.cause;
64
+ const base = error.message || String(error);
65
+ return cause?.message ? `${base}: ${cause.message}` : base;
66
+ }
67
+ return String(error);
68
+ }
69
+ function stripStackTrace(message) {
70
+ // Remove stack frame lines (lines starting with "at ")
71
+ const lines = message.split("\n").filter((line) => !line.trim().startsWith("at "));
72
+ return lines.join("\n").trim() || message.trim();
73
+ }
package/dist/format.js ADDED
@@ -0,0 +1,121 @@
1
+ const CODE_BLOCK_PREFIX = "\uE000CODE_";
2
+ const CODE_BLOCK_SUFFIX = "_\uE000";
3
+ const INLINE_CODE_PREFIX = "\uE001INLINE_";
4
+ const INLINE_CODE_SUFFIX = "_\uE001";
5
+ export function escapeHTML(text) {
6
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7
+ }
8
+ export function formatTelegramHTML(markdown) {
9
+ if (!markdown) {
10
+ return "";
11
+ }
12
+ const escaped = escapeHTML(markdown);
13
+ const codeBlocks = [];
14
+ const inlineCode = [];
15
+ let text = extractCodeBlocks(escaped, codeBlocks);
16
+ text = extractInlineCode(text, inlineCode);
17
+ text = formatBold(text);
18
+ text = formatItalic(text);
19
+ text = formatLinks(text);
20
+ text = formatBlockquotes(text);
21
+ text = restorePlaceholders(text, INLINE_CODE_PREFIX, INLINE_CODE_SUFFIX, inlineCode);
22
+ text = restorePlaceholders(text, CODE_BLOCK_PREFIX, CODE_BLOCK_SUFFIX, codeBlocks);
23
+ return text;
24
+ }
25
+ function extractCodeBlocks(text, codeBlocks) {
26
+ return text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, rawLanguage, rawCode) => {
27
+ const language = sanitizeLanguage(rawLanguage);
28
+ const code = language
29
+ ? `<pre><code class="language-${language}">${rawCode}</code></pre>`
30
+ : `<pre><code>${rawCode}</code></pre>`;
31
+ const index = codeBlocks.push(code) - 1;
32
+ return `${CODE_BLOCK_PREFIX}${index}${CODE_BLOCK_SUFFIX}`;
33
+ });
34
+ }
35
+ function extractInlineCode(text, inlineCode) {
36
+ let result = "";
37
+ let index = 0;
38
+ while (index < text.length) {
39
+ if (text[index] !== "`") {
40
+ result += text[index];
41
+ index += 1;
42
+ continue;
43
+ }
44
+ let tickCount = 1;
45
+ while (text[index + tickCount] === "`") {
46
+ tickCount += 1;
47
+ }
48
+ const fence = "`".repeat(tickCount);
49
+ const start = index + tickCount;
50
+ const end = text.indexOf(fence, start);
51
+ if (end === -1) {
52
+ result += fence;
53
+ index += tickCount;
54
+ continue;
55
+ }
56
+ const content = text.slice(start, end);
57
+ if (content.includes("\n")) {
58
+ result += fence;
59
+ index += tickCount;
60
+ continue;
61
+ }
62
+ const placeholder = `${INLINE_CODE_PREFIX}${inlineCode.push(`<code>${content}</code>`) - 1}${INLINE_CODE_SUFFIX}`;
63
+ result += placeholder;
64
+ index = end + tickCount;
65
+ }
66
+ return result;
67
+ }
68
+ function formatBold(text) {
69
+ return text.replace(/(?<!\*)\*\*(?!\s)([^\n]*?\S)\*\*(?!\*)/g, "<b>$1</b>");
70
+ }
71
+ function formatItalic(text) {
72
+ const withUnderscores = text.replace(/(?<![\w_])_(?!\s)([^_\n]*?\S)_(?![\w_])/g, "<i>$1</i>");
73
+ return withUnderscores.replace(/(?<![\w*])\*(?!\s)([^*\n]*?\S)\*(?![\w*])/g, "<i>$1</i>");
74
+ }
75
+ function formatLinks(text) {
76
+ return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => {
77
+ const safeUrl = sanitizeUrl(url);
78
+ return `<a href="${safeUrl}">${label}</a>`;
79
+ });
80
+ }
81
+ function formatBlockquotes(text) {
82
+ const lines = text.split("\n");
83
+ const output = [];
84
+ let quoteLines = [];
85
+ const flush = () => {
86
+ if (quoteLines.length === 0) {
87
+ return;
88
+ }
89
+ output.push(`<blockquote>${quoteLines.join("\n")}</blockquote>`);
90
+ quoteLines = [];
91
+ };
92
+ for (const line of lines) {
93
+ const match = line.match(/^&gt; (.*)$/);
94
+ if (match) {
95
+ quoteLines.push(match[1]);
96
+ continue;
97
+ }
98
+ flush();
99
+ output.push(line);
100
+ }
101
+ flush();
102
+ return output.join("\n");
103
+ }
104
+ function restorePlaceholders(text, prefix, suffix, values) {
105
+ const pattern = new RegExp(`${escapeRegExp(prefix)}(\\d+)${escapeRegExp(suffix)}`, "g");
106
+ return text.replace(pattern, (_match, rawIndex) => values[Number.parseInt(rawIndex, 10)] ?? "");
107
+ }
108
+ function sanitizeLanguage(language) {
109
+ return language.trim().replace(/[^a-zA-Z0-9_+-]/g, "");
110
+ }
111
+ const SAFE_URL_PROTOCOL = /^(https?|tg|mailto):/i;
112
+ function sanitizeUrl(url) {
113
+ const trimmed = url.trim().replace(/"/g, "%22");
114
+ if (!SAFE_URL_PROTOCOL.test(trimmed)) {
115
+ return "#";
116
+ }
117
+ return trimmed;
118
+ }
119
+ function escapeRegExp(text) {
120
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
121
+ }