@nordbyte/nordrelay 0.5.2 → 0.6.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.
Files changed (52) hide show
  1. package/.env.example +63 -11
  2. package/README.md +90 -19
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-rendering.js +10 -7
  7. package/dist/bot.js +458 -5
  8. package/dist/channel-actions.js +7 -2
  9. package/dist/channel-adapter.js +34 -7
  10. package/dist/channel-command-service.js +156 -0
  11. package/dist/channel-turn-service.js +237 -0
  12. package/dist/config-metadata.js +78 -13
  13. package/dist/config.js +77 -7
  14. package/dist/context-key.js +77 -5
  15. package/dist/discord-artifacts.js +165 -0
  16. package/dist/discord-bot.js +2014 -0
  17. package/dist/discord-channel-runtime.js +133 -0
  18. package/dist/discord-command-surface.js +119 -0
  19. package/dist/discord-rate-limit.js +141 -0
  20. package/dist/index.js +16 -5
  21. package/dist/job-store.js +127 -0
  22. package/dist/metrics.js +41 -0
  23. package/dist/relay-external-activity-monitor.js +47 -6
  24. package/dist/relay-runtime.js +986 -281
  25. package/dist/runtime-cache.js +57 -0
  26. package/dist/session-locks.js +10 -7
  27. package/dist/support-bundle.js +1 -0
  28. package/dist/telegram-access-commands.js +15 -2
  29. package/dist/telegram-access-middleware.js +16 -3
  30. package/dist/telegram-agent-commands.js +25 -0
  31. package/dist/telegram-artifact-commands.js +46 -0
  32. package/dist/telegram-diagnostics-command.js +5 -50
  33. package/dist/telegram-general-commands.js +2 -6
  34. package/dist/telegram-operational-commands.js +14 -6
  35. package/dist/telegram-queue-commands.js +74 -4
  36. package/dist/telegram-support-command.js +7 -0
  37. package/dist/telegram-update-commands.js +27 -0
  38. package/dist/user-management.js +208 -0
  39. package/dist/web-api-contract.js +9 -0
  40. package/dist/web-dashboard-access-routes.js +74 -1
  41. package/dist/web-dashboard-artifact-routes.js +3 -3
  42. package/dist/web-dashboard-assets.js +2 -0
  43. package/dist/web-dashboard-pages.js +97 -13
  44. package/dist/web-dashboard-runtime-routes.js +53 -8
  45. package/dist/web-dashboard-session-routes.js +27 -20
  46. package/dist/web-dashboard-ui.js +1 -0
  47. package/dist/web-dashboard.js +148 -6
  48. package/dist/web-state.js +33 -2
  49. package/dist/webui-assets/dashboard.css +75 -1
  50. package/dist/webui-assets/dashboard.js +358 -47
  51. package/package.json +3 -1
  52. package/plugins/nordrelay/scripts/nordrelay.mjs +210 -17
@@ -0,0 +1,57 @@
1
+ export class RuntimeSnapshotCache {
2
+ entries = new Map();
3
+ async get(key, ttlMs, producer) {
4
+ const now = Date.now();
5
+ const entry = this.entries.get(key);
6
+ const hasFreshValue = entry?.value !== undefined && ttlMs > 0 && now - entry.refreshedAt <= ttlMs;
7
+ if (hasFreshValue) {
8
+ return {
9
+ value: entry.value,
10
+ refreshedAt: new Date(entry.refreshedAt).toISOString(),
11
+ stale: false,
12
+ };
13
+ }
14
+ if (entry?.value !== undefined) {
15
+ if (!entry.refresh) {
16
+ entry.refresh = producer()
17
+ .then((value) => {
18
+ entry.value = value;
19
+ entry.refreshedAt = Date.now();
20
+ return value;
21
+ })
22
+ .catch(() => entry.value)
23
+ .finally(() => {
24
+ entry.refresh = undefined;
25
+ });
26
+ }
27
+ return {
28
+ value: entry.value,
29
+ refreshedAt: new Date(entry.refreshedAt).toISOString(),
30
+ stale: true,
31
+ };
32
+ }
33
+ const pending = entry?.refresh ?? producer();
34
+ this.entries.set(key, { refresh: pending, refreshedAt: now });
35
+ try {
36
+ const value = await pending;
37
+ const refreshedAt = Date.now();
38
+ this.entries.set(key, { value, refreshedAt });
39
+ return {
40
+ value,
41
+ refreshedAt: new Date(refreshedAt).toISOString(),
42
+ stale: false,
43
+ };
44
+ }
45
+ catch (error) {
46
+ this.entries.delete(key);
47
+ throw error;
48
+ }
49
+ }
50
+ invalidate(key) {
51
+ if (key) {
52
+ this.entries.delete(key);
53
+ return;
54
+ }
55
+ this.entries.clear();
56
+ }
57
+ }
@@ -22,14 +22,17 @@ export class SessionLockStore {
22
22
  }
