@nordbyte/nordrelay 0.6.0 → 0.8.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 +52 -0
- package/README.md +171 -50
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +95 -37
- package/dist/channel-adapter.js +44 -11
- package/dist/channel-command-catalog.js +94 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +230 -1
- package/dist/channel-mirror-registry.js +84 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +82 -8
- package/dist/config.js +79 -7
- package/dist/context-key.js +42 -0
- package/dist/discord-bot.js +173 -342
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +29 -0
- package/dist/metrics.js +48 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +288 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +658 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +307 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +210 -0
- package/dist/relay-runtime.js +79 -274
- package/dist/remote-prompt.js +98 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-general-commands.js +14 -0
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +16 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +26 -4
- package/dist/web-dashboard-peer-routes.js +225 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +46 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +870 -57
- package/package.json +5 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { SlackChannelAdapter, } from "./channel-adapter.js";
|
|
3
|
+
import { redactText } from "./redaction.js";
|
|
4
|
+
import { slackRateLimiter } from "./slack-rate-limit.js";
|
|
5
|
+
const SLACK_TEXT_LIMIT = 40000;
|
|
6
|
+
const SLACK_SAFE_TEXT_LIMIT = 3500;
|
|
7
|
+
export const SLACK_ACTION_PREFIX = "nr:";
|
|
8
|
+
export class SlackBotChannelRuntime {
|
|
9
|
+
client;
|
|
10
|
+
id = "slack";
|
|
11
|
+
label = "Slack";
|
|
12
|
+
capabilities = new SlackChannelAdapter().capabilities;
|
|
13
|
+
constructor(client) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
}
|
|
16
|
+
describe() {
|
|
17
|
+
return new SlackChannelAdapter().describe();
|
|
18
|
+
}
|
|
19
|
+
async sendMessage(context, message) {
|
|
20
|
+
const chunks = splitSlackMessage(slackMessageText(message));
|
|
21
|
+
let firstTs = "";
|
|
22
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
23
|
+
const result = await slackRateLimiter.run(slackBucket(context), "sendMessage", () => this.client.chat.postMessage({
|
|
24
|
+
channel: context.chatId,
|
|
25
|
+
thread_ts: message.threadId ?? context.topicId,
|
|
26
|
+
text: chunk,
|
|
27
|
+
mrkdwn: true,
|
|
28
|
+
blocks: slackBlocks({ ...message, fallbackText: chunk, text: chunk }, index === chunks.length - 1),
|
|
29
|
+
unfurl_links: false,
|
|
30
|
+
unfurl_media: false,
|
|
31
|
+
}));
|
|
32
|
+
firstTs ||= result.ts ?? "";
|
|
33
|
+
}
|
|
34
|
+
return { messageId: firstTs };
|
|
35
|
+
}
|
|
36
|
+
async editMessage(context, messageId, message) {
|
|
37
|
+
const text = trimSlackMessage(slackMessageText(message));
|
|
38
|
+
await slackRateLimiter.run(slackBucket(context), "editMessage", () => this.client.chat.update({
|
|
39
|
+
channel: context.chatId,
|
|
40
|
+
ts: messageId,
|
|
41
|
+
text,
|
|
42
|
+
blocks: slackBlocks({ ...message, fallbackText: text, text }, true),
|
|
43
|
+
unfurl_links: false,
|
|
44
|
+
unfurl_media: false,
|
|
45
|
+
})).catch(async () => {
|
|
46
|
+
await this.sendMessage(context, message);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async sendTyping(context) {
|
|
50
|
+
await slackRateLimiter.run(slackBucket(context), "typing", async () => {
|
|
51
|
+
await this.client.assistant?.threads?.setStatus?.({
|
|
52
|
+
channel_id: context.chatId,
|
|
53
|
+
thread_ts: context.topicId,
|
|
54
|
+
status: "Working...",
|
|
55
|
+
});
|
|
56
|
+
}).catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
async sendFile(context, file) {
|
|
59
|
+
const result = await slackRateLimiter.run(slackBucket(context), "sendFile", () => this.client.files.uploadV2({
|
|
60
|
+
channel_id: context.chatId,
|
|
61
|
+
thread_ts: file.threadId ?? context.topicId,
|
|
62
|
+
file: createReadStream(file.localPath),
|
|
63
|
+
filename: file.name,
|
|
64
|
+
title: file.name,
|
|
65
|
+
initial_comment: file.caption ? trimSlackMessage(redactText(file.caption)) : undefined,
|
|
66
|
+
}));
|
|
67
|
+
return { messageId: result.files?.[0]?.id ?? "" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function slackMessageText(message) {
|
|
71
|
+
const text = message.fallbackText?.trim() || stripHtml(message.text).trim() || ".";
|
|
72
|
+
return text.length <= SLACK_TEXT_LIMIT ? text : `${text.slice(0, SLACK_TEXT_LIMIT - 1)}…`;
|
|
73
|
+
}
|
|
74
|
+
export function slackActionId(action) {
|
|
75
|
+
const raw = `${SLACK_ACTION_PREFIX}${action}`;
|
|
76
|
+
return raw.length <= 255 ? raw : `${SLACK_ACTION_PREFIX}${action.slice(0, 255 - SLACK_ACTION_PREFIX.length)}`;
|
|
77
|
+
}
|
|
78
|
+
export function actionFromSlackActionId(actionId) {
|
|
79
|
+
return actionId.startsWith(SLACK_ACTION_PREFIX) ? actionId.slice(SLACK_ACTION_PREFIX.length) : null;
|
|
80
|
+
}
|
|
81
|
+
export function splitSlackMessage(text) {
|
|
82
|
+
const normalized = text || ".";
|
|
83
|
+
if (normalized.length <= SLACK_SAFE_TEXT_LIMIT) {
|
|
84
|
+
return [normalized];
|
|
85
|
+
}
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let remaining = normalized;
|
|
88
|
+
while (remaining.length > 0) {
|
|
89
|
+
const slice = remaining.slice(0, SLACK_SAFE_TEXT_LIMIT);
|
|
90
|
+
const breakAt = Math.max(slice.lastIndexOf("\n"), slice.lastIndexOf(" "));
|
|
91
|
+
const length = breakAt > 400 ? breakAt : SLACK_SAFE_TEXT_LIMIT;
|
|
92
|
+
chunks.push(remaining.slice(0, length).trimEnd() || ".");
|
|
93
|
+
remaining = remaining.slice(length).trimStart();
|
|
94
|
+
}
|
|
95
|
+
return chunks;
|
|
96
|
+
}
|
|
97
|
+
export function trimSlackMessage(text) {
|
|
98
|
+
return text.length <= SLACK_SAFE_TEXT_LIMIT ? text : `${text.slice(0, SLACK_SAFE_TEXT_LIMIT - 1)}…`;
|
|
99
|
+
}
|
|
100
|
+
export function slackBlocks(message, includeButtons = true) {
|
|
101
|
+
const blocks = [
|
|
102
|
+
{
|
|
103
|
+
type: "section",
|
|
104
|
+
text: {
|
|
105
|
+
type: "mrkdwn",
|
|
106
|
+
text: trimSlackMessage(slackMessageText(message)),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
if (includeButtons && message.buttons?.length) {
|
|
111
|
+
blocks.push(...message.buttons.slice(0, 5).map((row) => ({
|
|
112
|
+
type: "actions",
|
|
113
|
+
elements: row.slice(0, 5).map((button) => slackButton(button)),
|
|
114
|
+
})));
|
|
115
|
+
}
|
|
116
|
+
return blocks;
|
|
117
|
+
}
|
|
118
|
+
function slackButton(button) {
|
|
119
|
+
return {
|
|
120
|
+
type: "button",
|
|
121
|
+
text: {
|
|
122
|
+
type: "plain_text",
|
|
123
|
+
text: trimButtonLabel(button.label),
|
|
124
|
+
emoji: true,
|
|
125
|
+
},
|
|
126
|
+
action_id: slackActionId(button.action),
|
|
127
|
+
value: button.action.slice(0, 2000),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function slackBucket(context) {
|
|
131
|
+
return context.topicId ?? context.chatId;
|
|
132
|
+
}
|
|
133
|
+
function stripHtml(text) {
|
|
134
|
+
return text
|
|
135
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
136
|
+
.replace(/<\/(p|div|li|pre)>/gi, "\n")
|
|
137
|
+
.replace(/<[^>]+>/g, "")
|
|
138
|
+
.replace(/</g, "<")
|
|
139
|
+
.replace(/>/g, ">")
|
|
140
|
+
.replace(/&/g, "&")
|
|
141
|
+
.replace(/"/g, "\"")
|
|
142
|
+
.replace(/'/g, "'");
|
|
143
|
+
}
|
|
144
|
+
function trimButtonLabel(label) {
|
|
145
|
+
const trimmed = label.trim() || "Action";
|
|
146
|
+
return trimmed.length <= 75 ? trimmed : trimmed.slice(0, 75);
|
|
147
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { permissionForCommand } from "./access-control.js";
|
|
2
|
+
import { normalizeChannelCommandName, parseChannelCommand } from "./channel-runtime.js";
|
|
3
|
+
export function parseSlackMessageCommand(text) {
|
|
4
|
+
return parseChannelCommand(text, { allowBotMention: false });
|
|
5
|
+
}
|
|
6
|
+
export function parseSlackSlashCommand(text) {
|
|
7
|
+
const trimmed = text.trim();
|
|
8
|
+
const parsed = parseSlackMessageCommand(trimmed);
|
|
9
|
+
if (parsed) {
|
|
10
|
+
return parsed;
|
|
11
|
+
}
|
|
12
|
+
const [command = "prompt", ...rest] = trimmed.split(/\s+/);
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return { command: "help", argument: "" };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
command: normalizeChannelCommandName(command),
|
|
18
|
+
argument: rest.join(" ").trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function requiredPermissionForSlackCommand(command, argument) {
|
|
22
|
+
const normalized = normalizeChannelCommandName(command);
|
|
23
|
+
if (normalized === "prompt")
|
|
24
|
+
return "prompt.send";
|
|
25
|
+
if (normalized === "queue")
|
|
26
|
+
return argument.trim() ? "queue.write" : "queue.read";
|
|
27
|
+
return permissionForCommand(normalized);
|
|
28
|
+
}
|
|
29
|
+
export function isUnauthenticatedSlackCommandAllowed(command) {
|
|
30
|
+
return normalizeChannelCommandName(command) === "link";
|
|
31
|
+
}
|
|
32
|
+
export function permissionForSlackAction(action) {
|
|
33
|
+
if (action.startsWith("slack_queue_") || action.startsWith("slack_peer_queue_"))
|
|
34
|
+
return "queue.write";
|
|
35
|
+
if (action.startsWith("slack_abort:"))
|
|
36
|
+
return "prompt.abort";
|
|
37
|
+
if (action.startsWith("slack_pick:"))
|
|
38
|
+
return "sessions.write";
|
|
39
|
+
if (action.startsWith("slack_artifact_delete:"))
|
|
40
|
+
return "files.write";
|
|
41
|
+
if (action.startsWith("slack_artifact_"))
|
|
42
|
+
return "files.read";
|
|
43
|
+
if (action.startsWith("agent-update:"))
|
|
44
|
+
return "updates.run";
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { WebClient } from "@slack/web-api";
|
|
2
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
3
|
+
import { UserStore } from "./user-management.js";
|
|
4
|
+
export async function collectSlackDiagnostics(input) {
|
|
5
|
+
const { config } = input;
|
|
6
|
+
const checks = [];
|
|
7
|
+
const userStore = input.userStore ?? new UserStore();
|
|
8
|
+
const registeredChannels = userStore.snapshot().slackChannels;
|
|
9
|
+
const mode = config.slackSocketMode ? "socket" : "http";
|
|
10
|
+
const configured = Boolean(config.slackBotToken) &&
|
|
11
|
+
(config.slackSocketMode ? Boolean(config.slackAppToken) : Boolean(config.slackSigningSecret));
|
|
12
|
+
checks.push(check(Boolean(config.slackBotToken), "Bot token", "SLACK_BOT_TOKEN is configured.", "SLACK_BOT_TOKEN is missing."));
|
|
13
|
+
checks.push(check(config.slackSocketMode ? Boolean(config.slackAppToken) : Boolean(config.slackSigningSecret), "Transport secret", config.slackSocketMode ? "Socket Mode app token is configured." : "HTTP signing secret is configured.", config.slackSocketMode ? "SLACK_APP_TOKEN is required for Socket Mode." : "SLACK_SIGNING_SECRET is required for HTTP Events mode."));
|
|
14
|
+
checks.push({
|
|
15
|
+
status: registeredChannels.length > 0 ? "ok" : "warn",
|
|
16
|
+
label: "Registered channels",
|
|
17
|
+
detail: registeredChannels.length > 0
|
|
18
|
+
? `${registeredChannels.length} Slack channel access record(s) configured.`
|
|
19
|
+
: "No Slack channels are registered yet. Admins can use /register_channel or the WebUI Access page.",
|
|
20
|
+
});
|
|
21
|
+
checks.push({
|
|
22
|
+
status: config.slackAllowedTeamIds.length > 0 || config.slackAllowedChannelIds.length > 0 ? "ok" : "warn",
|
|
23
|
+
label: "Environment allow-list",
|
|
24
|
+
detail: config.slackAllowedTeamIds.length > 0 || config.slackAllowedChannelIds.length > 0
|
|
25
|
+
? `Team allow-list: ${config.slackAllowedTeamIds.length}; channel allow-list: ${config.slackAllowedChannelIds.length}.`
|
|
26
|
+
: "No SLACK_ALLOWED_TEAM_IDS or SLACK_ALLOWED_CHANNEL_IDS are set. User/group permissions still apply.",
|
|
27
|
+
});
|
|
28
|
+
checks.push({
|
|
29
|
+
status: "ok",
|
|
30
|
+
label: "File upload smoke",
|
|
31
|
+
detail: "Slack file upload uses files.uploadV2; real upload probes are intentionally not sent without a target channel.",
|
|
32
|
+
});
|
|
33
|
+
let auth;
|
|
34
|
+
const channelChecks = [];
|
|
35
|
+
if (config.slackEnabled && config.slackBotToken) {
|
|
36
|
+
const client = new WebClient(config.slackBotToken);
|
|
37
|
+
const timeoutMs = input.timeoutMs ?? 4_000;
|
|
38
|
+
try {
|
|
39
|
+
const authResult = await withTimeout(client.auth.test(), timeoutMs);
|
|
40
|
+
auth = {
|
|
41
|
+
ok: Boolean(authResult.ok),
|
|
42
|
+
teamId: authResult.team_id,
|
|
43
|
+
userId: authResult.user_id,
|
|
44
|
+
botId: authResult.bot_id,
|
|
45
|
+
url: authResult.url,
|
|
46
|
+
detail: authResult.ok ? "Slack auth.test succeeded." : "Slack auth.test returned ok=false.",
|
|
47
|
+
};
|
|
48
|
+
checks.push({ status: auth.ok ? "ok" : "error", label: "Slack auth.test", detail: auth.detail });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
auth = { ok: false, detail: friendlyErrorText(error) };
|
|
52
|
+
checks.push({ status: "error", label: "Slack auth.test", detail: auth.detail });
|
|
53
|
+
}
|
|
54
|
+
for (const channel of registeredChannels.slice(0, input.channelProbeLimit ?? 5)) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await withTimeout(client.conversations.info({ channel: channel.channelId }), timeoutMs);
|
|
57
|
+
channelChecks.push({
|
|
58
|
+
channelId: channel.channelId,
|
|
59
|
+
teamId: channel.teamId,
|
|
60
|
+
title: channel.title,
|
|
61
|
+
status: result.ok ? "ok" : "warn",
|
|
62
|
+
detail: result.ok ? "Slack conversations.info succeeded." : "Slack conversations.info returned ok=false.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
channelChecks.push({
|
|
67
|
+
channelId: channel.channelId,
|
|
68
|
+
teamId: channel.teamId,
|
|
69
|
+
title: channel.title,
|
|
70
|
+
status: "error",
|
|
71
|
+
detail: friendlyErrorText(error),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (config.slackEnabled) {
|
|
77
|
+
checks.push({ status: "error", label: "Slack API probes", detail: "Cannot run Slack API probes without SLACK_BOT_TOKEN." });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
checks.push({ status: "skipped", label: "Slack API probes", detail: "Slack adapter is disabled." });
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
enabled: config.slackEnabled,
|
|
84
|
+
mode,
|
|
85
|
+
configured,
|
|
86
|
+
generatedAt: new Date().toISOString(),
|
|
87
|
+
checks,
|
|
88
|
+
auth,
|
|
89
|
+
registeredChannels: registeredChannels.length,
|
|
90
|
+
channelChecks,
|
|
91
|
+
rateLimit: input.rateLimit,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function check(condition, label, okDetail, errorDetail) {
|
|
95
|
+
return {
|
|
96
|
+
status: condition ? "ok" : "error",
|
|
97
|
+
label,
|
|
98
|
+
detail: condition ? okDetail : errorDetail,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async function withTimeout(promise, timeoutMs) {
|
|
102
|
+
let timer;
|
|
103
|
+
try {
|
|
104
|
+
return await Promise.race([
|
|
105
|
+
promise,
|
|
106
|
+
new Promise((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => reject(new Error(`Slack API probe timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
}),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (timer)
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const DEFAULT_OPTIONS = {
|
|
2
|
+
minIntervalMs: 250,
|
|
3
|
+
editMinIntervalMs: 1_500,
|
|
4
|
+
typingMinIntervalMs: 4_500,
|
|
5
|
+
maxRetries: 5,
|
|
6
|
+
};
|
|
7
|
+
export class SlackRateLimiter {
|
|
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 = { chain: Promise.resolve(), queuedUntilMs: 0, lastRunAtMs: 0 };
|
|
77
|
+
this.buckets.set(bucket, state);
|
|
78
|
+
}
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
async waitForBucket(state, method) {
|
|
82
|
+
const interval = this.intervalForMethod(method);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const earliest = Math.max(state.queuedUntilMs, state.lastRunAtMs + interval);
|
|
85
|
+
if (earliest > now) {
|
|
86
|
+
await delay(earliest - now);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async runWithRetries(operation) {
|
|
90
|
+
let attempt = 0;
|
|
91
|
+
while (true) {
|
|
92
|
+
try {
|
|
93
|
+
return await operation();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const retryAfterSeconds = getRetryAfterSeconds(error);
|
|
97
|
+
if (retryAfterSeconds === undefined || attempt >= this.options.maxRetries) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
attempt += 1;
|
|
101
|
+
this.retries += 1;
|
|
102
|
+
this.rateLimitHits += 1;
|
|
103
|
+
this.lastRateLimitAt = new Date().toISOString();
|
|
104
|
+
this.lastRetryAfterSeconds = retryAfterSeconds;
|
|
105
|
+
await delay((retryAfterSeconds * 1000) + 250);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
intervalForMethod(method) {
|
|
110
|
+
if (method.startsWith("edit"))
|
|
111
|
+
return this.options.editMinIntervalMs;
|
|
112
|
+
if (method.startsWith("typing"))
|
|
113
|
+
return this.options.typingMinIntervalMs;
|
|
114
|
+
return this.options.minIntervalMs;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export const slackRateLimiter = new SlackRateLimiter();
|
|
118
|
+
export function getSlackRateLimitMetrics() {
|
|
119
|
+
return slackRateLimiter.getMetrics();
|
|
120
|
+
}
|
|
121
|
+
function getRetryAfterSeconds(error) {
|
|
122
|
+
if (!error || typeof error !== "object") {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const candidate = error;
|
|
126
|
+
if (typeof candidate.retryAfter === "number") {
|
|
127
|
+
return candidate.retryAfter;
|
|
128
|
+
}
|
|
129
|
+
if (typeof candidate.data?.retryAfter === "number") {
|
|
130
|
+
return candidate.data.retryAfter;
|
|
131
|
+
}
|
|
132
|
+
const header = candidate.headers?.["retry-after"];
|
|
133
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
134
|
+
const parsed = Number(raw);
|
|
135
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
136
|
+
}
|
|
137
|
+
function delay(ms) {
|
|
138
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
139
|
+
}
|
|
@@ -1,55 +1,5 @@
|
|
|
1
|
+
import { telegramCommandCatalog } from "./channel-command-catalog.js";
|
|
2
|
+
export const TELEGRAM_COMMANDS = telegramCommandCatalog();
|
|
1
3
|
export async function registerCommands(bot) {
|
|
2
|
-
await bot.api.setMyCommands([
|
|
3
|
-
{ command: "start", description: "Welcome & status" },
|
|
4
|
-
{ command: "help", description: "Command reference" },
|
|
5
|
-
{ command: "link", description: "Link Telegram to NordRelay user" },
|
|
6
|
-
{ command: "whoami", description: "Show your NordRelay user" },
|
|
7
|
-
{ command: "register_chat", description: "Admin: enable this group chat" },
|
|
8
|
-
{ command: "channels", description: "Messaging adapter status" },
|
|
9
|
-
{ command: "agents", description: "Agent adapter status" },
|
|
10
|
-
{ command: "agent", description: "Select agent" },
|
|
11
|
-
{ command: "new", description: "Start a new thread" },
|
|
12
|
-
{ command: "session", description: "Current thread details" },
|
|
13
|
-
{ command: "sessions", description: "Browse & switch threads" },
|
|
14
|
-
{ command: "sync", description: "Sync active session from CLI state" },
|
|
15
|
-
{ command: "pinned", description: "Show pinned threads" },
|
|
16
|
-
{ command: "pin", description: "Pin current or given thread" },
|
|
17
|
-
{ command: "unpin", description: "Unpin current or given thread" },
|
|
18
|
-
{ command: "retry", description: "Resend the last prompt" },
|
|
19
|
-
{ command: "queue", description: "Show queued prompts" },
|
|
20
|
-
{ command: "cancel", description: "Cancel a queued prompt" },
|
|
21
|
-
{ command: "clearqueue", description: "Clear queued prompts" },
|
|
22
|
-
{ command: "artifacts", description: "List or resend generated files" },
|
|
23
|
-
{ command: "workspaces", description: "List allowed workspaces" },
|
|
24
|
-
{ command: "abort", description: "Cancel current operation" },
|
|
25
|
-
{ command: "stop", description: "Cancel current operation" },
|
|
26
|
-
{ command: "launch_profiles", description: "Select launch profile" },
|
|
27
|
-
{ command: "fast", description: "Toggle fast mode" },
|
|
28
|
-
{ command: "model", description: "View & change model" },
|
|
29
|
-
{ command: "reasoning", description: "Set reasoning effort" },
|
|
30
|
-
{ command: "mirror", description: "Control CLI mirroring" },
|
|
31
|
-
{ command: "notify", description: "Control notifications" },
|
|
32
|
-
{ command: "auth", description: "Check auth status" },
|
|
33
|
-
{ command: "login", description: "Start authentication" },
|
|
34
|
-
{ command: "logout", description: "Sign out" },
|
|
35
|
-
{ command: "voice", description: "Voice transcription status" },
|
|
36
|
-
{ command: "tasks", description: "Current turn progress" },
|
|
37
|
-
{ command: "progress", description: "Current turn progress" },
|
|
38
|
-
{ command: "activity", description: "Thread activity timeline" },
|
|
39
|
-
{ command: "audit", description: "Admin: recent audit events" },
|
|
40
|
-
{ command: "status", description: "Connector runtime status" },
|
|
41
|
-
{ command: "health", description: "Connector health report" },
|
|
42
|
-
{ command: "version", description: "Connector version" },
|
|
43
|
-
{ command: "logs", description: "Admin: show connector logs" },
|
|
44
|
-
{ command: "diagnostics", description: "Admin: connector diagnostics" },
|
|
45
|
-
{ command: "support", description: "Admin: export diagnostics bundle" },
|
|
46
|
-
{ command: "lock", description: "Lock session writes to you" },
|
|
47
|
-
{ command: "unlock", description: "Release session write lock" },
|
|
48
|
-
{ command: "locks", description: "List session write locks" },
|
|
49
|
-
{ command: "restart", description: "Admin: restart connector" },
|
|
50
|
-
{ command: "update", description: "Admin: update connector or agents" },
|
|
51
|
-
{ command: "handback", description: "Hand session back to CLI" },
|
|
52
|
-
{ command: "attach", description: "Bind a session to this topic" },
|
|
53
|
-
{ command: "switch", description: "Switch to a thread by ID" },
|
|
54
|
-
]);
|
|
4
|
+
await bot.api.setMyCommands([...TELEGRAM_COMMANDS]);
|
|
55
5
|
}
|
|
@@ -37,6 +37,20 @@ export function registerTelegramGeneralCommands(options) {
|
|
|
37
37
|
options.bot.command("agents", async (ctx) => {
|
|
38
38
|
await options.replyChannelAction(ctx, options.commandService.renderAgents());
|
|
39
39
|
});
|
|
40
|
+
options.bot.command("peers", async (ctx) => {
|
|
41
|
+
await options.replyChannelAction(ctx, options.commandService.renderPeers());
|
|
42
|
+
});
|
|
43
|
+
options.bot.command("target", async (ctx) => {
|
|
44
|
+
const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
|
|
45
|
+
if (!contextSession)
|
|
46
|
+
return;
|
|
47
|
+
await options.replyChannelAction(ctx, options.commandService.renderTargetPreference({
|
|
48
|
+
source: "telegram",
|
|
49
|
+
contextKey: contextSession.contextKey,
|
|
50
|
+
argument: ctx.match?.toString() ?? "",
|
|
51
|
+
preferencesStore: options.preferencesStore,
|
|
52
|
+
}));
|
|
53
|
+
});
|
|
40
54
|
options.bot.command("restart", async (ctx) => {
|
|
41
55
|
await safeReply(ctx, escapeHTML("Restarting connector..."), {
|
|
42
56
|
fallbackText: "Restarting connector...",
|