@nordbyte/nordrelay 0.7.0 → 0.8.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 +35 -0
- package/README.md +118 -49
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot.js +18 -31
- package/dist/channel-adapter.js +33 -6
- package/dist/channel-command-catalog.js +6 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +20 -4
- package/dist/channel-mirror-registry.js +9 -2
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/config-metadata.js +67 -8
- package/dist/config.js +48 -1
- package/dist/context-key.js +32 -0
- package/dist/discord-bot.js +99 -327
- package/dist/index.js +9 -0
- package/dist/metrics.js +2 -0
- package/dist/peer-client.js +90 -2
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +22 -0
- package/dist/peer-server.js +20 -4
- package/dist/peer-store.js +17 -2
- package/dist/relay-runtime-helpers.js +3 -1
- package/dist/relay-runtime.js +7 -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/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 +8 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +14 -4
- package/dist/web-dashboard-peer-routes.js +32 -11
- package/dist/web-dashboard.js +34 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +546 -145
- package/package.json +3 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { App } from "@slack/bolt";
|
|
3
|
+
import { ADMIN_GROUP_ID } from "./access-control.js";
|
|
4
|
+
import { agentLabel, agentReasoningLabel, agentReasoningOptions } from "./agent.js";
|
|
5
|
+
import { getAgentActivityLog, getExternalSnapshotForSession } from "./agent-activity.js";
|
|
6
|
+
import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
7
|
+
import { AgentUpdateManager } from "./agent-updates.js";
|
|
8
|
+
import { enabledAgents } from "./agent-factory.js";
|
|
9
|
+
import { collectRecentWorkspaceArtifacts, ensureOutDir, formatArtifactSummary, persistWorkspaceArtifactReport } from "./artifacts.js";
|
|
10
|
+
import { buildFileInstructions, outboxPath, stageFile } from "./attachments.js";
|
|
11
|
+
import { AuditLogStore } from "./audit-log.js";
|
|
12
|
+
import { BotPreferencesStore } from "./bot-preferences.js";
|
|
13
|
+
import { capabilitiesOf, filterActivityEvents, parseActivityOptions, renderExternalMirrorEvent, renderExternalMirrorStatus, renderPromptFailure, trimLine } from "./bot-rendering.js";
|
|
14
|
+
import { parseAgentUpdateId, renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderQueueListAction } from "./channel-actions.js";
|
|
15
|
+
import { createSharedChannelCommandDispatcher } from "./channel-command-core.js";
|
|
16
|
+
import { slackHelpCommandList } from "./channel-command-catalog.js";
|
|
17
|
+
import { ChannelCommandService } from "./channel-command-service.js";
|
|
18
|
+
import { createChannelPromptEngine } from "./channel-prompt-engine.js";
|
|
19
|
+
import { runChannelPeerPrompt } from "./channel-peer-prompt.js";
|
|
20
|
+
import { deliverChannelAction } from "./channel-runtime.js";
|
|
21
|
+
import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
|
|
22
|
+
import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
|
|
23
|
+
import { isSlackContextKey, parseSlackContextKey, slackContextKey } from "./context-key.js";
|
|
24
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
25
|
+
import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
|
|
26
|
+
import { spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
|
|
27
|
+
import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
|
|
28
|
+
import { RemoteRelayClient } from "./peer-client.js";
|
|
29
|
+
import { checkPiAuthStatus } from "./pi-auth.js";
|
|
30
|
+
import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
|
|
31
|
+
import { RelayArtifactService } from "./relay-artifact-service.js";
|
|
32
|
+
import { configureRedaction, redactText } from "./redaction.js";
|
|
33
|
+
import { renderSessionInfoPlain } from "./session-format.js";
|
|
34
|
+
import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
|
|
35
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
36
|
+
import { createSlackArtifactCommandHandler, sendRecentSlackArtifacts } from "./slack-artifacts.js";
|
|
37
|
+
import { SlackBotChannelRuntime, actionFromSlackActionId, splitSlackMessage, trimSlackMessage } from "./slack-channel-runtime.js";
|
|
38
|
+
import { isUnauthenticatedSlackCommandAllowed, parseSlackMessageCommand, parseSlackSlashCommand, permissionForSlackAction, requiredPermissionForSlackCommand } from "./slack-command-surface.js";
|
|
39
|
+
import { collectSlackDiagnostics } from "./slack-diagnostics.js";
|
|
40
|
+
import { getSlackRateLimitMetrics } from "./slack-rate-limit.js";
|
|
41
|
+
import { transcribeAudio } from "./voice.js";
|
|
42
|
+
import { UserStore } from "./user-management.js";
|
|
43
|
+
import { WebActivityStore } from "./web-state.js";
|
|
44
|
+
import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
|
|
45
|
+
export { isUnauthenticatedSlackCommandAllowed, permissionForSlackAction, requiredPermissionForSlackCommand } from "./slack-command-surface.js";
|
|
46
|
+
const EDIT_DEBOUNCE_MS = 1500;
|
|
47
|
+
const TYPING_INTERVAL_MS = 4500;
|
|
48
|
+
const MAX_CHOICES = 25;
|
|
49
|
+
const MAX_ATTACHMENT_DOWNLOAD = 25 * 1024 * 1024;
|
|
50
|
+
export function createSlackBridge(config, registry) {
|
|
51
|
+
if (!config.slackEnabled) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (!config.slackBotToken) {
|
|
55
|
+
console.warn("Slack adapter disabled: SLACK_ENABLED=true requires SLACK_BOT_TOKEN.");
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
configureRedaction(config.telegramRedactPatterns);
|
|
59
|
+
const app = new App({
|
|
60
|
+
token: config.slackBotToken,
|
|
61
|
+
appToken: config.slackSocketMode ? config.slackAppToken : undefined,
|
|
62
|
+
signingSecret: config.slackSigningSecret,
|
|
63
|
+
socketMode: config.slackSocketMode,
|
|
64
|
+
});
|
|
65
|
+
const runtime = new SlackBotChannelRuntime(app.client);
|
|
66
|
+
const promptStore = new PromptStore(config.workspace, config.stateBackend);
|
|
67
|
+
const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
|
|
68
|
+
const activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
69
|
+
const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
70
|
+
const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
|
|
71
|
+
const userStore = new UserStore();
|
|
72
|
+
const artifactService = new RelayArtifactService(config);
|
|
73
|
+
const agentUpdates = new AgentUpdateManager();
|
|
74
|
+
const commandService = new ChannelCommandService(config);
|
|
75
|
+
const busyStates = new Map();
|
|
76
|
+
const turnProgress = new Map();
|
|
77
|
+
const draining = new Set();
|
|
78
|
+
const picks = new Map();
|
|
79
|
+
const externalMirrors = new Map();
|
|
80
|
+
const queueStatusMessages = new Map();
|
|
81
|
+
const remoteClient = new RemoteRelayClient();
|
|
82
|
+
let externalMonitor;
|
|
83
|
+
const getBusyState = (contextKey) => {
|
|
84
|
+
let state = busyStates.get(contextKey);
|
|
85
|
+
if (!state) {
|
|
86
|
+
state = { processing: false, switching: false };
|
|
87
|
+
busyStates.set(contextKey, state);
|
|
88
|
+
}
|
|
89
|
+
return state;
|
|
90
|
+
};
|
|
91
|
+
const actorFor = (request) => ({
|
|
92
|
+
channel: "slack",
|
|
93
|
+
id: request.authUser?.user.id ?? `slack:${request.userId}`,
|
|
94
|
+
label: request.authUser?.user.displayName || request.authUser?.user.email || request.username || request.userId,
|
|
95
|
+
username: request.authUser?.user.email ?? request.username,
|
|
96
|
+
channelUserId: request.userId,
|
|
97
|
+
});
|
|
98
|
+
const appendActivity = (request, input) => {
|
|
99
|
+
activityStore.append({
|
|
100
|
+
source: "slack",
|
|
101
|
+
contextKey: request.contextKey,
|
|
102
|
+
actor: input.actor ?? actorFor(request),
|
|
103
|
+
workspace: input.workspace ?? config.workspace,
|
|
104
|
+
threadId: input.threadId ?? null,
|
|
105
|
+
...input,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
const audit = (request, input) => {
|
|
109
|
+
auditLog.append({
|
|
110
|
+
channelId: "slack",
|
|
111
|
+
contextKey: input.contextKey ?? request.contextKey,
|
|
112
|
+
actor: input.actor ?? actorFor(request),
|
|
113
|
+
actorId: request.authUser?.user.id ?? request.userId,
|
|
114
|
+
actorRole: request.authUser?.groups.map((group) => group.name).join(", ") ?? "unauthenticated",
|
|
115
|
+
...input,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
const hasPermission = (request, permission) => userStore.hasPermission(request.authUser, permission);
|
|
119
|
+
const reply = async (request, content, options = {}) => {
|
|
120
|
+
if (options.ephemeral && request.respond) {
|
|
121
|
+
await request.respond({
|
|
122
|
+
text: trimSlackMessage(content),
|
|
123
|
+
response_type: "ephemeral",
|
|
124
|
+
replace_original: false,
|
|
125
|
+
}).catch(() => runtime.sendMessage(request.context, { text: trimSlackMessage(content), fallbackText: trimSlackMessage(content), buttons: options.buttons }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const [index, chunk] of splitSlackMessage(content).entries()) {
|
|
129
|
+
await runtime.sendMessage(request.context, {
|
|
130
|
+
text: chunk,
|
|
131
|
+
fallbackText: chunk,
|
|
132
|
+
buttons: index === splitSlackMessage(content).length - 1 ? options.buttons : undefined,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const authenticate = async (request, permission, commandName) => {
|
|
137
|
+
if (commandName && isUnauthenticatedSlackCommandAllowed(commandName)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (!userStore.hasAdminUser()) {
|
|
141
|
+
await reply(request, "NordRelay has no admin user yet. Run `nordrelay user create-admin` on the host.", { ephemeral: true });
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const authUser = userStore.resolveSlackUser({ slackUserId: request.userId, teamId: request.teamId });
|
|
145
|
+
if (!authUser) {
|
|
146
|
+
audit(request, {
|
|
147
|
+
action: "permission_denied",
|
|
148
|
+
status: "denied",
|
|
149
|
+
description: "Slack account is not linked",
|
|
150
|
+
});
|
|
151
|
+
if (request.isDirectMessage || request.respond) {
|
|
152
|
+
await reply(request, "Unauthorized. Link this Slack account to a NordRelay user first.", { ephemeral: true });
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
request.authUser = authUser;
|
|
157
|
+
if (!isSlackTeamAllowed(request.teamId) || !isSlackChannelAllowedByEnv(request.channelId)) {
|
|
158
|
+
audit(request, {
|
|
159
|
+
action: "permission_denied",
|
|
160
|
+
status: "denied",
|
|
161
|
+
description: "Slack team or channel is outside configured allow-list",
|
|
162
|
+
});
|
|
163
|
+
await reply(request, "This Slack team or channel is not allowed for NordRelay.", { ephemeral: true });
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const channelAllowed = userStore.isSlackChannelAllowed({
|
|
167
|
+
teamId: request.teamId,
|
|
168
|
+
channelId: request.channelId,
|
|
169
|
+
isDirectMessage: request.isDirectMessage,
|
|
170
|
+
}, authUser);
|
|
171
|
+
if (!channelAllowed && commandName !== "register_channel") {
|
|
172
|
+
audit(request, {
|
|
173
|
+
action: "permission_denied",
|
|
174
|
+
status: "denied",
|
|
175
|
+
description: "Slack channel is not enabled or outside user scope",
|
|
176
|
+
});
|
|
177
|
+
if (request.isDirectMessage || request.respond) {
|
|
178
|
+
await reply(request, "This Slack channel is not enabled for NordRelay. An admin can use `/register_channel` in the channel.", { ephemeral: true });
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (!permission) {
|
|
183
|
+
audit(request, {
|
|
184
|
+
action: "permission_denied",
|
|
185
|
+
status: "denied",
|
|
186
|
+
description: commandName ? `Unsupported command /${commandName}` : "Unsupported action",
|
|
187
|
+
});
|
|
188
|
+
await reply(request, "Unsupported command or action.", { ephemeral: true });
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (!hasPermission(request, permission)) {
|
|
192
|
+
audit(request, {
|
|
193
|
+
action: "permission_denied",
|
|
194
|
+
status: "denied",
|
|
195
|
+
description: `${permission} required`,
|
|
196
|
+
});
|
|
197
|
+
await reply(request, `Access denied: ${permission} permission required.`, { ephemeral: true });
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
};
|
|
202
|
+
const getSession = async (request, options) => registry.getOrCreate(request.contextKey, options);
|
|
203
|
+
const updateSession = (request, session) => {
|
|
204
|
+
registry.updateMetadata(request.contextKey, session);
|
|
205
|
+
};
|
|
206
|
+
const artifactDeps = {
|
|
207
|
+
config,
|
|
208
|
+
runtime,
|
|
209
|
+
artifactService,
|
|
210
|
+
getSession,
|
|
211
|
+
reply,
|
|
212
|
+
appendActivity,
|
|
213
|
+
};
|
|
214
|
+
const commandArtifacts = createSlackArtifactCommandHandler(artifactDeps);
|
|
215
|
+
const getBusyReason = (contextKey) => {
|
|
216
|
+
const state = busyStates.get(contextKey);
|
|
217
|
+
const session = registry.get(contextKey);
|
|
218
|
+
if (state?.processing || state?.switching || session?.isProcessing()) {
|
|
219
|
+
return { busy: true, kind: "connector", state: state ?? getBusyState(contextKey) };
|
|
220
|
+
}
|
|
221
|
+
const snapshot = session ? getExternalSnapshotForSession(session, config, { maxEvents: 0 }) : null;
|
|
222
|
+
if (snapshot?.activity.active) {
|
|
223
|
+
return { busy: true, kind: "external", agentLabel: snapshot.agentLabel };
|
|
224
|
+
}
|
|
225
|
+
return { busy: false, kind: "idle" };
|
|
226
|
+
};
|
|
227
|
+
const updateQueueStatusMessage = async (contextKey, context, text) => {
|
|
228
|
+
const state = queueStatusMessages.get(contextKey) ?? {};
|
|
229
|
+
if (state.lastText === text && state.messageId) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!state.messageId) {
|
|
233
|
+
const sent = await runtime.sendMessage(context, { text, fallbackText: text });
|
|
234
|
+
state.messageId = sent.messageId;
|
|
235
|
+
state.lastText = text;
|
|
236
|
+
queueStatusMessages.set(contextKey, state);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await runtime.editMessage(context, state.messageId, { text, fallbackText: text });
|
|
240
|
+
state.lastText = text;
|
|
241
|
+
queueStatusMessages.set(contextKey, state);
|
|
242
|
+
};
|
|
243
|
+
const sendExternalWorkingNotice = async (context, state, snapshot) => {
|
|
244
|
+
const turnKey = snapshot.activity.turnId ?? snapshot.activity.startedAt?.toISOString() ?? "unknown";
|
|
245
|
+
if (state.workingNoticeTurnKey === turnKey) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const prompt = trimLine(snapshot.latestUserMessage ?? "", 250);
|
|
249
|
+
const text = prompt ? `*Working on* ${prompt}` : `*Working on* external ${snapshot.agentLabel} task...`;
|
|
250
|
+
await runtime.sendMessage(context, {
|
|
251
|
+
text,
|
|
252
|
+
fallbackText: prompt ? `Working on ${prompt}` : `Working on external ${snapshot.agentLabel} task...`,
|
|
253
|
+
});
|
|
254
|
+
state.workingNoticeTurnKey = turnKey;
|
|
255
|
+
};
|
|
256
|
+
const mirrorExternalSnapshot = async (contextKey, context, session, snapshot) => {
|
|
257
|
+
let state = externalMirrors.get(contextKey);
|
|
258
|
+
if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.sourcePath) {
|
|
259
|
+
state = {
|
|
260
|
+
threadId: snapshot.threadId,
|
|
261
|
+
rolloutPath: snapshot.sourcePath,
|
|
262
|
+
lastLine: snapshot.lineCount,
|
|
263
|
+
turnId: snapshot.activity.turnId,
|
|
264
|
+
startedAt: snapshot.activity.startedAt,
|
|
265
|
+
};
|
|
266
|
+
externalMirrors.set(contextKey, state);
|
|
267
|
+
}
|
|
268
|
+
const mirrorMode = preferencesStore.get(contextKey).mirrorMode ?? config.slackMirrorMode;
|
|
269
|
+
if (snapshot.activity.active) {
|
|
270
|
+
state.turnId = snapshot.activity.turnId;
|
|
271
|
+
state.startedAt = snapshot.activity.startedAt;
|
|
272
|
+
if (mirrorMode !== "off") {
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
if (!state.lastTypingAt || now - state.lastTypingAt >= TYPING_INTERVAL_MS) {
|
|
275
|
+
state.lastTypingAt = now;
|
|
276
|
+
await runtime.sendTyping(context).catch(() => { });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (mirrorMode === "final") {
|
|
280
|
+
await sendExternalWorkingNotice(context, state, snapshot);
|
|
281
|
+
state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (mirrorMode === "off") {
|
|
285
|
+
state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const status = renderExternalMirrorStatus(snapshot, promptStore.list(contextKey).length);
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
const canUpdateStatus = !state.latestStatusAt || now - state.latestStatusAt >= config.slackMirrorMinUpdateMs;
|
|
291
|
+
if (!state.statusMessageId) {
|
|
292
|
+
const sent = await runtime.sendMessage(context, { text: status.plain, fallbackText: status.plain });
|
|
293
|
+
state.statusMessageId = sent.messageId;
|
|
294
|
+
state.latestStatusAt = now;
|
|
295
|
+
}
|
|
296
|
+
else if (state.latestStatus !== status.plain && canUpdateStatus) {
|
|
297
|
+
await runtime.editMessage(context, state.statusMessageId, { text: status.plain, fallbackText: status.plain });
|
|
298
|
+
state.latestStatusAt = now;
|
|
299
|
+
}
|
|
300
|
+
state.latestStatus = status.plain;
|
|
301
|
+
if (mirrorMode === "full") {
|
|
302
|
+
const newEvents = snapshot.events
|
|
303
|
+
.filter((event) => event.lineNumber > (state.latestMirroredEventLine ?? state.lastLine))
|
|
304
|
+
.slice(-6);
|
|
305
|
+
for (const event of newEvents) {
|
|
306
|
+
const rendered = renderExternalMirrorEvent(event);
|
|
307
|
+
if (rendered) {
|
|
308
|
+
await runtime.sendMessage(context, { text: rendered.plain, fallbackText: rendered.plain });
|
|
309
|
+
state.latestMirroredEventLine = event.lineNumber;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
|
|
317
|
+
if (terminalEvent) {
|
|
318
|
+
const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
|
|
319
|
+
if (mirrorMode !== "off" && mirrorMode !== "status" && finalAgent?.text && finalAgent.lineNumber !== state.latestAgentLine) {
|
|
320
|
+
await runtime.sendMessage(context, {
|
|
321
|
+
text: `*${snapshot.agentLabel} CLI final answer:*`,
|
|
322
|
+
fallbackText: `${snapshot.agentLabel} CLI final answer:`,
|
|
323
|
+
});
|
|
324
|
+
for (const chunk of splitSlackMessage(finalAgent.text)) {
|
|
325
|
+
await runtime.sendMessage(context, { text: chunk, fallbackText: chunk });
|
|
326
|
+
}
|
|
327
|
+
state.latestAgentLine = finalAgent.lineNumber;
|
|
328
|
+
}
|
|
329
|
+
await deliverCliGeneratedArtifacts(contextKey, context, session, state.startedAt, terminalEvent.turnId);
|
|
330
|
+
}
|
|
331
|
+
state.workingNoticeTurnKey = undefined;
|
|
332
|
+
state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
|
|
333
|
+
};
|
|
334
|
+
const ensureActiveThread = async (request, session) => {
|
|
335
|
+
if (!session.hasActiveThread()) {
|
|
336
|
+
await session.newThread();
|
|
337
|
+
updateSession(request, session);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const checkAgentAuthStatus = async (info) => {
|
|
341
|
+
if (info.agentId === "pi")
|
|
342
|
+
return checkPiAuthStatus(info.model);
|
|
343
|
+
if (info.agentId === "hermes")
|
|
344
|
+
return checkHermesAuthStatus({ baseUrl: config.hermesApiBaseUrl, apiKey: config.hermesApiKey });
|
|
345
|
+
if (info.agentId === "openclaw")
|
|
346
|
+
return checkOpenClawAuthStatus({ gatewayUrl: config.openClawGatewayUrl, token: config.openClawGatewayToken, password: config.openClawGatewayPassword });
|
|
347
|
+
if (info.agentId === "claude-code")
|
|
348
|
+
return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
|
|
349
|
+
return checkAuthStatus(config.codexApiKey);
|
|
350
|
+
};
|
|
351
|
+
const checkLoginAuthStatus = async (info) => {
|
|
352
|
+
if (info.agentId === "hermes")
|
|
353
|
+
return checkHermesAuthStatus({ baseUrl: config.hermesApiBaseUrl, apiKey: config.hermesApiKey });
|
|
354
|
+
if (info.agentId === "claude-code")
|
|
355
|
+
return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
|
|
356
|
+
return checkAuthStatus(config.codexApiKey);
|
|
357
|
+
};
|
|
358
|
+
const startAgentLogin = (info) => {
|
|
359
|
+
if (info.agentId === "hermes")
|
|
360
|
+
return startHermesLogin(config.hermesCliPath);
|
|
361
|
+
if (info.agentId === "claude-code")
|
|
362
|
+
return startClaudeCodeLogin(config.claudeCodeCliPath);
|
|
363
|
+
if (info.agentId === "codex")
|
|
364
|
+
return startCodexLogin();
|
|
365
|
+
return Promise.resolve({ success: false, message: `${info.agentLabel} login is not managed by NordRelay. Run the agent login flow on the host.` });
|
|
366
|
+
};
|
|
367
|
+
const startAgentLogout = (info) => {
|
|
368
|
+
if (info.agentId === "hermes")
|
|
369
|
+
return startHermesLogout(config.hermesCliPath);
|
|
370
|
+
if (info.agentId === "claude-code")
|
|
371
|
+
return startClaudeCodeLogout(config.claudeCodeCliPath);
|
|
372
|
+
if (info.agentId === "codex")
|
|
373
|
+
return startCodexLogout();
|
|
374
|
+
return Promise.resolve({ success: false, message: `${info.agentLabel} logout is not managed by NordRelay. Run the agent logout flow on the host.` });
|
|
375
|
+
};
|
|
376
|
+
const hostLoginCommand = (info) => {
|
|
377
|
+
if (info.agentId === "hermes")
|
|
378
|
+
return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
|
|
379
|
+
if (info.agentId === "claude-code")
|
|
380
|
+
return `${config.claudeCodeCliPath ?? "claude"} auth login`;
|
|
381
|
+
if (info.agentId === "pi")
|
|
382
|
+
return `${config.piCliPath ?? "pi"} auth login`;
|
|
383
|
+
if (info.agentId === "openclaw")
|
|
384
|
+
return `${config.openClawCliPath ?? "openclaw"} login`;
|
|
385
|
+
return "codex login --device-auth";
|
|
386
|
+
};
|
|
387
|
+
const hostLogoutCommand = (info) => {
|
|
388
|
+
if (info.agentId === "hermes")
|
|
389
|
+
return `${config.hermesCliPath ?? "hermes"} logout`;
|
|
390
|
+
if (info.agentId === "claude-code")
|
|
391
|
+
return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
|
|
392
|
+
if (info.agentId === "pi")
|
|
393
|
+
return `${config.piCliPath ?? "pi"} auth logout`;
|
|
394
|
+
if (info.agentId === "openclaw")
|
|
395
|
+
return `${config.openClawCliPath ?? "openclaw"} logout`;
|
|
396
|
+
return "codex logout";
|
|
397
|
+
};
|
|
398
|
+
const denyIfLocked = async (request) => {
|
|
399
|
+
const lock = lockStore.get(request.contextKey);
|
|
400
|
+
const isAdmin = request.authUser?.groups.some((group) => group.id === ADMIN_GROUP_ID) ?? false;
|
|
401
|
+
if (canWriteWithLock(lock, request.authUser?.user.id, isAdmin)) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
await reply(request, `Session is locked by ${lock?.ownerLabel || lock?.ownerUserId || "another user"}.`);
|
|
405
|
+
return true;
|
|
406
|
+
};
|
|
407
|
+
const handleRemotePrompt = async (request, envelope) => {
|
|
408
|
+
const targetPeerId = preferencesStore.get(request.contextKey).targetPeerId ?? undefined;
|
|
409
|
+
return runChannelPeerPrompt({
|
|
410
|
+
targetPeerId,
|
|
411
|
+
contextKey: request.contextKey,
|
|
412
|
+
prompt: envelope,
|
|
413
|
+
remoteClient,
|
|
414
|
+
editMinIntervalMs: EDIT_DEBOUNCE_MS,
|
|
415
|
+
typingIntervalMs: TYPING_INTERVAL_MS,
|
|
416
|
+
sendTyping: () => runtime.sendTyping(request.context),
|
|
417
|
+
sendResponse: async (text) => {
|
|
418
|
+
const rendered = trimSlackMessage(text);
|
|
419
|
+
const sent = await runtime.sendMessage(request.context, { text: rendered, fallbackText: rendered });
|
|
420
|
+
return sent.messageId;
|
|
421
|
+
},
|
|
422
|
+
editResponse: async (messageId, text) => {
|
|
423
|
+
const rendered = trimSlackMessage(text);
|
|
424
|
+
await runtime.editMessage(request.context, messageId, { text: rendered, fallbackText: rendered });
|
|
425
|
+
},
|
|
426
|
+
sendTurnStart: (remotePrompt) => reply(request, `Remote peer working on:\n${remotePrompt}`),
|
|
427
|
+
sendToolStart: (toolName) => reply(request, `Remote tool: ${toolName}`),
|
|
428
|
+
sendQueued: async (queueId) => {
|
|
429
|
+
await reply(request, `Remote prompt queued${queueId ? `: ${queueId}` : ""}.`, queueId ? {
|
|
430
|
+
buttons: [[{ label: "Cancel queued message", action: `slack_peer_queue_cancel:${targetPeerId}:${queueId}` }]],
|
|
431
|
+
} : undefined);
|
|
432
|
+
},
|
|
433
|
+
sendCompleted: () => reply(request, "Remote turn completed."),
|
|
434
|
+
sendFailure: (message) => reply(request, `Remote peer failed: ${message}`),
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
const handlePrompt = async (request, input, artifactOutDir, options = {}) => {
|
|
438
|
+
const session = await getSession(request);
|
|
439
|
+
const envelope = toPromptEnvelope(input, artifactOutDir);
|
|
440
|
+
envelope.activityActor = actorFor(request);
|
|
441
|
+
if (!options.fromQueue && await handleRemotePrompt(request, envelope)) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (!options.fromQueue && await denyIfLocked(request)) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const busy = getBusyReason(request.contextKey);
|
|
448
|
+
if (busy.busy) {
|
|
449
|
+
const item = options.fromQueue && isQueuedPrompt(envelope) ? envelope : promptStore.enqueue(request.contextKey, envelope);
|
|
450
|
+
const position = promptStore.list(request.contextKey).findIndex((queued) => queued.id === item.id) + 1;
|
|
451
|
+
const text = busy.kind === "external"
|
|
452
|
+
? `Queued prompt ${item.id} at position ${position}. The ${busy.agentLabel} session is still active and is processing a previous task.`
|
|
453
|
+
: `Queued prompt ${item.id} at position ${position}.`;
|
|
454
|
+
await reply(request, text, {
|
|
455
|
+
buttons: [[{ label: "Cancel queued message", action: `slack_queue_cancel:${request.contextKey}:${item.id}` }]],
|
|
456
|
+
});
|
|
457
|
+
appendActivity(request, { status: "queued", type: "prompt_queued", prompt: item.description, detail: text });
|
|
458
|
+
audit(request, { action: "prompt_queued", status: "ok", promptId: item.id, description: item.description });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const busyState = getBusyState(request.contextKey);
|
|
462
|
+
busyState.processing = true;
|
|
463
|
+
const engine = createChannelPromptEngine({
|
|
464
|
+
runtime,
|
|
465
|
+
context: request.context,
|
|
466
|
+
contextKey: request.contextKey,
|
|
467
|
+
promptDescription: envelope.description,
|
|
468
|
+
abortAction: `slack_abort:${request.contextKey}`,
|
|
469
|
+
trimMessage: trimSlackMessage,
|
|
470
|
+
splitMessage: splitSlackMessage,
|
|
471
|
+
editDebounceMs: EDIT_DEBOUNCE_MS,
|
|
472
|
+
typingIntervalMs: TYPING_INTERVAL_MS,
|
|
473
|
+
toolVerbosity: config.toolVerbosity,
|
|
474
|
+
logPrefix: "Slack",
|
|
475
|
+
onToolStart: (toolName) => appendActivity(request, { status: "running", type: "tool_started", prompt: envelope.description, detail: toolName, threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, agentId: session.getInfo().agentId }),
|
|
476
|
+
onToolEnd: (isError) => appendActivity(request, { status: isError ? "failed" : "completed", type: isError ? "tool_failed" : "tool_completed", prompt: envelope.description, detail: "tool", threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, agentId: session.getInfo().agentId }),
|
|
477
|
+
});
|
|
478
|
+
const progress = engine.progress;
|
|
479
|
+
turnProgress.set(request.contextKey, progress);
|
|
480
|
+
engine.start();
|
|
481
|
+
try {
|
|
482
|
+
const info = session.getInfo();
|
|
483
|
+
if ((info.capabilities ?? capabilitiesOf(info)).auth) {
|
|
484
|
+
const auth = await checkAgentAuthStatus(info);
|
|
485
|
+
if (!auth.authenticated) {
|
|
486
|
+
throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await ensureActiveThread(request, session);
|
|
490
|
+
const currentInfo = session.getInfo();
|
|
491
|
+
const workspacePolicy = evaluateWorkspacePolicy(currentInfo.workspace, config);
|
|
492
|
+
if (!workspacePolicy.allowed) {
|
|
493
|
+
throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
|
|
494
|
+
}
|
|
495
|
+
promptStore.setLastPrompt(request.contextKey, envelope);
|
|
496
|
+
appendActivity(request, { status: "running", type: "prompt_started", prompt: envelope.description, threadId: currentInfo.threadId, workspace: currentInfo.workspace, agentId: currentInfo.agentId });
|
|
497
|
+
audit(request, { action: "prompt_started", status: "ok", agentId: currentInfo.agentId, threadId: currentInfo.threadId, workspace: currentInfo.workspace, description: envelope.description });
|
|
498
|
+
await session.prompt(envelope.input, engine.callbacks);
|
|
499
|
+
updateSession(request, session);
|
|
500
|
+
progress.status = "completed";
|
|
501
|
+
progress.completedAt = Date.now();
|
|
502
|
+
progress.updatedAt = progress.completedAt;
|
|
503
|
+
await engine.finalize();
|
|
504
|
+
await artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, engine.turnId, new Date(engine.startedAt));
|
|
505
|
+
if (config.slackAutoSendArtifacts) {
|
|
506
|
+
await sendRecentSlackArtifacts(artifactDeps, request, session, new Date(engine.startedAt), engine.turnId);
|
|
507
|
+
}
|
|
508
|
+
appendActivity(request, { status: "completed", type: "prompt_completed", prompt: envelope.description, threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, agentId: session.getInfo().agentId, durationMs: Date.now() - engine.startedAt });
|
|
509
|
+
audit(request, { action: "prompt_completed", status: "ok", agentId: session.getInfo().agentId, threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, description: envelope.description });
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
progress.status = "failed";
|
|
513
|
+
progress.completedAt = Date.now();
|
|
514
|
+
progress.updatedAt = progress.completedAt;
|
|
515
|
+
progress.error = friendlyErrorText(error);
|
|
516
|
+
const errorText = renderPromptFailure(engine.accumulatedText(), error);
|
|
517
|
+
await engine.fail(errorText);
|
|
518
|
+
appendActivity(request, { status: "failed", type: "prompt_failed", prompt: envelope.description, detail: friendlyErrorText(error), threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, agentId: session.getInfo().agentId, durationMs: Date.now() - engine.startedAt });
|
|
519
|
+
audit(request, { action: "prompt_failed", status: "failed", agentId: session.getInfo().agentId, threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, description: envelope.description, detail: friendlyErrorText(error) });
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
engine.stop();
|
|
523
|
+
busyState.processing = false;
|
|
524
|
+
await drainQueue(request).catch((error) => console.error("Failed to drain Slack queue:", error));
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const drainQueue = async (request) => {
|
|
528
|
+
if (draining.has(request.contextKey))
|
|
529
|
+
return;
|
|
530
|
+
draining.add(request.contextKey);
|
|
531
|
+
try {
|
|
532
|
+
while (true) {
|
|
533
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
534
|
+
if (session.isProcessing() || getBusyReason(request.contextKey).busy || promptStore.isPaused(request.contextKey))
|
|
535
|
+
return;
|
|
536
|
+
const next = promptStore.dequeue(request.contextKey);
|
|
537
|
+
if (!next)
|
|
538
|
+
return;
|
|
539
|
+
await reply(request, `Processing queued prompt ${next.id}: ${next.description}`);
|
|
540
|
+
await handlePrompt(request, next.input, next.artifactOutDir, { fromQueue: true });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
draining.delete(request.contextKey);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const deliverCliGeneratedArtifacts = async (contextKey, context, session, startedAt, turnId) => {
|
|
548
|
+
if (!startedAt || !turnId)
|
|
549
|
+
return;
|
|
550
|
+
const state = externalMirrors.get(contextKey);
|
|
551
|
+
if (state?.artifactsDeliveredForTurnId === turnId)
|
|
552
|
+
return;
|
|
553
|
+
const workspace = session.getInfo().workspace;
|
|
554
|
+
const report = await collectRecentWorkspaceArtifacts(workspace, {
|
|
555
|
+
since: startedAt,
|
|
556
|
+
until: new Date(),
|
|
557
|
+
maxFileSize: config.maxFileSize,
|
|
558
|
+
limit: 5,
|
|
559
|
+
ignoreDirs: config.artifactIgnoreDirs,
|
|
560
|
+
ignoreGlobs: config.artifactIgnoreGlobs,
|
|
561
|
+
});
|
|
562
|
+
if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
|
|
563
|
+
if (state)
|
|
564
|
+
state.artifactsDeliveredForTurnId = turnId;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const persisted = await persistWorkspaceArtifactReport(workspace, turnId, report).catch((error) => {
|
|
568
|
+
console.error("Failed to persist Slack CLI artifact report:", error);
|
|
569
|
+
return null;
|
|
570
|
+
});
|
|
571
|
+
const summary = formatArtifactSummary(report.artifacts, report.skippedCount, report.omittedCount);
|
|
572
|
+
if (summary) {
|
|
573
|
+
await runtime.sendMessage(context, { text: summary, fallbackText: summary });
|
|
574
|
+
}
|
|
575
|
+
if (config.slackAutoSendArtifacts) {
|
|
576
|
+
for (const artifact of (persisted?.artifacts ?? report.artifacts).slice(0, 5)) {
|
|
577
|
+
await runtime.sendFile(context, { localPath: artifact.localPath, name: artifact.name }).catch((error) => {
|
|
578
|
+
console.error(`Failed to send Slack CLI artifact ${artifact.name}:`, error);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const info = session.getInfo();
|
|
583
|
+
activityStore.append({ source: "cli", status: "info", type: config.slackAutoSendArtifacts ? "artifacts_sent" : "artifacts_detected", contextKey, threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, actor: { channel: "cli", label: `${info.agentLabel} CLI` }, detail: summary });
|
|
584
|
+
if (state)
|
|
585
|
+
state.artifactsDeliveredForTurnId = turnId;
|
|
586
|
+
};
|
|
587
|
+
const commandDispatcher = createSharedChannelCommandDispatcher({
|
|
588
|
+
transport: "slack",
|
|
589
|
+
bindings: [
|
|
590
|
+
{ names: ["start", "help"], handler: (request) => commandHelp(request) },
|
|
591
|
+
{ names: ["channels"], handler: (request) => deliverChannelAction(runtime, request.context, commandService.renderChannels()).then(() => { }) },
|
|
592
|
+
{ names: ["peers"], handler: (request) => deliverChannelAction(runtime, request.context, commandService.renderPeers()).then(() => { }) },
|
|
593
|
+
{ names: ["target"], handler: (request, argument) => deliverChannelAction(runtime, request.context, commandService.renderTargetPreference({ source: "slack", contextKey: request.contextKey, argument, preferencesStore })).then(() => { }) },
|
|
594
|
+
{ names: ["agents"], handler: (request) => deliverChannelAction(runtime, request.context, commandService.renderAgents()).then(() => { }) },
|
|
595
|
+
{ names: ["agent"], handler: (request, argument) => commandAgent(request, argument) },
|
|
596
|
+
{ names: ["auth"], handler: (request) => commandAuth(request) },
|
|
597
|
+
{ names: ["login"], handler: (request) => commandLogin(request) },
|
|
598
|
+
{ names: ["logout"], handler: (request) => commandLogout(request) },
|
|
599
|
+
{ names: ["session"], handler: (request) => commandSession(request) },
|
|
600
|
+
{ names: ["sessions"], handler: (request, argument) => commandSessions(request, argument) },
|
|
601
|
+
{ names: ["new"], handler: (request, argument) => commandNew(request, argument) },
|
|
602
|
+
{ names: ["switch", "attach"], handler: (request, argument) => commandSwitch(request, argument) },
|
|
603
|
+
{ names: ["model"], handler: (request, argument) => commandModel(request, argument) },
|
|
604
|
+
{ names: ["reasoning", "effort"], handler: (request, argument) => commandReasoning(request, argument) },
|
|
605
|
+
{ names: ["fast"], handler: (request, argument) => commandFast(request, argument) },
|
|
606
|
+
{ names: ["launch", "launch_profiles", "launch-profiles"], handler: (request, argument) => commandLaunch(request, argument) },
|
|
607
|
+
{ names: ["queue"], handler: (request, argument) => commandQueue(request, argument) },
|
|
608
|
+
{ names: ["clearqueue"], handler: (request) => { promptStore.clear(request.contextKey); return reply(request, "Queue cleared."); } },
|
|
609
|
+
{ names: ["cancel"], handler: (request, argument) => commandQueue(request, `cancel ${argument}`) },
|
|
610
|
+
{ names: ["abort", "stop"], handler: (request) => commandAbort(request) },
|
|
611
|
+
{ names: ["retry"], handler: (request) => commandRetry(request) },
|
|
612
|
+
{ names: ["sync"], handler: (request) => commandSync(request) },
|
|
613
|
+
{ names: ["tasks", "progress"], handler: (request) => commandProgress(request) },
|
|
614
|
+
{ names: ["activity"], handler: (request, argument) => commandActivity(request, argument) },
|
|
615
|
+
{ names: ["audit"], handler: (request, argument) => commandAudit(request, argument) },
|
|
616
|
+
{ names: ["artifacts"], handler: (request, argument) => commandArtifacts(request, argument) },
|
|
617
|
+
{ names: ["logs"], handler: async (request, argument) => deliverChannelAction(runtime, request.context, await commandService.renderLogs(argument)).then(() => { }) },
|
|
618
|
+
{ names: ["version", "health", "status"], handler: async (request) => deliverChannelAction(runtime, request.context, await commandService.renderVersion()).then(() => { }) },
|
|
619
|
+
{ names: ["diagnostics", "support"], handler: (request) => commandDiagnostics(request) },
|
|
620
|
+
{ names: ["restart"], handler: (request) => commandRestart(request) },
|
|
621
|
+
{ names: ["update"], handler: (request, argument) => commandUpdate(request, argument) },
|
|
622
|
+
{ names: ["lock"], handler: (request) => commandLock(request) },
|
|
623
|
+
{ names: ["unlock"], handler: (request) => { lockStore.clear(request.contextKey); return reply(request, "Session unlocked."); } },
|
|
624
|
+
{ names: ["locks"], handler: (request) => reply(request, lockStore.list().map((lock) => `${lock.contextKey}: ${lock.ownerLabel || lock.ownerUserId}`).join("\n") || "No active locks.") },
|
|
625
|
+
{ names: ["mirror"], handler: (request, argument) => commandMirror(request, argument) },
|
|
626
|
+
{ names: ["notify"], handler: (request, argument) => commandNotify(request, argument) },
|
|
627
|
+
{ names: ["voice"], handler: (request, argument) => commandVoice(request, argument) },
|
|
628
|
+
{ names: ["workspaces"], handler: (request) => commandWorkspaces(request) },
|
|
629
|
+
{ names: ["pin"], handler: (request, argument) => commandPin(request, argument) },
|
|
630
|
+
{ names: ["unpin"], handler: (request, argument) => commandUnpin(request, argument) },
|
|
631
|
+
{ names: ["pinned"], handler: (request) => commandPinned(request) },
|
|
632
|
+
{ names: ["handback"], handler: (request) => commandHandback(request) },
|
|
633
|
+
{ names: ["register_channel", "register_chat"], handler: (request) => commandRegisterChannel(request) },
|
|
634
|
+
{ names: ["link"], handler: (request, argument) => commandLink(request, argument) },
|
|
635
|
+
{ names: ["whoami"], handler: (request) => reply(request, request.authUser ? `${request.authUser.user.displayName} <${request.authUser.user.email}>\nGroups: ${request.authUser.groups.map((group) => group.name).join(", ")}` : "Not linked.") },
|
|
636
|
+
{ names: ["prompt"], handler: (request, argument) => handlePrompt(request, argument) },
|
|
637
|
+
],
|
|
638
|
+
});
|
|
639
|
+
const handleCommand = async (request, command, argument) => {
|
|
640
|
+
const normalized = command.toLowerCase();
|
|
641
|
+
const permission = requiredPermissionForSlackCommand(normalized, argument);
|
|
642
|
+
if (!await authenticate(request, permission, normalized))
|
|
643
|
+
return;
|
|
644
|
+
audit(request, { action: "command", status: "ok", description: `/${normalized} ${argument}`.trim() });
|
|
645
|
+
const result = await commandDispatcher.dispatch(request, normalized, argument);
|
|
646
|
+
if (!result.matched) {
|
|
647
|
+
await reply(request, `Unknown command: /${normalized}`);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
const commandHelp = async (request) => {
|
|
651
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
652
|
+
await reply(request, [
|
|
653
|
+
"NordRelay Slack adapter is ready.",
|
|
654
|
+
"",
|
|
655
|
+
"Send a message, mention the app, or use the configured Slash command.",
|
|
656
|
+
"",
|
|
657
|
+
`Core commands: ${slackHelpCommandList()}.`,
|
|
658
|
+
"",
|
|
659
|
+
renderSessionInfoPlain(session.getInfo()),
|
|
660
|
+
].join("\n"));
|
|
661
|
+
};
|
|
662
|
+
const commandAgent = async (request, argument) => {
|
|
663
|
+
const choices = enabledAgents(config);
|
|
664
|
+
const requested = argument.trim();
|
|
665
|
+
if (requested && choices.includes(requested)) {
|
|
666
|
+
const state = getBusyState(request.contextKey);
|
|
667
|
+
if (getBusyReason(request.contextKey).busy) {
|
|
668
|
+
await reply(request, "Cannot switch agent while this context is busy.");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
state.switching = true;
|
|
672
|
+
try {
|
|
673
|
+
const session = await registry.switchAgent(request.contextKey, requested);
|
|
674
|
+
updateSession(request, session);
|
|
675
|
+
appendActivity(request, { status: "info", type: "agent_switch", agentId: requested, detail: `Switched to ${agentLabel(requested)}.` });
|
|
676
|
+
await reply(request, `Switched agent to ${agentLabel(requested)}.\n\n${renderSessionInfoPlain(session.getInfo())}`);
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
state.switching = false;
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const pickId = createPick("agent", choices);
|
|
684
|
+
await reply(request, "Select agent:", { buttons: choices.map((id, index) => [{ label: agentLabel(id), action: `slack_pick:${pickId}:${index}` }]) });
|
|
685
|
+
};
|
|
686
|
+
const commandAuth = async (request) => {
|
|
687
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
688
|
+
const info = session.getInfo();
|
|
689
|
+
if (!capabilitiesOf(info).auth) {
|
|
690
|
+
await deliverChannelAction(runtime, request.context, commandService.renderHostAuthInstruction(info.agentLabel, hostLoginCommand(info), "login"));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const status = await checkAgentAuthStatus(info);
|
|
694
|
+
await deliverChannelAction(runtime, request.context, commandService.renderAuthStatus({ label: info.agentLabel, authenticated: status.authenticated, method: status.method, detail: status.detail }));
|
|
695
|
+
};
|
|
696
|
+
const commandLogin = async (request) => {
|
|
697
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
698
|
+
const info = session.getInfo();
|
|
699
|
+
if (!capabilitiesOf(info).login) {
|
|
700
|
+
await deliverChannelAction(runtime, request.context, commandService.renderHostAuthInstruction(info.agentLabel, hostLoginCommand(info), "login"));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const auth = await checkLoginAuthStatus(info);
|
|
704
|
+
if (info.agentId !== "hermes" && auth.authenticated) {
|
|
705
|
+
await reply(request, `${info.agentLabel} is already authenticated via ${auth.method ?? "unknown"}.`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (!config.enableTelegramLogin) {
|
|
709
|
+
await reply(request, `Remote login is disabled. Run this on the host: ${hostLoginCommand(info)}`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const result = await startAgentLogin(info);
|
|
713
|
+
appendActivity(request, { status: result.success ? "info" : "failed", type: result.success ? "login_started" : "login_failed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, detail: redactText(result.message) });
|
|
714
|
+
await deliverChannelAction(runtime, request.context, commandService.renderAuthActionResult("login", { ...result, message: redactText(result.message) }));
|
|
715
|
+
};
|
|
716
|
+
const commandLogout = async (request) => {
|
|
717
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
718
|
+
const info = session.getInfo();
|
|
719
|
+
if (!capabilitiesOf(info).logout) {
|
|
720
|
+
await deliverChannelAction(runtime, request.context, commandService.renderHostAuthInstruction(info.agentLabel, hostLogoutCommand(info), "logout"));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const auth = await checkLoginAuthStatus(info);
|
|
724
|
+
if (auth.method === "api-key") {
|
|
725
|
+
await reply(request, `Cannot logout ${info.agentLabel} while API-key authentication is active. Remove the API key from .env to use CLI auth.`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (!config.enableTelegramLogin) {
|
|
729
|
+
await reply(request, `Remote auth management is disabled. Run this on the host: ${hostLogoutCommand(info)}`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (info.agentId !== "hermes" && !auth.authenticated) {
|
|
733
|
+
await reply(request, `${info.agentLabel} is not currently authenticated.`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const result = await startAgentLogout(info);
|
|
737
|
+
appendActivity(request, { status: result.success ? "info" : "failed", type: result.success ? "logout_completed" : "logout_failed", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, detail: redactText(result.message) });
|
|
738
|
+
await deliverChannelAction(runtime, request.context, commandService.renderAuthActionResult("logout", { ...result, message: redactText(result.message) }));
|
|
739
|
+
};
|
|
740
|
+
const commandSession = async (request) => {
|
|
741
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
742
|
+
await reply(request, `Slack session:\n${renderSessionInfoPlain(session.getInfo())}`);
|
|
743
|
+
};
|
|
744
|
+
const commandSessions = async (request, query) => {
|
|
745
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
746
|
+
const records = session.listAllSessions(50).filter((record) => !query.trim() || [record.id, record.title, record.cwd, record.firstUserMessage].some((value) => value?.toLowerCase().includes(query.toLowerCase()))).slice(0, 10);
|
|
747
|
+
if (records.length === 0) {
|
|
748
|
+
await reply(request, "No sessions found.");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const pickId = createPick("session", records.map((record) => record.id));
|
|
752
|
+
await reply(request, ["Sessions:", ...records.map((record, index) => `${index + 1}. ${record.title || record.id}\n ${record.id}\n ${record.cwd || "-"}`)].join("\n"), {
|
|
753
|
+
buttons: records.map((record, index) => [{ label: trimLine(record.title || record.id, 70), action: `slack_pick:${pickId}:${index}` }]),
|
|
754
|
+
});
|
|
755
|
+
};
|
|
756
|
+
const commandNew = async (request, workspace) => {
|
|
757
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
758
|
+
if (getBusyReason(request.contextKey).busy) {
|
|
759
|
+
await reply(request, "Cannot create a new thread while this context is busy.");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const workspaceValue = workspace.trim() || undefined;
|
|
763
|
+
if (workspaceValue && !filterAllowedWorkspaces(session.listWorkspaces(), config).includes(workspaceValue)) {
|
|
764
|
+
await reply(request, "Workspace is not allowed.");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const info = await session.newThread(workspaceValue);
|
|
768
|
+
updateSession(request, session);
|
|
769
|
+
appendActivity(request, { status: "info", type: "session_new", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, detail: info.workspace });
|
|
770
|
+
await reply(request, `New thread created.\n\n${renderSessionInfoPlain(info)}`);
|
|
771
|
+
};
|
|
772
|
+
const commandSwitch = async (request, threadId) => {
|
|
773
|
+
if (!threadId.trim()) {
|
|
774
|
+
await reply(request, "Usage: `/switch <thread-id>`");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
778
|
+
const info = await session.switchSession(threadId.trim());
|
|
779
|
+
updateSession(request, session);
|
|
780
|
+
appendActivity(request, { status: "info", type: "session_switch", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId });
|
|
781
|
+
await reply(request, `Switched session.\n\n${renderSessionInfoPlain(info)}`);
|
|
782
|
+
};
|
|
783
|
+
const commandModel = async (request, argument) => {
|
|
784
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
785
|
+
const info = session.getInfo();
|
|
786
|
+
if (!capabilitiesOf(info).modelSelection) {
|
|
787
|
+
await reply(request, `Model selection is not supported for ${info.agentLabel}.`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (argument.trim()) {
|
|
791
|
+
await session.setModelForCurrentSession(argument.trim());
|
|
792
|
+
updateSession(request, session);
|
|
793
|
+
await reply(request, `Model set to ${argument.trim()}.\n\n${renderSessionInfoPlain(session.getInfo())}`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
await session.refreshModels({ force: true }).catch(() => { });
|
|
797
|
+
const models = session.listModels().map((model) => model.slug).slice(0, MAX_CHOICES);
|
|
798
|
+
const pickId = createPick("model", models);
|
|
799
|
+
await reply(request, "Select model:", { buttons: models.map((model, index) => [{ label: trimLine(model, 75), action: `slack_pick:${pickId}:${index}` }]) });
|
|
800
|
+
};
|
|
801
|
+
const commandReasoning = async (request, argument) => {
|
|
802
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
803
|
+
const options = agentReasoningOptions(session.getInfo().agentId);
|
|
804
|
+
if (!options.length) {
|
|
805
|
+
await reply(request, `${agentReasoningLabel(session.getInfo().agentId)} is not supported for ${session.getInfo().agentLabel}.`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const requested = argument.trim();
|
|
809
|
+
if (requested) {
|
|
810
|
+
if (!options.includes(requested)) {
|
|
811
|
+
await reply(request, `Invalid ${agentReasoningLabel(session.getInfo().agentId)}. Options: ${options.join(", ")}`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
await session.setReasoningEffortForCurrentSession(requested);
|
|
815
|
+
updateSession(request, session);
|
|
816
|
+
await reply(request, `${agentReasoningLabel(session.getInfo().agentId)} set to ${requested}.`);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const pickId = createPick("reasoning", options);
|
|
820
|
+
await reply(request, `Select ${agentReasoningLabel(session.getInfo().agentId)}:`, { buttons: options.map((value, index) => [{ label: value, action: `slack_pick:${pickId}:${index}` }]) });
|
|
821
|
+
};
|
|
822
|
+
const commandFast = async (request, argument) => {
|
|
823
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
824
|
+
if (!capabilitiesOf(session.getInfo()).fastMode) {
|
|
825
|
+
await reply(request, `Fast mode is not supported for ${session.getInfo().agentLabel}.`);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const normalized = argument.trim().toLowerCase();
|
|
829
|
+
const enabled = normalized ? ["on", "true", "yes", "1"].includes(normalized) : !session.getInfo().fastMode;
|
|
830
|
+
session.setFastMode(enabled);
|
|
831
|
+
updateSession(request, session);
|
|
832
|
+
await reply(request, `Fast mode ${enabled ? "on" : "off"}.`);
|
|
833
|
+
};
|
|
834
|
+
const commandLaunch = async (request, argument) => {
|
|
835
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
836
|
+
if (!capabilitiesOf(session.getInfo()).launchProfiles) {
|
|
837
|
+
await reply(request, `Launch profiles are not supported for ${session.getInfo().agentLabel}.`);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const parts = argument.trim().split(/\s+/).filter(Boolean);
|
|
841
|
+
const requested = parts[0] ?? "";
|
|
842
|
+
const confirmed = parts.slice(1).some((part) => part.toLowerCase() === "confirm");
|
|
843
|
+
if (requested) {
|
|
844
|
+
const profile = session.listLaunchProfiles().find((candidate) => candidate.id === requested);
|
|
845
|
+
if (!profile) {
|
|
846
|
+
await reply(request, `Unknown launch profile: ${requested}`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (profile.unsafe && !confirmed) {
|
|
850
|
+
await reply(request, [`Confirm launch profile: ${profile.label}`, `Behavior: ${profile.behavior}`, "", "WARNING: This profile uses danger-full-access.", `Run \`/launch ${profile.id} confirm\` to enable it for new or reattached threads in this Slack context.`].join("\n"));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
session.setLaunchProfile(profile.id);
|
|
854
|
+
updateSession(request, session);
|
|
855
|
+
await reply(request, `Launch profile set to ${profile.label}.\nBehavior: ${profile.behavior}`);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const profiles = session.listLaunchProfiles();
|
|
859
|
+
const pickId = createPick("launch", profiles.map((profile) => profile.id));
|
|
860
|
+
await reply(request, "Select launch profile:", { buttons: profiles.map((profile, index) => [{ label: trimLine(profile.label || profile.id, 75), action: `slack_pick:${pickId}:${index}` }]) });
|
|
861
|
+
};
|
|
862
|
+
const commandQueue = async (request, argument) => {
|
|
863
|
+
const [action, id] = argument.trim().split(/\s+/, 2);
|
|
864
|
+
if (!action) {
|
|
865
|
+
const queue = promptStore.list(request.contextKey);
|
|
866
|
+
if (queue.length === 0) {
|
|
867
|
+
await reply(request, promptStore.isPaused(request.contextKey) ? "Queue is paused and empty." : "Queue is empty.");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
await deliverChannelAction(runtime, request.context, { ...renderQueueListAction(queue, promptStore.isPaused(request.contextKey)), buttons: queue.slice(0, 5).map((item) => [
|
|
871
|
+
{ label: `Run ${item.id}`, action: `slack_queue_run:${request.contextKey}:${item.id}` },
|
|
872
|
+
{ label: "Top", action: `slack_queue_top:${request.contextKey}:${item.id}` },
|
|
873
|
+
{ label: "Up", action: `slack_queue_up:${request.contextKey}:${item.id}` },
|
|
874
|
+
{ label: "Down", action: `slack_queue_down:${request.contextKey}:${item.id}` },
|
|
875
|
+
{ label: `Cancel ${item.id}`, action: `slack_queue_cancel:${request.contextKey}:${item.id}` },
|
|
876
|
+
]) });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (action === "pause")
|
|
880
|
+
promptStore.pause(request.contextKey);
|
|
881
|
+
else if (action === "resume") {
|
|
882
|
+
promptStore.resume(request.contextKey);
|
|
883
|
+
await drainQueue(request);
|
|
884
|
+
}
|
|
885
|
+
else if (action === "clear")
|
|
886
|
+
promptStore.clear(request.contextKey);
|
|
887
|
+
else if (action === "cancel" && id)
|
|
888
|
+
promptStore.remove(request.contextKey, id);
|
|
889
|
+
else if (action === "top" && id)
|
|
890
|
+
promptStore.moveToTop(request.contextKey, id);
|
|
891
|
+
else if (action === "up" && id)
|
|
892
|
+
promptStore.moveUp(request.contextKey, id);
|
|
893
|
+
else if (action === "down" && id)
|
|
894
|
+
promptStore.moveDown(request.contextKey, id);
|
|
895
|
+
else if (action === "run" && id) {
|
|
896
|
+
const item = promptStore.remove(request.contextKey, id);
|
|
897
|
+
if (item) {
|
|
898
|
+
await handlePrompt(request, item.input, item.artifactOutDir);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
await reply(request, "Usage: `/queue [pause|resume|clear|run <id>|cancel <id>|top <id>|up <id>|down <id>]`");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
await reply(request, "Queue updated.");
|
|
907
|
+
};
|
|
908
|
+
const commandAbort = async (request) => {
|
|
909
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
910
|
+
const external = getExternalSnapshotForSession(session, config, { maxEvents: 0 });
|
|
911
|
+
if (external?.activity.active && !session.isProcessing()) {
|
|
912
|
+
await reply(request, `Cannot abort the external ${external.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
await session.abort();
|
|
916
|
+
appendActivity(request, { status: "aborted", type: "prompt_aborted", threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, agentId: session.getInfo().agentId });
|
|
917
|
+
await reply(request, "Aborted current operation.");
|
|
918
|
+
};
|
|
919
|
+
const commandRetry = async (request) => {
|
|
920
|
+
const cached = promptStore.getLastPrompt(request.contextKey);
|
|
921
|
+
if (!cached) {
|
|
922
|
+
await reply(request, "Nothing to retry. Send a message first.");
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
await handlePrompt(request, cached.input, cached.artifactOutDir);
|
|
926
|
+
};
|
|
927
|
+
const commandSync = async (request) => {
|
|
928
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
929
|
+
if (!capabilitiesOf(session.getInfo()).externalActivity) {
|
|
930
|
+
await reply(request, `${session.getInfo().agentLabel} has no external state watcher.`);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const result = session.syncFromAgentState({ reattach: true });
|
|
934
|
+
if (result.changed)
|
|
935
|
+
updateSession(request, session);
|
|
936
|
+
await reply(request, `Sync complete: ${result.changedFields.join(", ") || "already current"}.`);
|
|
937
|
+
};
|
|
938
|
+
const commandProgress = async (request) => {
|
|
939
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
940
|
+
const external = getExternalSnapshotForSession(session, config, { maxEvents: 0 });
|
|
941
|
+
const state = getBusyState(request.contextKey);
|
|
942
|
+
await deliverChannelAction(runtime, request.context, commandService.renderProgress(turnProgress.get(request.contextKey), promptStore.list(request.contextKey).length, {
|
|
943
|
+
processing: state.processing || session.isProcessing(),
|
|
944
|
+
switching: state.switching,
|
|
945
|
+
transcribing: false,
|
|
946
|
+
approving: false,
|
|
947
|
+
external: Boolean(external?.activity.active),
|
|
948
|
+
}, session.getInfo()));
|
|
949
|
+
};
|
|
950
|
+
const commandActivity = async (request, argument) => {
|
|
951
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
952
|
+
const info = session.getInfo();
|
|
953
|
+
if (!capabilitiesOf(info).activityLog) {
|
|
954
|
+
await reply(request, `${info.agentLabel} activity timelines are not available yet.`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const threadId = session.getActiveThreadId();
|
|
958
|
+
if (!threadId) {
|
|
959
|
+
await reply(request, "No active thread yet.");
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const options = parseActivityOptions(argument);
|
|
963
|
+
const events = filterActivityEvents(getAgentActivityLog(session, config, options.exportFile ? 200 : options.limit), options);
|
|
964
|
+
await deliverChannelAction(runtime, request.context, commandService.renderActivity(threadId, events, options));
|
|
965
|
+
};
|
|
966
|
+
const commandAudit = async (request, argument) => {
|
|
967
|
+
const limit = Math.max(1, Math.min(100, Number.parseInt(argument, 10) || 20));
|
|
968
|
+
await deliverChannelAction(runtime, request.context, commandService.renderAudit(auditLog.list(limit)));
|
|
969
|
+
};
|
|
970
|
+
const commandDiagnostics = async (request) => {
|
|
971
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
972
|
+
const external = getExternalSnapshotForSession(session, config, { maxEvents: 3 });
|
|
973
|
+
const rateLimit = getSlackRateLimitMetrics();
|
|
974
|
+
const slackDiagnostics = await collectSlackDiagnostics({
|
|
975
|
+
config,
|
|
976
|
+
userStore,
|
|
977
|
+
timeoutMs: 2_500,
|
|
978
|
+
rateLimit,
|
|
979
|
+
});
|
|
980
|
+
await reply(request, [
|
|
981
|
+
"Diagnostics:",
|
|
982
|
+
`Context: ${request.contextKey}`,
|
|
983
|
+
`Channel: ${request.teamId || "team"} / ${request.channelId}`,
|
|
984
|
+
`Agent: ${session.getInfo().agentLabel}`,
|
|
985
|
+
`Thread: ${session.getInfo().threadId || "-"}`,
|
|
986
|
+
`Workspace: ${session.getInfo().workspace}`,
|
|
987
|
+
`Queue: ${promptStore.list(request.contextKey).length}${promptStore.isPaused(request.contextKey) ? " paused" : ""}`,
|
|
988
|
+
`External: ${external?.activity.active ? "active" : "idle"}`,
|
|
989
|
+
`Slack rate limit: queued ${rateLimit.queued}, running ${rateLimit.running}, retries ${rateLimit.retries}`,
|
|
990
|
+
"",
|
|
991
|
+
"Slack readiness:",
|
|
992
|
+
...slackDiagnostics.checks.map((check) => `${check.status.toUpperCase()} ${check.label}: ${check.detail}`),
|
|
993
|
+
...slackDiagnostics.channelChecks.map((channel) => `${channel.status.toUpperCase()} channel ${channel.channelId}: ${channel.detail}`),
|
|
994
|
+
].join("\n"));
|
|
995
|
+
};
|
|
996
|
+
const commandUpdate = async (request, argument) => {
|
|
997
|
+
const tokens = argument.trim().split(/\s+/).filter(Boolean);
|
|
998
|
+
const [target, second] = tokens;
|
|
999
|
+
if (!target) {
|
|
1000
|
+
const update = spawnSelfUpdate();
|
|
1001
|
+
await reply(request, `NordRelay update started with ${update.method}. Log: ${update.logPath}`);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (target === "agents" || target === "agent") {
|
|
1005
|
+
await deliverChannelAction(runtime, request.context, renderAgentUpdatePickerAction(listAgentAdapterDescriptors()));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (target === "jobs") {
|
|
1009
|
+
await deliverChannelAction(runtime, request.context, renderAgentUpdateJobsAction(agentUpdates.list()));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (target === "log" && second) {
|
|
1013
|
+
await deliverChannelAction(runtime, request.context, renderAgentUpdateLogAction(agentUpdates.readLog(second)));
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (target === "cancel" && second) {
|
|
1017
|
+
await deliverChannelAction(runtime, request.context, renderAgentUpdateJobAction(agentUpdates.cancel(second)));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (target === "input" && second) {
|
|
1021
|
+
const input = tokens.slice(2).join(" ");
|
|
1022
|
+
if (!input.trim()) {
|
|
1023
|
+
await reply(request, "Usage: `/update input <job-id> <text>`");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
await deliverChannelAction(runtime, request.context, renderAgentUpdateJobAction(agentUpdates.sendInput(second, input)));
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const operation = target === "install" ? "install" : "update";
|
|
1030
|
+
const agentId = parseAgentUpdateId(operation === "install" ? second : target);
|
|
1031
|
+
if (!agentId) {
|
|
1032
|
+
await reply(request, "Unknown agent.");
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const job = agentUpdates.start(agentId, {
|
|
1036
|
+
piCliPath: config.piCliPath,
|
|
1037
|
+
hermesCliPath: config.hermesCliPath,
|
|
1038
|
+
openClawCliPath: config.openClawCliPath,
|
|
1039
|
+
claudeCodeCliPath: config.claudeCodeCliPath,
|
|
1040
|
+
}, operation);
|
|
1041
|
+
await deliverChannelAction(runtime, request.context, renderAgentUpdateJobAction(job));
|
|
1042
|
+
};
|
|
1043
|
+
const commandLock = async (request) => {
|
|
1044
|
+
const owner = actorFor(request);
|
|
1045
|
+
lockStore.set(request.contextKey, { userId: request.authUser?.user.id ?? request.userId, label: owner.label, channel: "slack", channelUserId: request.userId }, config.sessionLockTtlMs);
|
|
1046
|
+
await reply(request, `Session locked to ${owner.label}.`);
|
|
1047
|
+
};
|
|
1048
|
+
const commandRestart = async (request) => {
|
|
1049
|
+
spawnConnectorRestart();
|
|
1050
|
+
appendActivity(request, {
|
|
1051
|
+
status: "info",
|
|
1052
|
+
type: "connector_restart_requested",
|
|
1053
|
+
workspace: config.workspace,
|
|
1054
|
+
detail: "Slack restart command",
|
|
1055
|
+
});
|
|
1056
|
+
await reply(request, "Restarting connector. Slack may disconnect briefly.");
|
|
1057
|
+
};
|
|
1058
|
+
const commandWorkspaces = async (request) => {
|
|
1059
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
1060
|
+
await deliverChannelAction(runtime, request.context, commandService.renderWorkspaces(session.getInfo(), filterAllowedWorkspaces(session.listWorkspaces(), config)));
|
|
1061
|
+
};
|
|
1062
|
+
const commandPin = async (request, argument) => {
|
|
1063
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
1064
|
+
const threadId = argument.trim() || session.getActiveThreadId();
|
|
1065
|
+
if (!threadId) {
|
|
1066
|
+
await reply(request, "No active thread to pin.");
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const pinned = registry.pinThread(request.contextKey, threadId);
|
|
1070
|
+
await reply(request, `Pinned thread ${threadId}.\nPinned threads: ${pinned.length}`);
|
|
1071
|
+
};
|
|
1072
|
+
const commandUnpin = async (request, argument) => {
|
|
1073
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
1074
|
+
const threadId = argument.trim() || session.getActiveThreadId();
|
|
1075
|
+
if (!threadId) {
|
|
1076
|
+
await reply(request, "No active thread to unpin.");
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const pinned = registry.unpinThread(request.contextKey, threadId);
|
|
1080
|
+
await reply(request, `Unpinned thread ${threadId}.\nPinned threads: ${pinned.length}`);
|
|
1081
|
+
};
|
|
1082
|
+
const commandPinned = async (request) => {
|
|
1083
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
1084
|
+
const pinned = registry.listPinnedThreadIds(request.contextKey);
|
|
1085
|
+
const records = pinned.map((threadId) => session.getSessionRecord(threadId)).filter((record) => Boolean(record));
|
|
1086
|
+
if (records.length === 0) {
|
|
1087
|
+
await reply(request, "No pinned threads.");
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const pickId = createPick("session", records.map((record) => record.id));
|
|
1091
|
+
await reply(request, [`Pinned threads (${records.length}):`, ...records.map((record, index) => `${index + 1}. ${record.title || record.id}\n ${record.id}\n ${record.cwd || "-"}`)].join("\n"), {
|
|
1092
|
+
buttons: records.map((record, index) => [{ label: trimLine(record.title || record.id, 75), action: `slack_pick:${pickId}:${index}` }]),
|
|
1093
|
+
});
|
|
1094
|
+
};
|
|
1095
|
+
const commandHandback = async (request) => {
|
|
1096
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
1097
|
+
if (getBusyReason(request.contextKey).busy) {
|
|
1098
|
+
await reply(request, "Cannot hand back while a prompt is running. Use `/stop` first.");
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (!session.hasActiveThread()) {
|
|
1102
|
+
await reply(request, "No active thread to hand back.");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const result = session.handback();
|
|
1106
|
+
updateSession(request, session);
|
|
1107
|
+
appendActivity(request, { status: "info", type: "handback", threadId: result.threadId, workspace: result.workspace, agentId: session.getInfo().agentId, detail: result.command ?? result.threadId ?? "handback" });
|
|
1108
|
+
await deliverChannelAction(runtime, request.context, commandService.renderHandback(result));
|
|
1109
|
+
};
|
|
1110
|
+
const commandMirror = async (request, argument) => {
|
|
1111
|
+
const session = await getSession(request, { deferThreadStart: true });
|
|
1112
|
+
const info = session.getInfo();
|
|
1113
|
+
await deliverChannelAction(runtime, request.context, commandService.renderMirrorPreference({ source: "slack", contextKey: request.contextKey, argument, preferencesStore, cliMirrorSupported: capabilitiesOf(info).cliMirror, agentLabel: info.agentLabel }));
|
|
1114
|
+
};
|
|
1115
|
+
const commandNotify = async (request, argument) => {
|
|
1116
|
+
await deliverChannelAction(runtime, request.context, commandService.renderNotifyPreference({ source: "slack", contextKey: request.contextKey, argument, preferencesStore }));
|
|
1117
|
+
};
|
|
1118
|
+
const commandVoice = async (request, argument) => {
|
|
1119
|
+
await deliverChannelAction(runtime, request.context, await commandService.renderVoicePreference({ source: "slack", contextKey: request.contextKey, argument, preferencesStore }));
|
|
1120
|
+
};
|
|
1121
|
+
const commandRegisterChannel = async (request) => {
|
|
1122
|
+
const channel = userStore.registerSlackChannel({ teamId: request.teamId, channelId: request.channelId, title: request.channelName, type: request.isDirectMessage ? "dm" : "channel", enabled: true });
|
|
1123
|
+
audit(request, { action: "slack_channel_updated", status: "ok", description: channel.channelId });
|
|
1124
|
+
await reply(request, `Slack channel registered: ${channel.title || channel.channelId}`);
|
|
1125
|
+
};
|
|
1126
|
+
const commandLink = async (request, code) => {
|
|
1127
|
+
if (!userStore.hasAdminUser()) {
|
|
1128
|
+
await reply(request, "NordRelay has no admin user yet. Run `nordrelay user create-admin` on the host.", { ephemeral: true });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
try {
|
|
1132
|
+
const linked = userStore.consumeSlackLinkCode(code, { slackUserId: request.userId, teamId: request.teamId, username: request.username });
|
|
1133
|
+
request.authUser = linked;
|
|
1134
|
+
audit(request, { action: "slack_linked", status: "ok", description: request.userId });
|
|
1135
|
+
await reply(request, `Linked Slack account to ${linked.user.email}.`, { ephemeral: true });
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
await reply(request, `Link failed: ${friendlyErrorText(error)}`, { ephemeral: true });
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
const handleAttachments = async (request, files, text) => {
|
|
1142
|
+
const session = await getSession(request);
|
|
1143
|
+
const workspace = session.getInfo().workspace;
|
|
1144
|
+
const turnId = randomUUID().slice(0, 12);
|
|
1145
|
+
const outDir = outboxPath(workspace, turnId);
|
|
1146
|
+
await ensureOutDir(outDir);
|
|
1147
|
+
const stagedFiles = [];
|
|
1148
|
+
const imagePaths = [];
|
|
1149
|
+
const transcripts = [];
|
|
1150
|
+
for (const file of files) {
|
|
1151
|
+
const size = Number(file.size ?? 0);
|
|
1152
|
+
if (size > Math.min(config.maxFileSize, MAX_ATTACHMENT_DOWNLOAD)) {
|
|
1153
|
+
await reply(request, `Skipped ${file.name || file.id}: file is too large.`);
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
const downloadUrl = file.url_private_download || file.url_private;
|
|
1157
|
+
if (!downloadUrl) {
|
|
1158
|
+
await reply(request, `Skipped ${file.name || file.id}: no download URL.`);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
const response = await fetch(downloadUrl, { headers: { authorization: `Bearer ${config.slackBotToken}` } });
|
|
1162
|
+
if (!response.ok) {
|
|
1163
|
+
throw new Error(`Failed to download ${file.name || file.id}: ${response.status}`);
|
|
1164
|
+
}
|
|
1165
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1166
|
+
const mimeType = file.mimetype || inferMimeType(file.name || "attachment");
|
|
1167
|
+
const staged = await stageFile(buffer, file.name || `slack-${file.id}`, mimeType, { workspace, turnId, maxFileSize: config.maxFileSize });
|
|
1168
|
+
stagedFiles.push(staged);
|
|
1169
|
+
if (mimeType.startsWith("image/"))
|
|
1170
|
+
imagePaths.push(staged.localPath);
|
|
1171
|
+
if (mimeType.startsWith("audio/")) {
|
|
1172
|
+
const result = await transcribeAudio(staged.localPath, {
|
|
1173
|
+
preferredBackend: config.voicePreferredBackend === "auto" ? undefined : config.voicePreferredBackend,
|
|
1174
|
+
language: config.voiceDefaultLanguage,
|
|
1175
|
+
});
|
|
1176
|
+
if (result.text.trim())
|
|
1177
|
+
transcripts.push(`Audio transcript (${staged.safeName}, via ${result.backend}):\n${result.text.trim()}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
|
|
1181
|
+
if ((preferencesStore.get(request.contextKey).voiceTranscribeOnly ?? config.voiceTranscribeOnly) && audioOnly && !text.trim()) {
|
|
1182
|
+
await reply(request, transcripts.join("\n\n") || "No transcript produced.");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const prompt = {};
|
|
1186
|
+
const textParts = [text.trim(), ...transcripts].filter(Boolean);
|
|
1187
|
+
if (textParts.length)
|
|
1188
|
+
prompt.text = textParts.join("\n\n");
|
|
1189
|
+
if (imagePaths.length)
|
|
1190
|
+
prompt.imagePaths = imagePaths;
|
|
1191
|
+
if (stagedFiles.length)
|
|
1192
|
+
prompt.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
|
|
1193
|
+
await handlePrompt(request, prompt, outDir);
|
|
1194
|
+
};
|
|
1195
|
+
const handleMessage = async (event) => {
|
|
1196
|
+
if (event.bot_id || event.subtype === "bot_message")
|
|
1197
|
+
return;
|
|
1198
|
+
const request = requestFromMessage(event);
|
|
1199
|
+
const text = stripSlackMention(event.text ?? "").trim();
|
|
1200
|
+
const parsed = parseSlackMessageCommand(text);
|
|
1201
|
+
if (parsed) {
|
|
1202
|
+
await handleCommand(request, parsed.command, parsed.argument);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (!config.slackMessageContentEnabled && !(event.files?.length)) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const permission = event.files?.length ? "files.write" : "prompt.send";
|
|
1209
|
+
if (!await authenticate(request, permission))
|
|
1210
|
+
return;
|
|
1211
|
+
if (event.files?.length) {
|
|
1212
|
+
await handleAttachments(request, event.files, text);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (text) {
|
|
1216
|
+
await handlePrompt(request, text);
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
const handleSlashCommand = async (payload, respond) => {
|
|
1220
|
+
const request = requestFromSlashCommand(payload, respond);
|
|
1221
|
+
const parsed = parseSlackSlashCommand(payload.text ?? "");
|
|
1222
|
+
await handleCommand(request, parsed.command, parsed.argument);
|
|
1223
|
+
};
|
|
1224
|
+
const handleButtonAction = async (request, action) => {
|
|
1225
|
+
const pickMatch = action.match(/^slack_pick:([^:]+):(\d+)$/);
|
|
1226
|
+
if (pickMatch?.[1]) {
|
|
1227
|
+
const pick = picks.get(pickMatch[1]);
|
|
1228
|
+
const index = Number.parseInt(pickMatch[2] ?? "", 10);
|
|
1229
|
+
const value = pick?.values[index];
|
|
1230
|
+
if (!pick || !value) {
|
|
1231
|
+
await reply(request, "Selection expired.", { ephemeral: true });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (pick.kind === "agent")
|
|
1235
|
+
await commandAgent(request, value);
|
|
1236
|
+
else if (pick.kind === "session")
|
|
1237
|
+
await commandSwitch(request, value);
|
|
1238
|
+
else if (pick.kind === "model")
|
|
1239
|
+
await commandModel(request, value);
|
|
1240
|
+
else if (pick.kind === "reasoning")
|
|
1241
|
+
await commandReasoning(request, value);
|
|
1242
|
+
else if (pick.kind === "launch")
|
|
1243
|
+
await commandLaunch(request, value);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const queueMatch = action.match(/^slack_queue_(run|cancel|top|up|down):(.+):([^:]+)$/);
|
|
1247
|
+
if (queueMatch?.[1] && queueMatch[2] === request.contextKey) {
|
|
1248
|
+
await commandQueue(request, `${queueMatch[1]} ${queueMatch[3]}`);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const peerQueueMatch = action.match(/^slack_peer_queue_cancel:([^:]+):([^:]+)$/);
|
|
1252
|
+
if (peerQueueMatch?.[1] && peerQueueMatch[2]) {
|
|
1253
|
+
await remoteClient.webProxy(peerQueueMatch[1], { method: "POST", path: "/api/queue", body: { action: "cancel", id: peerQueueMatch[2] }, contextKey: request.contextKey }, actorFor(request), request.contextKey);
|
|
1254
|
+
await reply(request, `Cancelled remote queued prompt ${peerQueueMatch[2]}.`, { ephemeral: true });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const artifactMatch = action.match(/^slack_artifact_(send|zip|delete):(.+):([^:]+)$/);
|
|
1258
|
+
if (artifactMatch?.[1] && artifactMatch[2] === request.contextKey) {
|
|
1259
|
+
await commandArtifacts(request, `${artifactMatch[1]} ${artifactMatch[3]}`);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const updateMatch = action.match(/^agent-update:(start|log|cancel):(.+)$/);
|
|
1263
|
+
if (updateMatch?.[1]) {
|
|
1264
|
+
const updateAction = updateMatch[1];
|
|
1265
|
+
const value = updateMatch[2] ?? "";
|
|
1266
|
+
if (updateAction === "start")
|
|
1267
|
+
await commandUpdate(request, value);
|
|
1268
|
+
else
|
|
1269
|
+
await commandUpdate(request, `${updateAction} ${value}`);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (action === "agent-update:jobs") {
|
|
1273
|
+
await commandUpdate(request, "jobs");
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const abortMatch = action.match(/^slack_abort:(.+)$/);
|
|
1277
|
+
if (abortMatch?.[1] === request.contextKey) {
|
|
1278
|
+
await commandAbort(request);
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
const createPick = (kind, values) => {
|
|
1282
|
+
const id = randomUUID().replace(/-/g, "").slice(0, 10);
|
|
1283
|
+
picks.set(id, { kind, values });
|
|
1284
|
+
setTimeout(() => picks.delete(id), 10 * 60 * 1000).unref?.();
|
|
1285
|
+
return id;
|
|
1286
|
+
};
|
|
1287
|
+
const monitorExternalContexts = async () => {
|
|
1288
|
+
const keys = new Set([...registry.listContexts().map((context) => context.contextKey), ...promptStore.listContextKeys()].filter(isSlackContextKey));
|
|
1289
|
+
for (const contextKey of keys) {
|
|
1290
|
+
const parsed = parseSlackContextKey(contextKey);
|
|
1291
|
+
if (!parsed)
|
|
1292
|
+
continue;
|
|
1293
|
+
if (!canSendSystemMessagesToSlackContext(userStore, contextKey))
|
|
1294
|
+
continue;
|
|
1295
|
+
if (!isSlackTeamAllowed(parsed.teamId) || !isSlackChannelAllowedByEnv(parsed.channelId))
|
|
1296
|
+
continue;
|
|
1297
|
+
const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
|
|
1298
|
+
if (!session)
|
|
1299
|
+
continue;
|
|
1300
|
+
const context = { channelId: "slack", chatId: parsed.channelId, topicId: parsed.threadTs };
|
|
1301
|
+
const previous = externalMirrors.get(contextKey);
|
|
1302
|
+
const snapshot = getExternalSnapshotForSession(session, config, { afterLine: previous?.lastLine ?? Number.MAX_SAFE_INTEGER }) ??
|
|
1303
|
+
getExternalSnapshotForSession(session, config, { maxEvents: 1 });
|
|
1304
|
+
if (snapshot && !session.isProcessing()) {
|
|
1305
|
+
await mirrorExternalSnapshot(contextKey, context, session, snapshot);
|
|
1306
|
+
}
|
|
1307
|
+
if (snapshot?.activity.active) {
|
|
1308
|
+
if (promptStore.list(contextKey).length > 0) {
|
|
1309
|
+
await updateQueueStatusMessage(contextKey, context, `Waiting for ${snapshot.agentLabel} CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`).catch(() => { });
|
|
1310
|
+
}
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
if (promptStore.list(contextKey).length > 0 && !promptStore.isPaused(contextKey) && !session.isProcessing()) {
|
|
1314
|
+
await updateQueueStatusMessage(contextKey, context, `CLI task finished, running queued prompt 1/${promptStore.list(contextKey).length}.`).catch(() => { });
|
|
1315
|
+
await drainQueue({ contextKey, context, userId: "system", channelId: parsed.channelId, teamId: parsed.teamId, isDirectMessage: false, source: "system" });
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
app.event("message", async ({ event }) => {
|
|
1320
|
+
await handleMessage(event);
|
|
1321
|
+
});
|
|
1322
|
+
app.event("app_mention", async ({ event }) => {
|
|
1323
|
+
await handleMessage(event);
|
|
1324
|
+
});
|
|
1325
|
+
app.command(config.slackCommand, async ({ command, ack, respond }) => {
|
|
1326
|
+
await ack();
|
|
1327
|
+
await handleSlashCommand(command, respond);
|
|
1328
|
+
});
|
|
1329
|
+
app.action(/^nr:/, async ({ action, body, ack, respond }) => {
|
|
1330
|
+
await ack();
|
|
1331
|
+
const actionId = String(action.action_id ?? "");
|
|
1332
|
+
const parsedAction = actionFromSlackActionId(actionId);
|
|
1333
|
+
if (!parsedAction)
|
|
1334
|
+
return;
|
|
1335
|
+
const request = requestFromAction(body, respond);
|
|
1336
|
+
if (!await authenticate(request, permissionForSlackAction(parsedAction)))
|
|
1337
|
+
return;
|
|
1338
|
+
await handleButtonAction(request, parsedAction);
|
|
1339
|
+
});
|
|
1340
|
+
return {
|
|
1341
|
+
app,
|
|
1342
|
+
async start() {
|
|
1343
|
+
await app.start(config.slackSocketMode ? undefined : config.slackPort);
|
|
1344
|
+
console.log(`Slack bot ready (${config.slackSocketMode ? "socket mode" : `port ${config.slackPort}`}).`);
|
|
1345
|
+
void collectSlackDiagnostics({
|
|
1346
|
+
config,
|
|
1347
|
+
userStore,
|
|
1348
|
+
timeoutMs: 3_500,
|
|
1349
|
+
rateLimit: getSlackRateLimitMetrics(),
|
|
1350
|
+
}).then((diagnostics) => {
|
|
1351
|
+
for (const check of diagnostics.checks.filter((item) => item.status === "warn" || item.status === "error")) {
|
|
1352
|
+
console.warn(`Slack ${check.status}: ${check.label}: ${check.detail}`);
|
|
1353
|
+
}
|
|
1354
|
+
for (const channel of diagnostics.channelChecks.filter((item) => item.status === "warn" || item.status === "error")) {
|
|
1355
|
+
console.warn(`Slack ${channel.status}: channel ${channel.channelId}: ${channel.detail}`);
|
|
1356
|
+
}
|
|
1357
|
+
}).catch((error) => console.warn("Slack diagnostics failed:", friendlyErrorText(error)));
|
|
1358
|
+
externalMonitor = setInterval(() => {
|
|
1359
|
+
void monitorExternalContexts().catch((error) => console.error("Failed to monitor Slack external activity:", error));
|
|
1360
|
+
}, config.codexExternalBusyCheckMs);
|
|
1361
|
+
externalMonitor.unref?.();
|
|
1362
|
+
},
|
|
1363
|
+
async stop() {
|
|
1364
|
+
if (externalMonitor)
|
|
1365
|
+
clearInterval(externalMonitor);
|
|
1366
|
+
agentUpdates.cancelAll();
|
|
1367
|
+
await app.stop();
|
|
1368
|
+
},
|
|
1369
|
+
};
|
|
1370
|
+
function requestFromMessage(event) {
|
|
1371
|
+
const threadTs = event.thread_ts && event.thread_ts !== event.ts ? event.thread_ts : undefined;
|
|
1372
|
+
return {
|
|
1373
|
+
contextKey: slackContextKey({ teamId: event.team, channelId: event.channel, threadTs }),
|
|
1374
|
+
context: { channelId: "slack", chatId: event.channel, ...(threadTs ? { topicId: threadTs } : {}), userId: event.user, username: event.username },
|
|
1375
|
+
userId: event.user ?? "unknown",
|
|
1376
|
+
username: event.username,
|
|
1377
|
+
teamId: event.team,
|
|
1378
|
+
channelId: event.channel,
|
|
1379
|
+
channelName: event.channel,
|
|
1380
|
+
isDirectMessage: event.channel_type === "im" || event.channel.startsWith("D"),
|
|
1381
|
+
source: "message",
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
function requestFromSlashCommand(command, respond) {
|
|
1385
|
+
return {
|
|
1386
|
+
contextKey: slackContextKey({ teamId: command.team_id, channelId: command.channel_id }),
|
|
1387
|
+
context: { channelId: "slack", chatId: command.channel_id, userId: command.user_id, username: command.user_name },
|
|
1388
|
+
userId: command.user_id,
|
|
1389
|
+
username: command.user_name,
|
|
1390
|
+
teamId: command.team_id,
|
|
1391
|
+
channelId: command.channel_id,
|
|
1392
|
+
channelName: command.channel_name,
|
|
1393
|
+
isDirectMessage: command.channel_name === "directmessage" || command.channel_id.startsWith("D"),
|
|
1394
|
+
source: "slash",
|
|
1395
|
+
respond,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
function requestFromAction(body, respond) {
|
|
1399
|
+
const channelId = body.channel?.id ?? "";
|
|
1400
|
+
const teamId = body.team?.id;
|
|
1401
|
+
const threadTs = body.message?.thread_ts && body.message.thread_ts !== body.message?.ts ? body.message.thread_ts : undefined;
|
|
1402
|
+
return {
|
|
1403
|
+
contextKey: slackContextKey({ teamId, channelId, threadTs }),
|
|
1404
|
+
context: { channelId: "slack", chatId: channelId, ...(threadTs ? { topicId: threadTs } : {}), userId: body.user?.id, username: body.user?.username },
|
|
1405
|
+
userId: body.user?.id ?? "unknown",
|
|
1406
|
+
username: body.user?.username,
|
|
1407
|
+
teamId,
|
|
1408
|
+
channelId,
|
|
1409
|
+
channelName: body.channel?.name,
|
|
1410
|
+
isDirectMessage: channelId.startsWith("D"),
|
|
1411
|
+
source: "action",
|
|
1412
|
+
respond,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
function isSlackTeamAllowed(teamId) {
|
|
1416
|
+
return !teamId || config.slackAllowedTeamIds.length === 0 || config.slackAllowedTeamIds.includes(teamId);
|
|
1417
|
+
}
|
|
1418
|
+
function isSlackChannelAllowedByEnv(channelId) {
|
|
1419
|
+
return config.slackAllowedChannelIds.length === 0 || config.slackAllowedChannelIds.includes(channelId);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
export function canSendSystemMessagesToSlackContext(userStore, contextKey) {
|
|
1423
|
+
if (!userStore.hasAdminUser()) {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
const parsed = parseSlackContextKey(contextKey);
|
|
1427
|
+
if (!parsed) {
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
return userStore.snapshot().slackChannels.some((channel) => channel.enabled &&
|
|
1431
|
+
channel.channelId === parsed.channelId &&
|
|
1432
|
+
(channel.teamId ?? "") === (parsed.teamId ?? ""));
|
|
1433
|
+
}
|
|
1434
|
+
function stripSlackMention(text) {
|
|
1435
|
+
return text.replace(/^<@[^>]+>\s*/, "");
|
|
1436
|
+
}
|
|
1437
|
+
function inferMimeType(name) {
|
|
1438
|
+
const lower = name.toLowerCase();
|
|
1439
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
|
|
1440
|
+
return "image/jpeg";
|
|
1441
|
+
if (lower.endsWith(".png"))
|
|
1442
|
+
return "image/png";
|
|
1443
|
+
if (lower.endsWith(".gif"))
|
|
1444
|
+
return "image/gif";
|
|
1445
|
+
if (lower.endsWith(".webp"))
|
|
1446
|
+
return "image/webp";
|
|
1447
|
+
if (lower.endsWith(".mp3"))
|
|
1448
|
+
return "audio/mpeg";
|
|
1449
|
+
if (lower.endsWith(".wav"))
|
|
1450
|
+
return "audio/wav";
|
|
1451
|
+
if (lower.endsWith(".ogg") || lower.endsWith(".oga"))
|
|
1452
|
+
return "audio/ogg";
|
|
1453
|
+
if (lower.endsWith(".m4a"))
|
|
1454
|
+
return "audio/mp4";
|
|
1455
|
+
if (lower.endsWith(".webm"))
|
|
1456
|
+
return "audio/webm";
|
|
1457
|
+
return "application/octet-stream";
|
|
1458
|
+
}
|
|
1459
|
+
function isQueuedPrompt(value) {
|
|
1460
|
+
return Boolean(value && typeof value === "object" && "id" in value && "contextKey" in value);
|
|
1461
|
+
}
|