23
23
  return lock;
24
24
  }
25
- set(contextKey, ownerId, ownerName, ttlMs) {
25
+ set(contextKey, owner, ttlMs) {
26
26
  const payload = this.readPayload();
27
+ const now = Date.now();
27
28
  const lock = {
28
29
  contextKey,
29
- ownerId,
30
- ownerName,
31
- createdAt: Date.now(),
32
- expiresAt: ttlMs > 0 ? Date.now() + ttlMs : undefined,
30
+ ownerUserId: owner.userId,
31
+ ownerLabel: owner.label,
32
+ ownerChannel: owner.channel,
33
+ ownerChannelUserId: owner.channelUserId,
34
+ createdAt: now,
35
+ expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
33
36
  };
34
37
  payload.locks[contextKey] = lock;
35
38
  this.store.write(payload);
@@ -67,7 +70,7 @@ export function canWriteWithLock(lock, userId, isAdmin) {
67
70
  if (!lock) {
68
71
  return true;
69
72
  }
70
- return isAdmin || userId === lock.ownerId;
73
+ return isAdmin || Boolean(userId && userId === lock.ownerUserId);
71
74
  }
72
75
  function isSessionLock(value) {
73
76
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -75,7 +78,7 @@ function isSessionLock(value) {
75
78
  }
76
79
  const candidate = value;
77
80
  return typeof candidate.contextKey === "string" &&
78
- Number.isInteger(candidate.ownerId) &&
81
+ typeof candidate.ownerUserId === "string" &&
79
82
  typeof candidate.createdAt === "number" &&
80
83
  (candidate.expiresAt === undefined || typeof candidate.expiresAt === "number");
81
84
  }
@@ -118,6 +118,7 @@ function relevantEnvironment() {
118
118
  const prefixes = [
119
119
  "NORDRELAY_",
120
120
  "TELEGRAM_",
121
+ "DISCORD_",
121
122
  "CODEX_",
122
123
  "PI_",
123
124
  "HERMES_",
@@ -29,6 +29,7 @@ export function registerTelegramAccessCommands(deps) {
29
29
  action: "auth_login_failed",
30
30
  status: "denied",
31
31
  contextKey: String(ctx.chat.id),
32
+ actor: telegramAuditActor(ctx),
32
33
  actorId: ctx.from.id,
33
34
  description: "Telegram link rate limited",
34
35
  detail: `${seconds}s retry-after`,
@@ -51,7 +52,8 @@ export function registerTelegramAccessCommands(deps) {
51
52
  action: "telegram_linked",
52
53
  status: "ok",
53
54
  contextKey: String(ctx.chat.id),
54
- actorId: ctx.from.id,
55
+ actor: telegramAuditActor(ctx, linked),
56
+ actorId: linked.user.id,
55
57
  actorRole: linked.groups.map((group) => group.name).join(", "),
56
58
  description: `Linked ${linked.user.email}`,
57
59
  });
@@ -65,6 +67,7 @@ export function registerTelegramAccessCommands(deps) {
65
67
  action: "auth_login_failed",
66
68
  status: "failed",
67
69
  contextKey: String(ctx.chat.id),
70
+ actor: telegramAuditActor(ctx),
68
71
  actorId: ctx.from.id,
69
72
  description: "Telegram link failed",
70
73
  detail: message,
@@ -112,7 +115,8 @@ export function registerTelegramAccessCommands(deps) {
112
115
  action: "telegram_chat_updated",
113
116
  status: "ok",
114
117
  contextKey: String(ctx.chat.id),
115
- actorId: ctx.from?.id,
118
+ actor: telegramAuditActor(ctx, authUser),
119
+ actorId: authUser.user.id,
116
120
  actorRole: getUserRole(ctx),
117
121
  description: `Registered Telegram chat ${chat.chatId}`,
118
122
  });
@@ -121,3 +125,12 @@ export function registerTelegramAccessCommands(deps) {
121
125
  });
122
126
  });
123
127
  }
