@nordbyte/nordrelay 0.2.1 → 0.3.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.
- package/.env.example +22 -0
- package/CHANGELOG.md +26 -0
- package/README.md +147 -19
- package/dist/access-control.js +6 -0
- package/dist/agent-adapter.js +60 -0
- package/dist/audit-log.js +54 -0
- package/dist/bot-preferences.js +13 -9
- package/dist/bot-ui.js +6 -0
- package/dist/bot.js +526 -26
- package/dist/channel-adapter.js +58 -0
- package/dist/codex-session.js +3 -1
- package/dist/config.js +47 -0
- package/dist/context-key.js +23 -0
- package/dist/index.js +47 -2
- package/dist/logger.js +24 -1
- package/dist/operations.js +340 -15
- package/dist/prompt-store.js +33 -11
- package/dist/relay-runtime.js +908 -0
- package/dist/session-locks.js +81 -0
- package/dist/session-registry.js +11 -7
- package/dist/settings-service.js +253 -0
- package/dist/state-backend.js +83 -0
- package/dist/web-dashboard.js +890 -0
- package/dist/web-state.js +131 -0
- package/docker-compose.yml +1 -1
- package/package.json +4 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +235 -13
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const TELEGRAM_CAPABILITIES = [
|
|
2
|
+
"text",
|
|
3
|
+
"streaming-edits",
|
|
4
|
+
"typing",
|
|
5
|
+
"inline-buttons",
|
|
6
|
+
"files",
|
|
7
|
+
"photos",
|
|
8
|
+
"voice",
|
|
9
|
+
"topics",
|
|
10
|
+
"webhooks",
|
|
11
|
+
];
|
|
12
|
+
const PLANNED_CHANNELS = [
|
|
13
|
+
{
|
|
14
|
+
id: "discord",
|
|
15
|
+
label: "Discord",
|
|
16
|
+
capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files", "photos", "voice"],
|
|
17
|
+
status: "planned",
|
|
18
|
+
notes: "Adapter boundary is ready; runtime integration still needs bot credentials and event mapping.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "whatsapp",
|
|
22
|
+
label: "WhatsApp",
|
|
23
|
+
capabilities: ["text", "typing", "files", "photos", "voice", "webhooks"],
|
|
24
|
+
status: "planned",
|
|
25
|
+
notes: "Requires a WhatsApp Business provider integration.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "slack",
|
|
29
|
+
label: "Slack",
|
|
30
|
+
capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files"],
|
|
31
|
+
status: "planned",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "matrix",
|
|
35
|
+
label: "Matrix",
|
|
36
|
+
capabilities: ["text", "files", "photos", "voice"],
|
|
37
|
+
status: "planned",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
export class TelegramChannelAdapter {
|
|
41
|
+
id = "telegram";
|
|
42
|
+
label = "Telegram";
|
|
43
|
+
capabilities = new Set(TELEGRAM_CAPABILITIES);
|
|
44
|
+
describe() {
|
|
45
|
+
return {
|
|
46
|
+
id: this.id,
|
|
47
|
+
label: this.label,
|
|
48
|
+
capabilities: [...this.capabilities],
|
|
49
|
+
status: "available",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function listChannelDescriptors() {
|
|
54
|
+
return [
|
|
55
|
+
new TelegramChannelAdapter().describe(),
|
|
56
|
+
...PLANNED_CHANNELS,
|
|
57
|
+
];
|
|
58
|
+
}
|
package/dist/codex-session.js
CHANGED
|
@@ -47,6 +47,8 @@ export class CodexSessionService {
|
|
|
47
47
|
const effectiveLaunchProfile = this.activeThreadLaunchProfile ?? this.currentLaunchProfile;
|
|
48
48
|
const codexFastMode = readCodexFastMode();
|
|
49
49
|
this.lastObservedFastMode = codexFastMode;
|
|
50
|
+
const attachedThreadFastMode = effectiveLaunchProfile.id === "attached-thread" &&
|
|
51
|
+
effectiveLaunchProfile.approvalPolicy === "never";
|
|
50
52
|
const info = {
|
|
51
53
|
agentId: "codex",
|
|
52
54
|
agentLabel: "Codex",
|
|
@@ -58,7 +60,7 @@ export class CodexSessionService {
|
|
|
58
60
|
launchProfileBehavior: formatLaunchProfileBehavior(effectiveLaunchProfile),
|
|
59
61
|
sandboxMode: effectiveLaunchProfile.sandboxMode,
|
|
60
62
|
approvalPolicy: effectiveLaunchProfile.approvalPolicy,
|
|
61
|
-
fastMode: codexFastMode ?? (effectiveLaunchProfile.approvalPolicy === "never"),
|
|
63
|
+
fastMode: attachedThreadFastMode || (codexFastMode ?? (effectiveLaunchProfile.approvalPolicy === "never")),
|
|
62
64
|
unsafeLaunch: effectiveLaunchProfile.unsafe,
|
|
63
65
|
capabilities: CODEX_AGENT_CAPABILITIES,
|
|
64
66
|
};
|
package/dist/config.js
CHANGED
|
@@ -24,9 +24,16 @@ export function loadConfig() {
|
|
|
24
24
|
const telegramNotifyMode = parseNotifyMode(optionalString(process.env.TELEGRAM_NOTIFY_MODE), "minimal");
|
|
25
25
|
const telegramQuietHours = parseQuietHours(optionalString(process.env.TELEGRAM_QUIET_HOURS));
|
|
26
26
|
const telegramRedactPatterns = parseOptionalStringList(optionalString(process.env.TELEGRAM_REDACT_PATTERNS));
|
|
27
|
+
const telegramTransport = parseTelegramTransport(optionalString(process.env.TELEGRAM_TRANSPORT));
|
|
28
|
+
const telegramWebhookUrl = optionalString(process.env.TELEGRAM_WEBHOOK_URL);
|
|
29
|
+
const telegramWebhookHost = optionalString(process.env.TELEGRAM_WEBHOOK_HOST) ?? "127.0.0.1";
|
|
30
|
+
const telegramWebhookPort = parsePositiveIntegerEnv(optionalString(process.env.TELEGRAM_WEBHOOK_PORT), 8080, "TELEGRAM_WEBHOOK_PORT");
|
|
31
|
+
const telegramWebhookPath = parseWebhookPath(optionalString(process.env.TELEGRAM_WEBHOOK_PATH));
|
|
32
|
+
const telegramWebhookSecret = optionalString(process.env.TELEGRAM_WEBHOOK_SECRET);
|
|
27
33
|
const workspace = resolveWorkspace();
|
|
28
34
|
const workspaceAllowedRoots = parsePathList(optionalString(process.env.WORKSPACE_ALLOWED_ROOTS));
|
|
29
35
|
const workspaceWarnRoots = parsePathList(optionalString(process.env.WORKSPACE_WARN_ROOTS));
|
|
36
|
+
const stateBackend = parseStateBackend(optionalString(process.env.NORDRELAY_STATE_BACKEND));
|
|
30
37
|
const maxFileSize = parseMaxFileSize(optionalString(process.env.MAX_FILE_SIZE));
|
|
31
38
|
const artifactRetentionDays = parsePositiveNumberEnv(optionalString(process.env.ARTIFACT_RETENTION_DAYS), 7, "ARTIFACT_RETENTION_DAYS");
|
|
32
39
|
const artifactMaxTurnDirs = parsePositiveIntegerEnv(optionalString(process.env.ARTIFACT_MAX_TURNS), 30, "ARTIFACT_MAX_TURNS");
|
|
@@ -60,6 +67,11 @@ export function loadConfig() {
|
|
|
60
67
|
const voicePreferredBackend = parseVoiceBackendPreference(optionalString(process.env.VOICE_PREFERRED_BACKEND));
|
|
61
68
|
const voiceDefaultLanguage = optionalString(process.env.VOICE_DEFAULT_LANGUAGE);
|
|
62
69
|
const voiceTranscribeOnly = parseBooleanEnv(optionalString(process.env.VOICE_TRANSCRIBE_ONLY), false);
|
|
70
|
+
const auditMaxEvents = parsePositiveIntegerEnv(optionalString(process.env.NORDRELAY_AUDIT_MAX_EVENTS), 1000, "NORDRELAY_AUDIT_MAX_EVENTS");
|
|
71
|
+
const sessionLockTtlMs = parseNonNegativeIntegerEnv(optionalString(process.env.NORDRELAY_SESSION_LOCK_TTL_MS), 30 * 60 * 1000, "NORDRELAY_SESSION_LOCK_TTL_MS");
|
|
72
|
+
if (telegramTransport === "webhook" && !telegramWebhookUrl) {
|
|
73
|
+
throw new Error("TELEGRAM_TRANSPORT=webhook requires TELEGRAM_WEBHOOK_URL");
|
|
74
|
+
}
|
|
63
75
|
return {
|
|
64
76
|
telegramBotToken,
|
|
65
77
|
telegramAllowedUserIds,
|
|
@@ -79,9 +91,16 @@ export function loadConfig() {
|
|
|
79
91
|
telegramNotifyMode,
|
|
80
92
|
telegramQuietHours,
|
|
81
93
|
telegramRedactPatterns,
|
|
94
|
+
telegramTransport,
|
|
95
|
+
telegramWebhookUrl,
|
|
96
|
+
telegramWebhookHost,
|
|
97
|
+
telegramWebhookPort,
|
|
98
|
+
telegramWebhookPath,
|
|
99
|
+
telegramWebhookSecret,
|
|
82
100
|
workspace,
|
|
83
101
|
workspaceAllowedRoots,
|
|
84
102
|
workspaceWarnRoots,
|
|
103
|
+
stateBackend,
|
|
85
104
|
maxFileSize,
|
|
86
105
|
artifactRetentionDays,
|
|
87
106
|
artifactMaxTurnDirs,
|
|
@@ -114,6 +133,8 @@ export function loadConfig() {
|
|
|
114
133
|
voicePreferredBackend,
|
|
115
134
|
voiceDefaultLanguage,
|
|
116
135
|
voiceTranscribeOnly,
|
|
136
|
+
auditMaxEvents,
|
|
137
|
+
sessionLockTtlMs,
|
|
117
138
|
};
|
|
118
139
|
}
|
|
119
140
|
/**
|
|
@@ -313,6 +334,32 @@ function parseLogFormat(raw) {
|
|
|
313
334
|
console.warn(`Invalid CONNECTOR_LOG_FORMAT value: "${raw}". Expected text or json. Falling back to "text".`);
|
|
314
335
|
return "text";
|
|
315
336
|
}
|
|
337
|
+
function parseTelegramTransport(raw) {
|
|
338
|
+
if (!raw) {
|
|
339
|
+
return "polling";
|
|
340
|
+
}
|
|
341
|
+
if (raw === "polling" || raw === "webhook") {
|
|
342
|
+
return raw;
|
|
343
|
+
}
|
|
344
|
+
console.warn(`Invalid TELEGRAM_TRANSPORT value: "${raw}". Expected polling or webhook. Falling back to polling.`);
|
|
345
|
+
return "polling";
|
|
346
|
+
}
|
|
347
|
+
function parseWebhookPath(raw) {
|
|
348
|
+
if (!raw) {
|
|
349
|
+
return "/telegram/webhook";
|
|
350
|
+
}
|
|
351
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
352
|
+
}
|
|
353
|
+
function parseStateBackend(raw) {
|
|
354
|
+
if (!raw) {
|
|
355
|
+
return "json";
|
|
356
|
+
}
|
|
357
|
+
if (raw === "json" || raw === "sqlite") {
|
|
358
|
+
return raw;
|
|
359
|
+
}
|
|
360
|
+
console.warn(`Invalid NORDRELAY_STATE_BACKEND value: "${raw}". Expected json or sqlite. Falling back to json.`);
|
|
361
|
+
return "json";
|
|
362
|
+
}
|
|
316
363
|
function parseLaunchProfiles(raw, codexSandboxMode, codexApprovalPolicy, enableUnsafeLaunchProfiles) {
|
|
317
364
|
const defaultProfile = createDefaultLaunchProfile(codexSandboxMode, codexApprovalPolicy);
|
|
318
365
|
const profiles = createBuiltinLaunchProfiles(defaultProfile, {
|
package/dist/context-key.js
CHANGED
|
@@ -21,3 +21,26 @@ export function parseContextKey(key) {
|
|
|
21
21
|
export function isTopicContextKey(key) {
|
|
22
22
|
return key.includes(":");
|
|
23
23
|
}
|
|
24
|
+
export function isTelegramContextKey(key) {
|
|
25
|
+
const parts = key.split(":");
|
|
26
|
+
if (parts.length < 1 || parts.length > 2) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const chatIdText = parts[0];
|
|
30
|
+
if (!chatIdText || !/^-?\d+$/.test(chatIdText)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const chatId = Number(chatIdText);
|
|
34
|
+
if (!Number.isSafeInteger(chatId) || chatId === 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const threadIdText = parts[1];
|
|
38
|
+
if (threadIdText === undefined) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (!/^\d+$/.test(threadIdText)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const threadId = Number(threadIdText);
|
|
45
|
+
return Number.isSafeInteger(threadId) && threadId > 0;
|
|
46
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
1
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { webhookCallback } from "grammy";
|
|
3
5
|
import { createBot, registerCommands } from "./bot.js";
|
|
4
6
|
import { checkAuthStatus } from "./codex-auth.js";
|
|
5
7
|
import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
|
|
@@ -12,8 +14,11 @@ import { configureRedaction } from "./redaction.js";
|
|
|
12
14
|
import { SessionRegistry } from "./session-registry.js";
|
|
13
15
|
let registry;
|
|
14
16
|
let bot;
|
|
17
|
+
let webhookServer;
|
|
18
|
+
let runtimeConfig;
|
|
15
19
|
try {
|
|
16
20
|
const config = loadConfig();
|
|
21
|
+
runtimeConfig = config;
|
|
17
22
|
configureRedaction(config.telegramRedactPatterns);
|
|
18
23
|
installConsoleLogger(config.logFormat);
|
|
19
24
|
registry = new SessionRegistry(config);
|
|
@@ -46,6 +51,7 @@ try {
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
console.log("Session mode: per Telegram context");
|
|
54
|
+
console.log(`Telegram transport: ${config.telegramTransport}`);
|
|
49
55
|
await writeConnectorState({
|
|
50
56
|
status: "ready",
|
|
51
57
|
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
@@ -56,6 +62,7 @@ try {
|
|
|
56
62
|
authMethod: authStatus.method,
|
|
57
63
|
codexCli: describeCodexCli(codexCli),
|
|
58
64
|
piCli: describePiCli(piCli),
|
|
65
|
+
telegramTransport: config.telegramTransport,
|
|
59
66
|
});
|
|
60
67
|
}
|
|
61
68
|
catch (error) {
|
|
@@ -77,8 +84,9 @@ const shutdown = (signal) => {
|
|
|
77
84
|
}
|
|
78
85
|
shuttingDown = true;
|
|
79
86
|
console.log(`Received ${signal}, shutting down NordRelay...`);
|
|
80
|
-
if (bot)
|
|
87
|
+
if (bot && runtimeConfig?.telegramTransport !== "webhook")
|
|
81
88
|
bot.stop();
|
|
89
|
+
webhookServer?.close();
|
|
82
90
|
setTimeout(() => {
|
|
83
91
|
registry?.disposeAll();
|
|
84
92
|
void writeConnectorState({
|
|
@@ -124,7 +132,44 @@ async function startPolling() {
|
|
|
124
132
|
process.exit(1);
|
|
125
133
|
}
|
|
126
134
|
}
|
|
127
|
-
|
|
135
|
+
if (registry && bot) {
|
|
136
|
+
if (runtimeConfig?.telegramTransport === "webhook") {
|
|
137
|
+
webhookServer = await startWebhook(bot, runtimeConfig);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
await startPolling();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function startWebhook(activeBot, config) {
|
|
144
|
+
const callback = webhookCallback(activeBot, "http", {
|
|
145
|
+
secretToken: config.telegramWebhookSecret,
|
|
146
|
+
});
|
|
147
|
+
const server = createServer((req, res) => {
|
|
148
|
+
if (req.method === "GET" && req.url === "/healthz") {
|
|
149
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
150
|
+
res.end("ok\n");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (req.url?.split("?")[0] !== config.telegramWebhookPath) {
|
|
154
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
155
|
+
res.end("not found\n");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
void callback(req, res);
|
|
159
|
+
});
|
|
160
|
+
await activeBot.api.setWebhook(joinWebhookUrl(config.telegramWebhookUrl, config.telegramWebhookPath), {
|
|
161
|
+
secret_token: config.telegramWebhookSecret,
|
|
162
|
+
drop_pending_updates: process.env.NORDRELAY_DROP_PENDING_UPDATES !== "0",
|
|
163
|
+
});
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
server.listen(config.telegramWebhookPort, config.telegramWebhookHost, resolve);
|
|
166
|
+
});
|
|
167
|
+
console.log(`Webhook listening on ${config.telegramWebhookHost}:${config.telegramWebhookPort}${config.telegramWebhookPath}`);
|
|
168
|
+
return server;
|
|
169
|
+
}
|
|
170
|
+
function joinWebhookUrl(baseUrl, webhookPath) {
|
|
171
|
+
return `${baseUrl.replace(/\/+$/, "")}${webhookPath.startsWith("/") ? webhookPath : `/${webhookPath}`}`;
|
|
172
|
+
}
|
|
128
173
|
async function writeConnectorState(payload) {
|
|
129
174
|
const stateFile = process.env.NORDRELAY_STATE_FILE;
|
|
130
175
|
if (!stateFile) {
|
package/dist/logger.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { redactUnknown } from "./redaction.js";
|
|
2
2
|
export function installConsoleLogger(format) {
|
|
3
|
+
const targetLog = console.log.bind(console);
|
|
4
|
+
const targetWarn = console.warn.bind(console);
|
|
5
|
+
const targetError = console.error.bind(console);
|
|
3
6
|
if (format !== "json") {
|
|
7
|
+
console.log = (...args) => {
|
|
8
|
+
targetLog(toTextRecord("info", args));
|
|
9
|
+
};
|
|
10
|
+
console.warn = (...args) => {
|
|
11
|
+
targetWarn(toTextRecord("warn", args));
|
|
12
|
+
};
|
|
13
|
+
console.error = (...args) => {
|
|
14
|
+
targetError(toTextRecord("error", args));
|
|
15
|
+
};
|
|
4
16
|
return;
|
|
5
17
|
}
|
|
6
|
-
const targetLog = console.log.bind(console);
|
|
7
18
|
console.log = (...args) => {
|
|
8
19
|
targetLog(JSON.stringify(toLogRecord("info", args)));
|
|
9
20
|
};
|
|
@@ -14,6 +25,9 @@ export function installConsoleLogger(format) {
|
|
|
14
25
|
targetLog(JSON.stringify(toLogRecord("error", args)));
|
|
15
26
|
};
|
|
16
27
|
}
|
|
28
|
+
function toTextRecord(level, args) {
|
|
29
|
+
return `[${formatLocalTimestamp(new Date())}] ${level.toUpperCase()} ${args.map(formatArg).join(" ")}`;
|
|
30
|
+
}
|
|
17
31
|
function toLogRecord(level, args) {
|
|
18
32
|
return {
|
|
19
33
|
ts: new Date().toISOString(),
|
|
@@ -25,3 +39,12 @@ function toLogRecord(level, args) {
|
|
|
25
39
|
function formatArg(value) {
|
|
26
40
|
return redactUnknown(value);
|
|
27
41
|
}
|
|
42
|
+
function formatLocalTimestamp(date) {
|
|
43
|
+
return [
|
|
44
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
|
|
45
|
+
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`,
|
|
46
|
+
].join(" ");
|
|
47
|
+
}
|
|
48
|
+
function pad(value) {
|
|
49
|
+
return String(value).padStart(2, "0");
|
|
50
|
+
}
|