@nordbyte/nordrelay 0.5.2 → 0.7.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/.env.example +80 -11
- package/README.md +154 -22
- package/dist/access-control.js +7 -1
- package/dist/activity-events.js +44 -0
- package/dist/audit-log.js +40 -2
- package/dist/bot-preferences.js +1 -0
- package/dist/bot-rendering.js +10 -7
- package/dist/bot.js +535 -11
- package/dist/channel-actions.js +7 -2
- package/dist/channel-adapter.js +40 -7
- package/dist/channel-command-catalog.js +88 -0
- package/dist/channel-command-service.js +369 -0
- package/dist/channel-mirror-registry.js +77 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-service.js +237 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +93 -13
- package/dist/config.js +103 -8
- package/dist/context-key.js +87 -5
- package/dist/discord-artifacts.js +165 -0
- package/dist/discord-bot.js +2073 -0
- package/dist/discord-channel-runtime.js +133 -0
- package/dist/discord-command-surface.js +57 -0
- package/dist/discord-rate-limit.js +141 -0
- package/dist/index.js +36 -5
- package/dist/job-store.js +127 -0
- package/dist/metrics.js +87 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +256 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-runtime-service.js +636 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +294 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-external-activity-monitor.js +47 -6
- package/dist/relay-runtime-helpers.js +208 -0
- package/dist/relay-runtime.js +897 -394
- package/dist/remote-prompt.js +98 -0
- package/dist/runtime-cache.js +57 -0
- package/dist/session-locks.js +10 -7
- package/dist/support-bundle.js +1 -0
- package/dist/telegram-access-commands.js +15 -2
- package/dist/telegram-access-middleware.js +16 -3
- package/dist/telegram-agent-commands.js +25 -0
- package/dist/telegram-artifact-commands.js +46 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-diagnostics-command.js +5 -50
- package/dist/telegram-general-commands.js +16 -6
- package/dist/telegram-operational-commands.js +14 -6
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/telegram-queue-commands.js +74 -4
- package/dist/telegram-support-command.js +7 -0
- package/dist/telegram-update-commands.js +27 -0
- package/dist/user-management.js +208 -0
- package/dist/web-api-contract.js +17 -0
- package/dist/web-dashboard-access-routes.js +74 -1
- package/dist/web-dashboard-artifact-routes.js +3 -3
- package/dist/web-dashboard-assets.js +2 -0
- package/dist/web-dashboard-pages.js +109 -13
- package/dist/web-dashboard-peer-routes.js +204 -0
- package/dist/web-dashboard-runtime-routes.js +53 -8
- package/dist/web-dashboard-session-routes.js +27 -20
- package/dist/web-dashboard-ui.js +2 -0
- package/dist/web-dashboard.js +160 -6
- package/dist/web-state.js +33 -2
- package/dist/webui-assets/dashboard.css +75 -1
- package/dist/webui-assets/dashboard.js +779 -55
- package/package.json +5 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, } from "discord.js";
|
|
2
|
+
import { DiscordChannelAdapter, } from "./channel-adapter.js";
|
|
3
|
+
import { discordRateLimiter } from "./discord-rate-limit.js";
|
|
4
|
+
import { redactText } from "./redaction.js";
|
|
5
|
+
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
6
|
+
const DISCORD_SAFE_MESSAGE_LIMIT = 1900;
|
|
7
|
+
export const DISCORD_ACTION_PREFIX = "nr:";
|
|
8
|
+
export class DiscordBotChannelRuntime {
|
|
9
|
+
client;
|
|
10
|
+
id = "discord";
|
|
11
|
+
label = "Discord";
|
|
12
|
+
capabilities = new DiscordChannelAdapter().capabilities;
|
|
13
|
+
constructor(client) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
}
|
|
16
|
+
describe() {
|
|
17
|
+
return new DiscordChannelAdapter().describe();
|
|
18
|
+
}
|
|
19
|
+
async sendMessage(context, message) {
|
|
20
|
+
const channel = await this.resolveChannel(context, message.threadId);
|
|
21
|
+
const content = discordMessageText(message);
|
|
22
|
+
const chunks = splitDiscordMessage(content);
|
|
23
|
+
let first = null;
|
|
24
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
25
|
+
const sent = await discordRateLimiter.run(discordBucket(context), "sendMessage", () => channel.send({
|
|
26
|
+
content: chunk,
|
|
27
|
+
components: index === chunks.length - 1 ? discordActionRows(message.buttons) : [],
|
|
28
|
+
allowedMentions: { parse: [] },
|
|
29
|
+
}));
|
|
30
|
+
first ??= sent;
|
|
31
|
+
}
|
|
32
|
+
return { messageId: first?.id ?? "" };
|
|
33
|
+
}
|
|
34
|
+
async editMessage(context, messageId, message) {
|
|
35
|
+
const channel = await this.resolveChannel(context, message.threadId);
|
|
36
|
+
const existing = await channel.messages.fetch(messageId).catch(() => null);
|
|
37
|
+
if (!existing) {
|
|
38
|
+
await this.sendMessage(context, message);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await discordRateLimiter.run(discordBucket(context), "editMessage", () => existing.edit({
|
|
42
|
+
content: trimDiscordMessage(discordMessageText(message)),
|
|
43
|
+
components: discordActionRows(message.buttons),
|
|
44
|
+
allowedMentions: { parse: [] },
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
async sendTyping(context) {
|
|
48
|
+
const channel = await this.resolveChannel(context);
|
|
49
|
+
await discordRateLimiter.run(discordBucket(context), "typing", () => channel.sendTyping());
|
|
50
|
+
}
|
|
51
|
+
async sendFile(context, file) {
|
|
52
|
+
const channel = await this.resolveChannel(context, file.threadId);
|
|
53
|
+
const sent = await discordRateLimiter.run(discordBucket(context), "sendFile", () => channel.send({
|
|
54
|
+
content: file.caption ? trimDiscordMessage(redactText(file.caption)) : undefined,
|
|
55
|
+
files: [new AttachmentBuilder(file.localPath, { name: file.name })],
|
|
56
|
+
allowedMentions: { parse: [] },
|
|
57
|
+
}));
|
|
58
|
+
return { messageId: sent.id };
|
|
59
|
+
}
|
|
60
|
+
async resolveChannel(context, overrideThreadId) {
|
|
61
|
+
const id = overrideThreadId ?? context.topicId ?? context.chatId;
|
|
62
|
+
const channel = await this.client.channels.fetch(id);
|
|
63
|
+
if (!channel?.isTextBased() || !("send" in channel) || !("messages" in channel)) {
|
|
64
|
+
throw new Error(`Discord channel is not text-capable: ${id}`);
|
|
65
|
+
}
|
|
66
|
+
return channel;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function discordBucket(context) {
|
|
70
|
+
return context.topicId ?? context.chatId;
|
|
71
|
+
}
|
|
72
|
+
export function discordMessageText(message) {
|
|
73
|
+
return message.fallbackText?.trim() || stripTelegramHtml(message.text).trim() || ".";
|
|
74
|
+
}
|
|
75
|
+
export function discordActionRows(rows) {
|
|
76
|
+
if (!rows?.length) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
return rows.slice(0, 5).map((row) => new ActionRowBuilder()
|
|
80
|
+
.addComponents(row.slice(0, 5).map((button) => new ButtonBuilder()
|
|
81
|
+
.setCustomId(discordActionId(button.action))
|
|
82
|
+
.setLabel(trimButtonLabel(button.label))
|
|
83
|
+
.setStyle(ButtonStyle.Secondary)))
|
|
84
|
+
.toJSON());
|
|
85
|
+
}
|
|
86
|
+
export function discordActionId(action) {
|
|
87
|
+
const raw = `${DISCORD_ACTION_PREFIX}${action}`;
|
|
88
|
+
return raw.length <= 100 ? raw : `${DISCORD_ACTION_PREFIX}${action.slice(0, 97 - DISCORD_ACTION_PREFIX.length)}`;
|
|
89
|
+
}
|
|
90
|
+
export function actionFromDiscordCustomId(customId) {
|
|
91
|
+
return customId.startsWith(DISCORD_ACTION_PREFIX) ? customId.slice(DISCORD_ACTION_PREFIX.length) : null;
|
|
92
|
+
}
|
|
93
|
+
export function splitDiscordMessage(text) {
|
|
94
|
+
const normalized = text || ".";
|
|
95
|
+
if (normalized.length <= DISCORD_SAFE_MESSAGE_LIMIT) {
|
|
96
|
+
return [normalized];
|
|
97
|
+
}
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let remaining = normalized;
|
|
100
|
+
while (remaining.length > 0) {
|
|
101
|
+
const slice = remaining.slice(0, DISCORD_SAFE_MESSAGE_LIMIT);
|
|
102
|
+
const breakAt = Math.max(slice.lastIndexOf("\n"), slice.lastIndexOf(" "));
|
|
103
|
+
const length = breakAt > 400 ? breakAt : DISCORD_SAFE_MESSAGE_LIMIT;
|
|
104
|
+
chunks.push(remaining.slice(0, length).trimEnd() || ".");
|
|
105
|
+
remaining = remaining.slice(length).trimStart();
|
|
106
|
+
}
|
|
107
|
+
return chunks;
|
|
108
|
+
}
|
|
109
|
+
export function trimDiscordMessage(text) {
|
|
110
|
+
return text.length <= DISCORD_MESSAGE_LIMIT ? text : `${text.slice(0, DISCORD_MESSAGE_LIMIT - 1)}…`;
|
|
111
|
+
}
|
|
112
|
+
export function discordMessageOptions(message) {
|
|
113
|
+
return {
|
|
114
|
+
content: trimDiscordMessage(discordMessageText(message)),
|
|
115
|
+
components: discordActionRows(message.buttons),
|
|
116
|
+
allowedMentions: { parse: [] },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function stripTelegramHtml(text) {
|
|
120
|
+
return text
|
|
121
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
122
|
+
.replace(/<\/(p|div|li|pre)>/gi, "\n")
|
|
123
|
+
.replace(/<[^>]+>/g, "")
|
|
124
|
+
.replace(/</g, "<")
|
|
125
|
+
.replace(/>/g, ">")
|
|
126
|
+
.replace(/&/g, "&")
|
|
127
|
+
.replace(/"/g, "\"")
|
|
128
|
+
.replace(/'/g, "'");
|
|
129
|
+
}
|
|
130
|
+
function trimButtonLabel(label) {
|
|
131
|
+
const trimmed = label.trim() || "Action";
|
|
132
|
+
return trimmed.length <= 80 ? trimmed : trimmed.slice(0, 80);
|
|
133
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { permissionForCommand } from "./access-control.js";
|
|
2
|
+
import { discordCommandCatalog } from "./channel-command-catalog.js";
|
|
3
|
+
import { normalizeChannelCommandName, parseChannelCommand } from "./channel-runtime.js";
|
|
4
|
+
export function parseDiscordMessageCommand(text) {
|
|
5
|
+
return parseChannelCommand(text, { allowBotMention: false });
|
|
6
|
+
}
|
|
7
|
+
export function argumentFromDiscordInteraction(interaction) {
|
|
8
|
+
if (interaction.commandName === "prompt") {
|
|
9
|
+
return interaction.options.getString("text") ?? "";
|
|
10
|
+
}
|
|
11
|
+
if (interaction.commandName === "queue") {
|
|
12
|
+
return [interaction.options.getString("action"), interaction.options.getString("id")].filter(Boolean).join(" ");
|
|
13
|
+
}
|
|
14
|
+
if (interaction.commandName === "update") {
|
|
15
|
+
return [interaction.options.getString("target"), interaction.options.getString("agent"), interaction.options.getString("input")].filter(Boolean).join(" ");
|
|
16
|
+
}
|
|
17
|
+
return interaction.options.getString("value") ?? interaction.options.getString("query") ?? interaction.options.getString("thread_id") ?? "";
|
|
18
|
+
}
|
|
19
|
+
export function requiredPermissionForDiscordCommand(command, argument) {
|
|
20
|
+
const normalized = normalizeChannelCommandName(command);
|
|
21
|
+
if (normalized === "prompt")
|
|
22
|
+
return "prompt.send";
|
|
23
|
+
if (normalized === "queue")
|
|
24
|
+
return argument.trim() ? "queue.write" : "queue.read";
|
|
25
|
+
return permissionForCommand(normalized);
|
|
26
|
+
}
|
|
27
|
+
export function isUnauthenticatedDiscordCommandAllowed(command) {
|
|
28
|
+
return normalizeChannelCommandName(command) === "link";
|
|
29
|
+
}
|
|
30
|
+
export function permissionForDiscordAction(action) {
|
|
31
|
+
if (action.startsWith("discord_queue_") || action.startsWith("discord_peer_queue_"))
|
|
32
|
+
return "queue.write";
|
|
33
|
+
if (action.startsWith("discord_abort:"))
|
|
34
|
+
return "prompt.abort";
|
|
35
|
+
if (action.startsWith("discord_pick:"))
|
|
36
|
+
return "sessions.write";
|
|
37
|
+
if (action.startsWith("discord_artifact_delete:"))
|
|
38
|
+
return "files.write";
|
|
39
|
+
if (action.startsWith("discord_artifact_"))
|
|
40
|
+
return "files.read";
|
|
41
|
+
if (action.startsWith("agent-update:"))
|
|
42
|
+
return "updates.run";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function discordCommands() {
|
|
46
|
+
return discordCommandCatalog()
|
|
47
|
+
.map((entry) => command(entry.name, entry.description, entry.options));
|
|
48
|
+
}
|
|
49
|
+
function command(name, description, options = []) {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
description,
|
|
53
|
+
type: 1,
|
|
54
|
+
dm_permission: true,
|
|
55
|
+
options,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const DEFAULT_OPTIONS = {
|
|
2
|
+
minIntervalMs: 250,
|
|
3
|
+
editMinIntervalMs: 1_500,
|
|
4
|
+
typingMinIntervalMs: 4_500,
|
|
5
|
+
maxRetries: 5,
|
|
6
|
+
};
|
|
7
|
+
export class DiscordRateLimiter {
|
|
8
|
+
options;
|
|
9
|
+
buckets = new Map();
|
|
10
|
+
queued = 0;
|
|
11
|
+
running = 0;
|
|
12
|
+
completed = 0;
|
|
13
|
+
failed = 0;
|
|
14
|
+
retries = 0;
|
|
15
|
+
rateLimitHits = 0;
|
|
16
|
+
lastRateLimitAt;
|
|
17
|
+
lastRetryAfterSeconds;
|
|
18
|
+
constructor(options = DEFAULT_OPTIONS) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
}
|
|
21
|
+
configure(options) {
|
|
22
|
+
this.options = { ...this.options, ...options };
|
|
23
|
+
}
|
|
24
|
+
async run(bucket, method, operation) {
|
|
25
|
+
const normalizedBucket = `${method}:${bucket}`;
|
|
26
|
+
const state = this.getBucket(normalizedBucket);
|
|
27
|
+
this.queued += 1;
|
|
28
|
+
let releasePrevious;
|
|
29
|
+
const previous = state.chain;
|
|
30
|
+
state.chain = new Promise((resolve) => {
|
|
31
|
+
releasePrevious = resolve;
|
|
32
|
+
});
|
|
33
|
+
await previous.catch(() => { });
|
|
34
|
+
this.queued = Math.max(0, this.queued - 1);
|
|
35
|
+
this.running += 1;
|
|
36
|
+
try {
|
|
37
|
+
await this.waitForBucket(state, method);
|
|
38
|
+
const result = await this.runWithRetries(operation);
|
|
39
|
+
this.completed += 1;
|
|
40
|
+
state.lastRunAtMs = Date.now();
|
|
41
|
+
state.queuedUntilMs = Math.max(state.queuedUntilMs, state.lastRunAtMs + this.intervalForMethod(method));
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.failed += 1;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
this.running = Math.max(0, this.running - 1);
|
|
50
|
+
releasePrevious();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
getMetrics() {
|
|
54
|
+
return {
|
|
55
|
+
queued: this.queued,
|
|
56
|
+
running: this.running,
|
|
57
|
+
completed: this.completed,
|
|
58
|
+
failed: this.failed,
|
|
59
|
+
retries: this.retries,
|
|
60
|
+
rateLimitHits: this.rateLimitHits,
|
|
61
|
+
lastRateLimitAt: this.lastRateLimitAt,
|
|
62
|
+
lastRetryAfterSeconds: this.lastRetryAfterSeconds,
|
|
63
|
+
buckets: [...this.buckets.entries()]
|
|
64
|
+
.filter(([, state]) => state.queuedUntilMs > Date.now() || state.lastRunAtMs > 0)
|
|
65
|
+
.slice(0, 12)
|
|
66
|
+
.map(([bucket, state]) => ({
|
|
67
|
+
bucket,
|
|
68
|
+
queuedUntilMs: state.queuedUntilMs,
|
|
69
|
+
lastRunAtMs: state.lastRunAtMs,
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
getBucket(bucket) {
|
|
74
|
+
let state = this.buckets.get(bucket);
|
|
75
|
+
if (!state) {
|
|
76
|
+
state = {
|
|
77
|
+
chain: Promise.resolve(),
|
|
78
|
+
queuedUntilMs: 0,
|
|
79
|
+
lastRunAtMs: 0,
|
|
80
|
+
};
|
|
81
|
+
this.buckets.set(bucket, state);
|
|
82
|
+
}
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
async waitForBucket(state, method) {
|
|
86
|
+
const interval = this.intervalForMethod(method);
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const earliest = Math.max(state.queuedUntilMs, state.lastRunAtMs + interval);
|
|
89
|
+
if (earliest > now) {
|
|
90
|
+
await delay(earliest - now);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async runWithRetries(operation) {
|
|
94
|
+
let attempt = 0;
|
|
95
|
+
while (true) {
|
|
96
|
+
try {
|
|
97
|
+
return await operation();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const retryAfterSeconds = getRetryAfterSeconds(error);
|
|
101
|
+
if (retryAfterSeconds === undefined || attempt >= this.options.maxRetries) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
attempt += 1;
|
|
105
|
+
this.retries += 1;
|
|
106
|
+
this.rateLimitHits += 1;
|
|
107
|
+
this.lastRateLimitAt = new Date().toISOString();
|
|
108
|
+
this.lastRetryAfterSeconds = retryAfterSeconds;
|
|
109
|
+
await delay((retryAfterSeconds * 1000) + 250);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
intervalForMethod(method) {
|
|
114
|
+
if (method.startsWith("edit"))
|
|
115
|
+
return this.options.editMinIntervalMs;
|
|
116
|
+
if (method.startsWith("typing"))
|
|
117
|
+
return this.options.typingMinIntervalMs;
|
|
118
|
+
return this.options.minIntervalMs;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export const discordRateLimiter = new DiscordRateLimiter();
|
|
122
|
+
export function getDiscordRateLimitMetrics() {
|
|
123
|
+
return discordRateLimiter.getMetrics();
|
|
124
|
+
}
|
|
125
|
+
function getRetryAfterSeconds(error) {
|
|
126
|
+
const candidate = error;
|
|
127
|
+
const direct = candidate.retryAfter ?? candidate.rawError?.retry_after ?? candidate.data?.retry_after;
|
|
128
|
+
if (typeof direct === "number" && Number.isFinite(direct)) {
|
|
129
|
+
return Math.max(1, direct > 100 ? direct / 1000 : direct);
|
|
130
|
+
}
|
|
131
|
+
const text = typeof candidate.message === "string" ? candidate.message : "";
|
|
132
|
+
const match = text.match(/retry[_ -]?after["':\s]+(\d+(?:\.\d+)?)/i);
|
|
133
|
+
if (!match) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const parsed = Number(match[1]);
|
|
137
|
+
return Number.isFinite(parsed) ? Math.max(1, parsed) : undefined;
|
|
138
|
+
}
|
|
139
|
+
function delay(ms) {
|
|
140
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
141
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { webhookCallback } from "grammy";
|
|
5
5
|
import { agentLabel } from "./agent.js";
|
|
6
6
|
import { createBot, registerCommands } from "./bot.js";
|
|
7
|
+
import { createDiscordBridge } from "./discord-bot.js";
|
|
7
8
|
import { checkAuthStatus } from "./codex-auth.js";
|
|
8
9
|
import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
|
|
9
10
|
import { checkClaudeCodeAuthStatus } from "./claude-code-auth.js";
|
|
@@ -18,12 +19,17 @@ import { describeOpenClawCli, resolveOpenClawCli } from "./openclaw-cli.js";
|
|
|
18
19
|
import { installConsoleLogger } from "./logger.js";
|
|
19
20
|
import { checkPiAuthStatus } from "./pi-auth.js";
|
|
20
21
|
import { describePiCli, resolvePiCli } from "./pi-cli.js";
|
|
22
|
+
import { startPeerServer } from "./peer-server.js";
|
|
23
|
+
import { RelayRuntime } from "./relay-runtime.js";
|
|
21
24
|
import { configureRedaction } from "./redaction.js";
|
|
22
25
|
import { SessionRegistry } from "./session-registry.js";
|
|
23
26
|
import { UserStore } from "./user-management.js";
|
|
24
27
|
let registry;
|
|
25
28
|
let bot;
|
|
29
|
+
let discordBridge;
|
|
26
30
|
let webhookServer;
|
|
31
|
+
let peerServer;
|
|
32
|
+
let peerRuntime;
|
|
27
33
|
let runtimeConfig;
|
|
28
34
|
try {
|
|
29
35
|
const config = loadConfig();
|
|
@@ -31,8 +37,16 @@ try {
|
|
|
31
37
|
configureRedaction(config.telegramRedactPatterns);
|
|
32
38
|
installConsoleLogger(config.logFormat);
|
|
33
39
|
registry = new SessionRegistry(config);
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
if (config.telegramEnabled) {
|
|
41
|
+
bot = createBot(config, registry);
|
|
42
|
+
await registerCommands(bot);
|
|
43
|
+
}
|
|
44
|
+
discordBridge = createDiscordBridge(config, registry);
|
|
45
|
+
await discordBridge?.start();
|
|
46
|
+
if (config.peerEnabled) {
|
|
47
|
+
peerRuntime = new RelayRuntime(config);
|
|
48
|
+
peerServer = await startPeerServer({ config, runtime: peerRuntime });
|
|
49
|
+
}
|
|
36
50
|
console.log("NordRelay running");
|
|
37
51
|
const userStore = new UserStore();
|
|
38
52
|
if (userStore.hasAdminUser()) {
|
|
@@ -46,6 +60,9 @@ try {
|
|
|
46
60
|
if (!authStatus.authenticated) {
|
|
47
61
|
console.warn(`Warning: ${agentLabel(config.defaultAgent)} is not authenticated. ${authStatus.detail}`);
|
|
48
62
|
}
|
|
63
|
+
for (const warning of config.adapterWarnings ?? []) {
|
|
64
|
+
console.warn(`Warning: ${warning}`);
|
|
65
|
+
}
|
|
49
66
|
console.log(`Workspace: ${config.workspace}`);
|
|
50
67
|
console.log(`Enabled agents: ${enabledAgents(config).join(", ")} (default: ${config.defaultAgent})`);
|
|
51
68
|
if (config.codexModel) {
|
|
@@ -74,14 +91,16 @@ try {
|
|
|
74
91
|
console.warn("Warning: Default launch profile uses danger-full-access.");
|
|
75
92
|
}
|
|
76
93
|
}
|
|
77
|
-
console.log("Session mode: per
|
|
78
|
-
console.log(`Telegram
|
|
94
|
+
console.log("Session mode: per chat context");
|
|
95
|
+
console.log(`Telegram: ${config.telegramEnabled ? config.telegramTransport : "disabled"}`);
|
|
96
|
+
console.log(`Discord: ${config.discordEnabled ? "enabled" : "disabled"}`);
|
|
97
|
+
console.log(`Peers: ${peerServer ? peerServer.url : "disabled"}`);
|
|
79
98
|
await writeConnectorState({
|
|
80
99
|
status: "ready",
|
|
81
100
|
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
82
101
|
appPid: process.pid,
|
|
83
102
|
workspace: config.workspace,
|
|
84
|
-
sessionMode: "per
|
|
103
|
+
sessionMode: "per chat context",
|
|
85
104
|
authenticated: authStatus.authenticated,
|
|
86
105
|
authMethod: authStatus.method,
|
|
87
106
|
codexCli: describeCodexCli(codexCli),
|
|
@@ -91,6 +110,11 @@ try {
|
|
|
91
110
|
claudeCodeCli: describeClaudeCodeCli(claudeCodeCli),
|
|
92
111
|
openClawGateway: config.openClawGatewayUrl,
|
|
93
112
|
telegramTransport: config.telegramTransport,
|
|
113
|
+
discordEnabled: config.discordEnabled,
|
|
114
|
+
peerEnabled: config.peerEnabled,
|
|
115
|
+
peerUrl: peerServer?.url,
|
|
116
|
+
peerTlsFingerprint: peerServer?.tlsFingerprint,
|
|
117
|
+
adapterWarnings: config.adapterWarnings ?? [],
|
|
94
118
|
});
|
|
95
119
|
}
|
|
96
120
|
catch (error) {
|
|
@@ -137,9 +161,16 @@ const shutdown = (signal) => {
|
|
|
137
161
|
console.log(`Received ${signal}, shutting down NordRelay...`);
|
|
138
162
|
if (bot && runtimeConfig?.telegramTransport !== "webhook")
|
|
139
163
|
bot.stop();
|
|
164
|
+
void discordBridge?.stop().catch((error) => {
|
|
165
|
+
console.warn("Failed to stop Discord bridge:", error instanceof Error ? error.message : String(error));
|
|
166
|
+
});
|
|
140
167
|
webhookServer?.close();
|
|
168
|
+
void peerServer?.close().catch((error) => {
|
|
169
|
+
console.warn("Failed to stop peer server:", error instanceof Error ? error.message : String(error));
|
|
170
|
+
});
|
|
141
171
|
setTimeout(() => {
|
|
142
172
|
registry?.disposeAll();
|
|
173
|
+
peerRuntime?.dispose();
|
|
143
174
|
void writeConnectorState({
|
|
144
175
|
status: "stopped",
|
|
145
176
|
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createDocumentStore } from "./state-backend.js";
|
|
2
|
+
const DEFAULT_MAX_JOBS = 1000;
|
|
3
|
+
export class UnifiedJobStore {
|
|
4
|
+
maxJobs;
|
|
5
|
+
store;
|
|
6
|
+
constructor(workspace, backend = "json", maxJobs = DEFAULT_MAX_JOBS) {
|
|
7
|
+
this.maxJobs = maxJobs;
|
|
8
|
+
this.store = createDocumentStore({
|
|
9
|
+
workspace,
|
|
10
|
+
backend,
|
|
11
|
+
fileName: "unified-jobs.json",
|
|
12
|
+
sqliteKey: "unified-jobs",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
list(limit = 200) {
|
|
16
|
+
return this.readPayload().jobs
|
|
17
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
|
|
18
|
+
.slice(0, Math.max(1, Math.min(this.maxJobs, limit)));
|
|
19
|
+
}
|
|
20
|
+
get(id) {
|
|
21
|
+
return this.readPayload().jobs.find((job) => job.id === id) ?? null;
|
|
22
|
+
}
|
|
23
|
+
upsert(job) {
|
|
24
|
+
const payload = this.readPayload();
|
|
25
|
+
const normalized = normalizeJob(job);
|
|
26
|
+
const index = payload.jobs.findIndex((candidate) => candidate.id === normalized.id);
|
|
27
|
+
if (index >= 0) {
|
|
28
|
+
payload.jobs[index] = {
|
|
29
|
+
...payload.jobs[index],
|
|
30
|
+
...normalized,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
payload.jobs.push(normalized);
|
|
35
|
+
}
|
|
36
|
+
payload.jobs = trimJobs(payload.jobs, this.maxJobs);
|
|
37
|
+
this.store.write(payload);
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
upsertMany(jobs) {
|
|
41
|
+
if (jobs.length === 0) {
|
|
42
|
+
return this.list();
|
|
43
|
+
}
|
|
44
|
+
const payload = this.readPayload();
|
|
45
|
+
const byId = new Map(payload.jobs.map((job) => [job.id, job]));
|
|
46
|
+
for (const job of jobs) {
|
|
47
|
+
const normalized = normalizeJob(job);
|
|
48
|
+
byId.set(normalized.id, {
|
|
49
|
+
...byId.get(normalized.id),
|
|
50
|
+
...normalized,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
payload.jobs = trimJobs([...byId.values()], this.maxJobs);
|
|
54
|
+
this.store.write(payload);
|
|
55
|
+
return payload.jobs;
|
|
56
|
+
}
|
|
57
|
+
patch(id, patch) {
|
|
58
|
+
const payload = this.readPayload();
|
|
59
|
+
const index = payload.jobs.findIndex((job) => job.id === id);
|
|
60
|
+
if (index < 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
payload.jobs[index] = normalizeJob({
|
|
64
|
+
...payload.jobs[index],
|
|
65
|
+
...patch,
|
|
66
|
+
id,
|
|
67
|
+
updatedAt: patch.updatedAt ?? new Date().toISOString(),
|
|
68
|
+
});
|
|
69
|
+
this.store.write(payload);
|
|
70
|
+
return payload.jobs[index];
|
|
71
|
+
}
|
|
72
|
+
remove(id) {
|
|
73
|
+
const payload = this.readPayload();
|
|
74
|
+
const next = payload.jobs.filter((job) => job.id !== id);
|
|
75
|
+
if (next.length === payload.jobs.length) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
payload.jobs = next;
|
|
79
|
+
this.store.write(payload);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
readPayload() {
|
|
83
|
+
const payload = this.store.read();
|
|
84
|
+
if (!payload || payload.version !== 1 || !Array.isArray(payload.jobs)) {
|
|
85
|
+
return { version: 1, jobs: [] };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
jobs: trimJobs(payload.jobs.filter(isUnifiedJobDto).map(normalizeJob), this.maxJobs),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function trimJobs(jobs, maxJobs) {
|
|
94
|
+
return jobs
|
|
95
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
|
|
96
|
+
.slice(0, maxJobs);
|
|
97
|
+
}
|
|
98
|
+
function normalizeJob(job) {
|
|
99
|
+
return {
|
|
100
|
+
...job,
|
|
101
|
+
startedAt: validDateString(job.startedAt) ?? new Date().toISOString(),
|
|
102
|
+
updatedAt: validDateString(job.updatedAt) ?? new Date().toISOString(),
|
|
103
|
+
finishedAt: validDateString(job.finishedAt),
|
|
104
|
+
canCancel: Boolean(job.canCancel),
|
|
105
|
+
canRetry: Boolean(job.canRetry),
|
|
106
|
+
canReadLog: Boolean(job.canReadLog),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function validDateString(value) {
|
|
110
|
+
if (!value) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
114
|
+
}
|
|
115
|
+
function isUnifiedJobDto(value) {
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const candidate = value;
|
|
120
|
+
return typeof candidate.id === "string" &&
|
|
121
|
+
typeof candidate.kind === "string" &&
|
|
122
|
+
typeof candidate.title === "string" &&
|
|
123
|
+
typeof candidate.status === "string" &&
|
|
124
|
+
typeof candidate.source === "string" &&
|
|
125
|
+
typeof candidate.startedAt === "string" &&
|
|
126
|
+
typeof candidate.updatedAt === "string";
|
|
127
|
+
}
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
2
|
+
import { getDiscordRateLimitMetrics } from "./discord-rate-limit.js";
|
|
3
|
+
import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
|
|
4
|
+
const startedAt = Date.now();
|
|
5
|
+
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
6
|
+
eventLoopDelay.enable();
|
|
7
|
+
export function buildRuntimeMetrics(input) {
|
|
8
|
+
const completedPromptDurations = input.activity
|
|
9
|
+
.filter((event) => event.category === "prompt" && event.status === "completed" && typeof event.durationMs === "number")
|
|
10
|
+
.map((event) => event.durationMs);
|
|
11
|
+
return {
|
|
12
|
+
generatedAt: new Date().toISOString(),
|
|
13
|
+
queue: {
|
|
14
|
+
length: input.queueLength,
|
|
15
|
+
paused: input.queuePaused,
|
|
16
|
+
},
|
|
17
|
+
turns: {
|
|
18
|
+
active: input.activeTurnCount,
|
|
19
|
+
completed: countActivity(input.activity, "completed"),
|
|
20
|
+
failed: countActivity(input.activity, "failed"),
|
|
21
|
+
aborted: countActivity(input.activity, "aborted"),
|
|
22
|
+
averageDurationMs: completedPromptDurations.length
|
|
23
|
+
? Math.round(completedPromptDurations.reduce((sum, value) => sum + value, 0) / completedPromptDurations.length)
|
|
24
|
+
: null,
|
|
25
|
+
},
|
|
26
|
+
jobs: {
|
|
27
|
+
total: input.jobs.length,
|
|
28
|
+
queued: countJobs(input.jobs, "queued"),
|
|
29
|
+
running: countJobs(input.jobs, "running"),
|
|
30
|
+
completed: countJobs(input.jobs, "completed"),
|
|
31
|
+
failed: countJobs(input.jobs, "failed"),
|
|
32
|
+
aborted: countJobs(input.jobs, "aborted"),
|
|
33
|
+
},
|
|
34
|
+
process: processMetrics(),
|
|
35
|
+
adapters: {
|
|
36
|
+
telegram: getTelegramRateLimitMetrics(),
|
|
37
|
+
discord: getDiscordRateLimitMetrics(),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function processMetrics() {
|
|
42
|
+
const memory = process.memoryUsage();
|
|
43
|
+
const cpu = process.cpuUsage();
|
|
44
|
+
const uptimeMs = Math.max(0, Math.round(process.uptime() * 1000));
|
|
45
|
+
const totalMs = Math.round((cpu.user + cpu.system) / 1000);
|
|
46
|
+
return {
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
nodeVersion: process.version,
|
|
49
|
+
platform: process.platform,
|
|
50
|
+
arch: process.arch,
|
|
51
|
+
uptimeMs,
|
|
52
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
53
|
+
memory: {
|
|
54
|
+
rssBytes: memory.rss,
|
|
55
|
+
heapTotalBytes: memory.heapTotal,
|
|
56
|
+
heapUsedBytes: memory.heapUsed,
|
|
57
|
+
externalBytes: memory.external,
|
|
58
|
+
arrayBuffersBytes: memory.arrayBuffers,
|
|
59
|
+
},
|
|
60
|
+
cpu: {
|
|
61
|
+
userMs: Math.round(cpu.user / 1000),
|
|
62
|
+
systemMs: Math.round(cpu.system / 1000),
|
|
63
|
+
totalMs,
|
|
64
|
+
percentSinceStart: uptimeMs > 0 ? roundMetric((totalMs / uptimeMs) * 100) : null,
|
|
65
|
+
},
|
|
66
|
+
eventLoop: {
|
|
67
|
+
delayMeanMs: nanosecondsToMilliseconds(eventLoopDelay.mean),
|
|
68
|
+
delayMaxMs: nanosecondsToMilliseconds(eventLoopDelay.max),
|
|
69
|
+
delayP95Ms: nanosecondsToMilliseconds(eventLoopDelay.percentile(95)),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function nanosecondsToMilliseconds(value) {
|
|
74
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return roundMetric(value / 1_000_000);
|
|
78
|
+
}
|
|
79
|
+
function roundMetric(value) {
|
|
80
|
+
return Math.round(value * 100) / 100;
|
|
81
|
+
}
|
|
82
|
+
function countJobs(jobs, status) {
|
|
83
|
+
return jobs.filter((job) => job.status === status).length;
|
|
84
|
+
}
|
|
85
|
+
function countActivity(events, status) {
|
|
86
|
+
return events.filter((event) => event.category === "prompt" && event.status === status).length;
|
|
87
|
+
}
|