128
+ function telegramAuditActor(ctx, authUser) {
129
+ return {
130
+ channel: "telegram",
131
+ id: authUser?.user.id ?? (ctx.from?.id !== undefined ? `telegram:${ctx.from.id}` : undefined),
132
+ label: authUser?.user.displayName || authUser?.user.email || ctx.from?.username || (ctx.from?.id !== undefined ? String(ctx.from.id) : undefined),
133
+ username: authUser?.user.email ?? ctx.from?.username,
134
+ channelUserId: ctx.from?.id !== undefined ? String(ctx.from.id) : undefined,
135
+ };
136
+ }
@@ -31,6 +31,7 @@ export function createTelegramAccessMiddleware(options) {
31
31
  action: "permission_denied",
32
32
  status: "denied",
33
33
  contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
34
+ actor: telegramAuditActor(ctx),
34
35
  actorId: fromId,
35
36
  description: "Telegram account is not linked",
36
37
  });
@@ -50,7 +51,8 @@ export function createTelegramAccessMiddleware(options) {
50
51
  action: "permission_denied",
51
52
  status: "denied",
52
53
  contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
53
- actorId: fromId,
54
+ actor: telegramAuditActor(ctx, authUser),
55
+ actorId: authUser.user.id,
54
56
  actorRole: getUserRole(contextUsers, ctx),
55
57
  description: "Telegram chat is not enabled or outside user scope",
56
58
  });
@@ -69,7 +71,8 @@ export function createTelegramAccessMiddleware(options) {
69
71
  action: "permission_denied",
70
72
  status: "denied",
71
73
  contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
72
- actorId: fromId,
74
+ actor: telegramAuditActor(ctx, authUser),
75
+ actorId: authUser.user.id,
73
76
  actorRole: getUserRole(contextUsers, ctx),
74
77
  description: commandName ? `Unsupported command /${commandName}` : "Unsupported callback",
75
78
  });
@@ -87,7 +90,8 @@ export function createTelegramAccessMiddleware(options) {
87
90
  action: "permission_denied",
88
91
  status: "denied",
89
92
  contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
90
- actorId: fromId,
93
+ actor: telegramAuditActor(ctx, authUser),
94
+ actorId: authUser.user.id,
91
95
  actorRole: getUserRole(contextUsers, ctx),
92
96
  description: `${permission} required`,
93
97
  });
@@ -102,6 +106,15 @@ export function createTelegramAccessMiddleware(options) {
102
106
  await next();
103
107
  };
104
108
  }
109
+ function telegramAuditActor(ctx, authUser) {
110
+ return {
111
+ channel: "telegram",
112
+ id: authUser?.user.id ?? (ctx.from?.id !== undefined ? `telegram:${ctx.from.id}` : undefined),
113
+ label: authUser?.user.displayName || authUser?.user.email || ctx.from?.username || (ctx.from?.id !== undefined ? String(ctx.from.id) : undefined),
114
+ username: authUser?.user.email ?? ctx.from?.username,
115
+ channelUserId: ctx.from?.id !== undefined ? String(ctx.from.id) : undefined,
116
+ };
117
+ }
105
118
  function getUserRole(contextUsers, ctx) {
106
119
  const authUser = contextUsers.get(ctx);
107
120
  return authUser?.groups.map((group) => group.name).join(", ") || "unauthenticated";
@@ -99,6 +99,14 @@ export function registerTelegramAgentCommands(options) {
99
99
  return;
100
100
  }
101
101
  const result = await options.startAgentLogin(info);
102
+ options.appendActivity?.(ctx, {
103
+ status: result.success ? "info" : "failed",
104
+ type: result.success ? "login_started" : "login_failed",
105
+ threadId: info?.threadId ?? null,
106
+ workspace: info?.workspace,
107
+ agentId: options.agentIdForAuth(info),
108
+ detail: redactText(result.message),
109
+ });
102
110
  if (result.success) {
103
111
  await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(redactText(result.message))}</code>`, {
104
112
  fallbackText: `🔑 Login initiated.\n\n${redactText(result.message)}`,
@@ -156,6 +164,14 @@ export function registerTelegramAgentCommands(options) {
156
164
  return;
157
165
  }
158
166
  const result = await options.startAgentLogout(info);
167
+ options.appendActivity?.(ctx, {
168
+ status: result.success ? "info" : "failed",
169
+ type: result.success ? "logout_completed" : "logout_failed",
170
+ threadId: info?.threadId ?? null,
171
+ workspace: info?.workspace,
172
+ agentId: options.agentIdForAuth(info),
173
+ detail: redactText(result.message),
174
+ });
159
175
  if (result.success) {
160
176
  await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(redactText(result.message))}`, {
161
177
  fallbackText: `🔓 Logged out.\n\n${redactText(result.message)}`,
@@ -189,6 +205,15 @@ export function registerTelegramAgentCommands(options) {
189
205
  try {
190
206
  const session = await options.registry.switchAgent(contextKey, selectedAgent);
191
207
  const info = session.getInfo();
208
+ options.appendActivity?.(ctx, {
209
+ status: "info",
210
+ type: "agent_switch",
211
+ contextKey,
212
+ threadId: info.threadId,
213
+ workspace: info.workspace,
214
+ agentId: info.agentId,
215
+ detail: labelOf(info),
216
+ });
192
217
  const html = [`<b>Agent switched to ${escapeHTML(labelOf(info))}.</b>`, "", renderSessionInfoHTML(info)].join("\n");
193
218
  const plain = [`Agent switched to ${labelOf(info)}.`, "", renderSessionInfoPlain(info)].join("\n");
194
219
  if (messageId) {
@@ -35,6 +35,16 @@ export function registerTelegramArtifactCommands(options) {
35
35
  const removed = await removeArtifactTurn(workspace, selected.turnId);
36
36
  const text = removed ? `Deleted artifact turn: ${selected.turnId}` : `Artifact turn not found: ${selected.turnId}`;
37
37
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
38
+ if (removed) {
39
+ options.appendActivity?.(ctx, {
40
+ status: "info",
41
+ type: "artifact_deleted",
42
+ threadId: contextSession.session.getInfo().threadId,
43
+ workspace,
44
+ agentId: contextSession.session.getInfo().agentId,
45
+ detail: selected.turnId,
46
+ });
47
+ }
38
48
  return;
39
49
  }
40
50
  const filtered = filterArtifactReports(reports, argument);
@@ -64,9 +74,25 @@ export function registerTelegramArtifactCommands(options) {
64
74
  return;
65
75
  }
66
76
  if (shouldZip) {
77
+ options.appendActivity?.(ctx, {
78
+ status: "info",
79
+ type: "artifact_zip_sent",
80
+ threadId: contextSession.session.getInfo().threadId,
81
+ workspace,
82
+ agentId: contextSession.session.getInfo().agentId,
83
+ detail: selected.turnId,
84
+ });
67
85
  await options.deliverArtifactReportZip(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
68
86
  }
69
87
  else {
88
+ options.appendActivity?.(ctx, {
89
+ status: "info",
90
+ type: "artifacts_sent",
91
+ threadId: contextSession.session.getInfo().threadId,
92
+ workspace,
93
+ agentId: contextSession.session.getInfo().agentId,
94
+ detail: selected.turnId,
95
+ });
70
96
  await options.deliverArtifactReport(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
71
97
  }
72
98
  return;
@@ -111,6 +137,17 @@ export function registerTelegramArtifactCommands(options) {
111
137
  if (action === "delete_confirm") {
112
138
  const removed = await removeArtifactTurn(workspace, turnId);
113
139
  await ctx.answerCallbackQuery({ text: removed ? "Deleted" : "Already gone" });
140
+ if (removed) {
141
+ const info = contextSession.session.getInfo();
142
+ options.appendActivity?.(ctx, {
143
+ status: "info",
144
+ type: "artifact_deleted",
145
+ threadId: info.threadId,
146
+ workspace,
147
+ agentId: info.agentId,
148
+ detail: turnId,
149
+ });
150
+ }
114
151
  const html = removed
115
152
  ? `<b>Deleted artifact turn:</b> <code>${escapeHTML(turnId)}</code>`
116
153
  : `<b>Artifact turn not found:</b> <code>${escapeHTML(turnId)}</code>`;
@@ -129,6 +166,15 @@ export function registerTelegramArtifactCommands(options) {
129
166
  return;
130
167
  }
131
168
  await ctx.answerCallbackQuery({ text: action === "zip" ? "Sending ZIP..." : "Sending artifacts..." });
169
+ const info = contextSession.session.getInfo();
170
+ options.appendActivity?.(ctx, {
171
+ status: "info",
172
+ type: action === "zip" ? "artifact_zip_sent" : "artifacts_sent",
173
+ threadId: info.threadId,
174
+ workspace,
175
+ agentId: info.agentId,
176
+ detail: turnId,
177
+ });
132
178
  if (action === "zip") {
133
179
  await options.deliverArtifactReportZip(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
134
180
  }
@@ -1,11 +1,10 @@
1
1
  import { getAgentDiagnostics } from "./agent-activity.js";
2
2
  import { formatQuietHours } from "./bot-preferences.js";
3
- import { logTailRequests, parseLogsCommand, renderLogTailsAction, } from "./channel-actions.js";
3
+ import { cliPathOptions } from "./channel-command-service.js";
4
4
  import { checkAuthStatus } from "./codex-auth.js";
5
5
  import { contextKeyFromCtx } from "./context-key.js";
6
- import { escapeHTML } from "./format.js";
7
- import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, } from "./operations.js";
8
- import { formatCliPathHTML, formatCliPathPlain, renderAgentDiagnostics, renderDiagnosticsHTML, renderDiagnosticsPlain, renderHealthHTML, renderHealthPlain, renderVersionCheckHTML, renderVersionCheckPlain, } from "./bot-rendering.js";
6
+ import { getConnectorHealth, } from "./operations.js";
7
+ import { renderAgentDiagnostics, renderDiagnosticsHTML, renderDiagnosticsPlain, renderHealthHTML, renderHealthPlain, } from "./bot-rendering.js";
9
8
  import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
10
9
  import { safeReply } from "./telegram-output.js";
11
10
  export function registerTelegramDiagnosticsCommands(options) {
@@ -20,38 +19,7 @@ export function registerTelegramDiagnosticsCommands(options) {
20
19
  await safeReply(ctx, html, { fallbackText: plain });
21
20
  });
22
21
  options.bot.command("version", async (ctx) => {
23
- const health = await getConnectorHealth(cliPathOptions(options.config));
24
- const state = await readConnectorState();
25
- const versions = await getVersionChecks(cliPathOptions(options.config));
26
- const plain = [
27
- renderVersionCheckPlain(versions.nordrelay),
28
- `Runtime status: ${state.status ?? "unknown"}`,
29
- formatCliPathPlain("Codex CLI", health.codexCliPath, health.codexCli),
30
- renderVersionCheckPlain(versions.codex),
31
- formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
32
- renderVersionCheckPlain(versions.pi),
33
- formatCliPathPlain("Hermes CLI", health.hermesCliPath, health.hermesCli),
34
- renderVersionCheckPlain(versions.hermes),
35
- formatCliPathPlain("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
36
- renderVersionCheckPlain(versions.openclaw),
37
- formatCliPathPlain("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
38
- renderVersionCheckPlain(versions.claudeCode),
39
- ].join("\n");
40
- const html = [
41
- renderVersionCheckHTML(versions.nordrelay),
42
- `<b>Runtime status:</b> <code>${escapeHTML(state.status ?? "unknown")}</code>`,
43
- formatCliPathHTML("Codex CLI", health.codexCliPath, health.codexCli),
44
- renderVersionCheckHTML(versions.codex),
45
- formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
46
- renderVersionCheckHTML(versions.pi),
47
- formatCliPathHTML("Hermes CLI", health.hermesCliPath, health.hermesCli),
48
- renderVersionCheckHTML(versions.hermes),
49
- formatCliPathHTML("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
50
- renderVersionCheckHTML(versions.openclaw),
51
- formatCliPathHTML("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
52
- renderVersionCheckHTML(versions.claudeCode),
53
- ].join("\n");
54
- await safeReply(ctx, html, { fallbackText: plain });
22
+ await options.replyChannelAction(ctx, await options.commandService.renderVersion());
55
23
  });
56
24
  options.bot.command("diagnostics", async (ctx) => {
57
25
  const health = await getConnectorHealth(cliPathOptions(options.config));
@@ -84,19 +52,6 @@ export function registerTelegramDiagnosticsCommands(options) {
84
52
  options.bot.command("logs", async (ctx) => {
85
53
  const rawText = ctx.message?.text ?? "";
86
54
  const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
87
- const logRequest = parseLogsCommand(argument);
88
- const logs = await Promise.all(logTailRequests(logRequest.target).map(async (request) => ({
89
- title: request.title,
90
- tail: await readFormattedLogTail(logRequest.lines, request.path),
91
- })));
92
- await options.replyChannelAction(ctx, renderLogTailsAction(logs));
55
+ await options.replyChannelAction(ctx, await options.commandService.renderLogs(argument));
93
56
  });
94
57
  }
95
- function cliPathOptions(config) {
96
- return {
97
- piCliPath: config.piCliPath,
98
- hermesCliPath: config.hermesCliPath,
99
- openClawCliPath: config.openClawCliPath,
100
- claudeCodeCliPath: config.claudeCodeCliPath,
101
- };
102
- }
@@ -1,9 +1,5 @@
1
- import { listAgentAdapterDescriptors } from "./agent-adapter.js";
2
- import { enabledAgents } from "./agent-factory.js";
3
1
  import { renderWelcomeFirstTime, renderWelcomeReturning, renderHelpMessage, } from "./bot-ui.js";
4
2
  import { authHelpText, capabilitiesOf, labelOf, } from "./bot-rendering.js";
5
- import { renderAgentsAction, renderChannelsAction, } from "./channel-actions.js";
6
- import { listChannelDescriptors } from "./channel-adapter.js";
7
3
  import { escapeHTML } from "./format.js";
8
4
  import { spawnConnectorRestart } from "./operations.js";
9
5
  import { renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
@@ -36,10 +32,10 @@ export function registerTelegramGeneralCommands(options) {
36
32
  await safeReply(ctx, help.html, { fallbackText: help.plain });
37
33
  });
38
34
  options.bot.command("channels", async (ctx) => {
39
- await options.replyChannelAction(ctx, renderChannelsAction(listChannelDescriptors()));
35
+ await options.replyChannelAction(ctx, options.commandService.renderChannels());
40
36
  });
41
37
  options.bot.command("agents", async (ctx) => {
42
- await options.replyChannelAction(ctx, renderAgentsAction(listAgentAdapterDescriptors(), enabledAgents(options.config)));
38
+ await options.replyChannelAction(ctx, options.commandService.renderAgents());
43
39
  });
44
40
  options.bot.command("restart", async (ctx) => {
45
41
  await safeReply(ctx, escapeHTML("Restarting connector..."), {
@@ -4,9 +4,10 @@ import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { InputFile } from "grammy";
6
6
  import { getAgentActivityLog } from "./agent-activity.js";
7
- import { capabilitiesOf, filterActivityEvents, formatLocalDateTime, formatLockOwner, formatTelegramName, labelOf, parseActivityOptions, renderActivityTimeline, renderAuditEvents, renderProgressHTML, renderProgressPlain, renderSessionLocks, } from "./bot-rendering.js";
7
+ import { capabilitiesOf, filterActivityEvents, formatLocalDateTime, formatLockOwner, labelOf, parseActivityOptions, renderActivityTimeline, renderAuditEvents, renderProgressHTML, renderProgressPlain, renderSessionLocks, } from "./bot-rendering.js";
8
8
  import { escapeHTML } from "./format.js";
9
9
  import { renderSessionInfoHTML, renderSessionInfoPlain } from "./session-format.js";
10
+ import { canWriteWithLock } from "./session-locks.js";
10
11
  import { chatBucket, safeReply } from "./telegram-output.js";
11
12
  import { telegramRateLimiter } from "./telegram-rate-limit.js";
12
13
  export function registerTelegramOperationalCommands(options) {
@@ -72,21 +73,27 @@ export function registerTelegramOperationalCommands(options) {
72
73
  });
73
74
  bot.command("lock", async (ctx) => {
74
75
  const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
75
- if (!contextSession || !ctx.from) {
76
+ if (!contextSession) {
76
77
  return;
77
78
  }
78
79
  const { contextKey, session } = contextSession;
80
+ const owner = options.getLockOwner(ctx);
81
+ if (!owner) {
82
+ const text = "You must be authenticated before locking a session.";
83
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
84
+ return;
85
+ }
79
86
  const existing = lockStore.get(contextKey);
80
- if (existing && existing.ownerId !== ctx.from.id && !options.isAdminUser(ctx)) {
87
+ if (existing && !canWriteWithLock(existing, owner.userId, options.isAdminUser(ctx))) {
81
88
  const text = `Session is already locked by ${formatLockOwner(existing)}.`;
82
89
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
83
90
  return;
84
91
  }
85
- const lock = lockStore.set(contextKey, ctx.from.id, formatTelegramName(ctx), config.sessionLockTtlMs);
92
+ const lock = lockStore.set(contextKey, owner, config.sessionLockTtlMs);
86
93
  options.auditContext(ctx, contextKey, session, {
87
94
  action: "lock_updated",
88
95
  status: "ok",
89
- detail: `locked by ${lock.ownerId}`,
96
+ detail: `locked by ${lock.ownerUserId}`,
90
97
  });
91
98
  const text = `Session locked by ${formatLockOwner(lock)}${lock.expiresAt ? ` until ${formatLocalDateTime(new Date(lock.expiresAt))}` : ""}.`;
92
99
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
@@ -98,7 +105,8 @@ export function registerTelegramOperationalCommands(options) {
98
105
  }
99
106
  const { contextKey, session } = contextSession;
100
107
  const lock = lockStore.get(contextKey);
101
- if (lock && lock.ownerId !== ctx.from?.id && !options.isAdminUser(ctx)) {
108
+ const owner = options.getLockOwner(ctx);
109
+ if (lock && !canWriteWithLock(lock, owner?.userId, options.isAdminUser(ctx))) {
102
110
  const text = `Only ${formatLockOwner(lock)} or an admin can unlock this session.`;
103
111
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
104
112
  return;
@@ -48,12 +48,21 @@ export function registerTelegramQueueCommands(options) {
48
48
  const minutes = Math.min(7 * 24 * 60, Math.max(1, Number(laterMatch[1])));
49
49
  const text = laterMatch[2].trim();
50
50
  const notBefore = Date.now() + minutes * 60 * 1000;
51
- const item = promptStore.enqueue(contextKey, toPromptEnvelope(text), { notBefore });
51
+ const item = promptStore.enqueue(contextKey, {
52
+ ...toPromptEnvelope(text),
53
+ activityActor: options.activityActor?.(ctx),
54
+ }, { notBefore });
52
55
  const message = `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`;
53
56
  await safeReply(ctx, escapeHTML(message), {
54
57
  fallbackText: message,
55
58
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
56
59
  });
60
+ options.appendActivity?.(ctx, contextKey, session, {
61
+ status: "queued",
62
+ type: "prompt_queued",
63
+ prompt: item.description,
64
+ detail: `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`,
65
+ });
57
66
  options.auditContext(ctx, contextKey, session, {
58
67
  action: "prompt_queued",
59
68
  status: "ok",
@@ -81,12 +90,22 @@ export function registerTelegramQueueCommands(options) {
81
90
  const message = `Queue paused. ${promptStore.list(contextKey).length} queued.`;
82
91
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
83
92
  await options.updateQueueStatusMessage(contextKey, message);
93
+ options.appendActivity?.(ctx, contextKey, session, {
94
+ status: "info",
95
+ type: "queue_pause",
96
+ detail: message,
97
+ });
84
98
  return;
85
99
  }
86
100
  if (/^resume$/i.test(argument)) {
87
101
  promptStore.resume(contextKey);
88
102
  const message = `Queue resumed. ${promptStore.list(contextKey).length} queued.`;
89
103
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
104
+ options.appendActivity?.(ctx, contextKey, session, {
105
+ status: "info",
106
+ type: "queue_resume",
107
+ detail: message,
108
+ });
90
109
  if (chatId) {
91
110
  void options.drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
92
111
  console.error("Failed to drain queue after resume:", error);
@@ -110,6 +129,12 @@ export function registerTelegramQueueCommands(options) {
110
129
  }
111
130
  const message = `Moved queued prompt ${item.id} ${direction}.`;
112
131
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
132
+ options.appendActivity?.(ctx, contextKey, session, {
133
+ status: "info",
134
+ type: "queue_move",
135
+ prompt: item.description,
136
+ detail: message,
137
+ });
113
138
  return;
114
139
  }
115
140
  const runMatch = argument.match(/^run\s+([a-z0-9]+)$/i);
@@ -123,6 +148,12 @@ export function registerTelegramQueueCommands(options) {
123
148
  }
124
149
  promptStore.enqueueFront(contextKey, item);
125
150
  promptStore.resume(contextKey);
151
+ options.appendActivity?.(ctx, contextKey, session, {
152
+ status: "info",
153
+ type: "queue_run",
154
+ prompt: item.description,
155
+ detail: `Queued prompt ${item.id} moved to next.`,
156
+ });
126
157
  if (!chatId) {
127
158
  return;
128
159
  }
@@ -159,9 +190,15 @@ export function registerTelegramQueueCommands(options) {
159
190
  if (!contextSession) {
160
191
  return;
161
192
  }
162
- const count = promptStore.clear(contextSession.contextKey);
193
+ const { contextKey, session } = contextSession;
194
+ const count = promptStore.clear(contextKey);
163
195
  const message = `Cleared ${count} queued prompt${count === 1 ? "" : "s"}.`;
164
196
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
197
+ options.appendActivity?.(ctx, contextKey, session, {
198
+ status: "info",
199
+ type: "queue_clear",
200
+ detail: message,
201
+ });
165
202
  });
166
203
  bot.command("cancel", async (ctx) => {
167
204
  const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
@@ -176,7 +213,8 @@ export function registerTelegramQueueCommands(options) {
176
213
  });
177
214
  return;
178
215
  }
179
- const removed = promptStore.remove(contextSession.contextKey, id);
216
+ const { contextKey, session } = contextSession;
217
+ const removed = promptStore.remove(contextKey, id);
180
218
  if (!removed) {
181
219
  await safeReply(ctx, escapeHTML(`No queued prompt found with id ${id}.`), {
182
220
  fallbackText: `No queued prompt found with id ${id}.`,
@@ -186,6 +224,12 @@ export function registerTelegramQueueCommands(options) {
186
224
  await safeReply(ctx, escapeHTML(`Cancelled queued prompt ${removed.id}.`), {
187
225
  fallbackText: `Cancelled queued prompt ${removed.id}.`,
188
226
  });
227
+ options.appendActivity?.(ctx, contextKey, session, {
228
+ status: "aborted",
229
+ type: "queue_cancel",
230
+ prompt: removed.description,
231
+ detail: `Cancelled queued prompt ${removed.id}.`,
232
+ });
189
233
  });
190
234
  bot.callbackQuery(/^queue_(cancel|remove|top|up|down|run):(-?\d+(?::\d+)?):([a-z0-9]+)$/, async (ctx) => {
191
235
  const action = ctx.match?.[1];
@@ -209,6 +253,15 @@ export function registerTelegramQueueCommands(options) {
209
253
  ? promptStore.moveUp(contextKey, queueId)
210
254
  : promptStore.moveDown(contextKey, queueId);
211
255
  await ctx.answerCallbackQuery({ text: item ? `Moved ${queueId} ${action}.` : "Queued prompt not found." });
256
+ const session = item ? options.getSession(contextKey) : undefined;
257
+ if (item && session) {
258
+ options.appendActivity?.(ctx, contextKey, session, {
259
+ status: "info",
260
+ type: "queue_move",
261
+ prompt: item.description,
262
+ detail: `Moved queued prompt ${item.id} ${action}.`,
263
+ });
264
+ }
212
265
  if (chatId && messageId) {
213
266
  const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
214
267
  await safeEditMessage(bot, chatId, messageId, rendered.html, {
@@ -227,6 +280,15 @@ export function registerTelegramQueueCommands(options) {
227
280
  promptStore.enqueueFront(contextKey, item);
228
281
  promptStore.resume(contextKey);
229
282
  await ctx.answerCallbackQuery({ text: `Queued prompt ${queueId} moved to next.` });
283
+ const session = options.getSession(contextKey);
284
+ if (session) {
285
+ options.appendActivity?.(ctx, contextKey, session, {
286
+ status: "info",
287
+ type: "queue_run",
288
+ prompt: item.description,
289
+ detail: `Queued prompt ${item.id} moved to next.`,
290
+ });
291
+ }
230
292
  if (chatId && messageId) {
231
293
  const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
232
294
  await safeEditMessage(bot, chatId, messageId, rendered.html, {
@@ -234,7 +296,6 @@ export function registerTelegramQueueCommands(options) {
234
296
  replyMarkup: rendered.keyboard,
235
297
  });
236
298
  }
237
- const session = options.getSession(contextKey);
238
299
  if (chatId && session && !options.getBusyReason(contextKey).busy) {
239
300
  void options.drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
240
301
  console.error("Failed to drain queue after run-now callback:", error);
@@ -262,6 +323,15 @@ export function registerTelegramQueueCommands(options) {
262
323
  }
263
324
  const message = `Cancelled queued prompt ${removed.id}.`;
264
325
  await ctx.answerCallbackQuery({ text: message });
326
+ const session = options.getSession(contextKey);
327
+ if (session) {
328
+ options.appendActivity?.(ctx, contextKey, session, {
329
+ status: "aborted",
330
+ type: "queue_cancel",
331
+ prompt: removed.description,
332
+ detail: message,
333
+ });
334
+ }
265
335
  if (!chatId || !messageId) {
266
336
  return;
267
337
  }