opencode-router 0.11.77 → 0.11.78
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/dist/bridge.js +1456 -0
- package/dist/cli.js +553 -0
- package/dist/config.js +195 -0
- package/dist/db.js +196 -0
- package/dist/events.js +11 -0
- package/dist/health.js +499 -0
- package/dist/logger.js +14 -0
- package/dist/opencode.js +34 -0
- package/dist/slack.js +169 -0
- package/dist/telegram.js +78 -0
- package/dist/text.js +41 -0
- package/package.json +1 -1
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,1456 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { readConfigFile, writeConfigFile } from "./config.js";
|
|
5
|
+
import { BridgeStore } from "./db.js";
|
|
6
|
+
import { normalizeEvent } from "./events.js";
|
|
7
|
+
import { startHealthServer } from "./health.js";
|
|
8
|
+
import { buildPermissionRules, createClient } from "./opencode.js";
|
|
9
|
+
import { chunkText, formatInputSummary, truncateText } from "./text.js";
|
|
10
|
+
import { createSlackAdapter } from "./slack.js";
|
|
11
|
+
import { createTelegramAdapter } from "./telegram.js";
|
|
12
|
+
async function startAdapterBounded(adapter, options) {
|
|
13
|
+
const outcome = adapter
|
|
14
|
+
.start()
|
|
15
|
+
.then(() => ({ ok: true }))
|
|
16
|
+
.catch((error) => ({ ok: false, error }));
|
|
17
|
+
if (options.onError) {
|
|
18
|
+
void outcome.then((result) => {
|
|
19
|
+
if (!result.ok) {
|
|
20
|
+
options.onError?.(result.error);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const winner = await Promise.race([
|
|
25
|
+
outcome.then((result) => ({ kind: "outcome", result })),
|
|
26
|
+
delay(options.timeoutMs).then(() => ({ kind: "timeout" })),
|
|
27
|
+
]);
|
|
28
|
+
if (winner.kind === "timeout")
|
|
29
|
+
return { status: "timeout" };
|
|
30
|
+
if (winner.result.ok)
|
|
31
|
+
return { status: "started" };
|
|
32
|
+
return { status: "error", error: winner.result.error };
|
|
33
|
+
}
|
|
34
|
+
const TOOL_LABELS = {
|
|
35
|
+
bash: "bash",
|
|
36
|
+
read: "read",
|
|
37
|
+
write: "write",
|
|
38
|
+
edit: "edit",
|
|
39
|
+
patch: "patch",
|
|
40
|
+
multiedit: "edit",
|
|
41
|
+
grep: "grep",
|
|
42
|
+
glob: "glob",
|
|
43
|
+
task: "agent",
|
|
44
|
+
webfetch: "webfetch",
|
|
45
|
+
};
|
|
46
|
+
const CHANNEL_LABELS = {
|
|
47
|
+
telegram: "Telegram",
|
|
48
|
+
slack: "Slack",
|
|
49
|
+
};
|
|
50
|
+
const TYPING_INTERVAL_MS = 6000;
|
|
51
|
+
const OPENCODE_ROUTER_AGENT_FILE_RELATIVE_PATH = ".opencode/agents/opencode-router.md";
|
|
52
|
+
const OPENCODE_ROUTER_AGENT_MAX_CHARS = 16_000;
|
|
53
|
+
// Model presets for quick switching
|
|
54
|
+
const MODEL_PRESETS = {
|
|
55
|
+
opus: { providerID: "anthropic", modelID: "claude-opus-4-5-20251101" },
|
|
56
|
+
codex: { providerID: "openai", modelID: "gpt-5.2-codex" },
|
|
57
|
+
};
|
|
58
|
+
// Per-user model overrides (channel:peerId -> ModelRef)
|
|
59
|
+
const userModelOverrides = new Map();
|
|
60
|
+
function getUserModelKey(channel, identityId, peerId) {
|
|
61
|
+
return `${channel}:${identityId}:${peerId}`;
|
|
62
|
+
}
|
|
63
|
+
function getUserModel(channel, identityId, peerId, defaultModel) {
|
|
64
|
+
const key = getUserModelKey(channel, identityId, peerId);
|
|
65
|
+
return userModelOverrides.get(key) ?? defaultModel;
|
|
66
|
+
}
|
|
67
|
+
function setUserModel(channel, identityId, peerId, model) {
|
|
68
|
+
const key = getUserModelKey(channel, identityId, peerId);
|
|
69
|
+
if (model) {
|
|
70
|
+
userModelOverrides.set(key, model);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
userModelOverrides.delete(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function adapterKey(channel, identityId) {
|
|
77
|
+
return `${channel}:${identityId}`;
|
|
78
|
+
}
|
|
79
|
+
function normalizeIdentityId(value) {
|
|
80
|
+
const trimmed = (value ?? "").trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
return "default";
|
|
83
|
+
const safe = trimmed.replace(/[^a-zA-Z0-9_.-]+/g, "-");
|
|
84
|
+
const cleaned = safe.replace(/^-+|-+$/g, "").slice(0, 48);
|
|
85
|
+
return cleaned || "default";
|
|
86
|
+
}
|
|
87
|
+
export async function startBridge(config, logger, reporter, deps = {}) {
|
|
88
|
+
const reportStatus = reporter?.onStatus;
|
|
89
|
+
const clients = new Map();
|
|
90
|
+
const defaultDirectory = config.opencodeDirectory;
|
|
91
|
+
const workspaceRoot = resolve(defaultDirectory || process.cwd());
|
|
92
|
+
const workspaceAgentFilePath = join(workspaceRoot, OPENCODE_ROUTER_AGENT_FILE_RELATIVE_PATH);
|
|
93
|
+
const agentPromptCache = new Map();
|
|
94
|
+
let latestAgentConfig = {
|
|
95
|
+
filePath: workspaceAgentFilePath,
|
|
96
|
+
loaded: false,
|
|
97
|
+
instructions: "",
|
|
98
|
+
};
|
|
99
|
+
const parseMessagingAgentFile = (content) => {
|
|
100
|
+
const lines = content.split(/\r?\n/);
|
|
101
|
+
let start = 0;
|
|
102
|
+
while (start < lines.length && !lines[start]?.trim()) {
|
|
103
|
+
start += 1;
|
|
104
|
+
}
|
|
105
|
+
let selectedAgent;
|
|
106
|
+
if (start < lines.length) {
|
|
107
|
+
const first = lines[start]?.trim() ?? "";
|
|
108
|
+
const match = first.match(/^@agent\s+([A-Za-z0-9_.:/-]+)$/);
|
|
109
|
+
if (match?.[1]) {
|
|
110
|
+
selectedAgent = match[1];
|
|
111
|
+
lines.splice(start, 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const instructions = lines.join("\n").trim();
|
|
115
|
+
return { ...(selectedAgent ? { selectedAgent } : {}), instructions };
|
|
116
|
+
};
|
|
117
|
+
const loadMessagingAgentConfig = async () => {
|
|
118
|
+
const filePath = workspaceAgentFilePath;
|
|
119
|
+
try {
|
|
120
|
+
const info = await stat(filePath);
|
|
121
|
+
if (!info.isFile()) {
|
|
122
|
+
agentPromptCache.delete(filePath);
|
|
123
|
+
latestAgentConfig = { filePath, loaded: false, instructions: "" };
|
|
124
|
+
return latestAgentConfig;
|
|
125
|
+
}
|
|
126
|
+
const cached = agentPromptCache.get(filePath);
|
|
127
|
+
if (cached && cached.mtimeMs === info.mtimeMs) {
|
|
128
|
+
latestAgentConfig = cached.config;
|
|
129
|
+
return latestAgentConfig;
|
|
130
|
+
}
|
|
131
|
+
const raw = (await readFile(filePath, "utf8")).trim();
|
|
132
|
+
if (!raw) {
|
|
133
|
+
const next = { filePath, loaded: false, instructions: "" };
|
|
134
|
+
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next });
|
|
135
|
+
latestAgentConfig = next;
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
138
|
+
const truncated = raw.length > OPENCODE_ROUTER_AGENT_MAX_CHARS ? raw.slice(0, OPENCODE_ROUTER_AGENT_MAX_CHARS) : raw;
|
|
139
|
+
const parsed = parseMessagingAgentFile(truncated);
|
|
140
|
+
const next = {
|
|
141
|
+
filePath,
|
|
142
|
+
loaded: Boolean(parsed.instructions || parsed.selectedAgent),
|
|
143
|
+
...(parsed.selectedAgent ? { selectedAgent: parsed.selectedAgent } : {}),
|
|
144
|
+
instructions: parsed.instructions,
|
|
145
|
+
};
|
|
146
|
+
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next });
|
|
147
|
+
latestAgentConfig = next;
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const code = error?.code;
|
|
152
|
+
if (code === "ENOENT") {
|
|
153
|
+
agentPromptCache.delete(filePath);
|
|
154
|
+
latestAgentConfig = { filePath, loaded: false, instructions: "" };
|
|
155
|
+
return latestAgentConfig;
|
|
156
|
+
}
|
|
157
|
+
logger.warn({ error, filePath }, "failed to load opencode-router agent file");
|
|
158
|
+
latestAgentConfig = { filePath, loaded: false, instructions: "" };
|
|
159
|
+
return latestAgentConfig;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const isDangerousRootDirectory = (dir) => {
|
|
163
|
+
const normalized = dir.trim();
|
|
164
|
+
if (!normalized)
|
|
165
|
+
return true;
|
|
166
|
+
if (process.platform !== "win32") {
|
|
167
|
+
return normalized === "/";
|
|
168
|
+
}
|
|
169
|
+
// Windows roots like C:, C:/, C:\
|
|
170
|
+
return /^[a-zA-Z]:\/?$/.test(normalized.replace(/\\/g, "/"));
|
|
171
|
+
};
|
|
172
|
+
const resolveIdentityDirectory = (channel, identityId) => {
|
|
173
|
+
const id = identityId.trim();
|
|
174
|
+
if (!id)
|
|
175
|
+
return "";
|
|
176
|
+
if (channel === "telegram") {
|
|
177
|
+
const bot = config.telegramBots.find((entry) => entry.id === id);
|
|
178
|
+
return typeof bot?.directory === "string" ? String(bot.directory).trim() : "";
|
|
179
|
+
}
|
|
180
|
+
const app = config.slackApps.find((entry) => entry.id === id);
|
|
181
|
+
return typeof app?.directory === "string" ? String(app.directory).trim() : "";
|
|
182
|
+
};
|
|
183
|
+
const listIdentityConfigs = (channel) => {
|
|
184
|
+
if (channel === "telegram") {
|
|
185
|
+
return config.telegramBots.map((bot) => ({ id: bot.id, directory: (bot.directory ?? "").trim() }));
|
|
186
|
+
}
|
|
187
|
+
return config.slackApps.map((app) => ({ id: app.id, directory: (app.directory ?? "").trim() }));
|
|
188
|
+
};
|
|
189
|
+
const getClient = (directory) => {
|
|
190
|
+
const resolved = (directory ?? "").trim() || defaultDirectory;
|
|
191
|
+
if (deps.client && resolved === defaultDirectory) {
|
|
192
|
+
return deps.client;
|
|
193
|
+
}
|
|
194
|
+
const existing = clients.get(resolved);
|
|
195
|
+
if (existing)
|
|
196
|
+
return existing;
|
|
197
|
+
const next = deps.clientFactory ? deps.clientFactory(resolved) : createClient(config, resolved);
|
|
198
|
+
clients.set(resolved, next);
|
|
199
|
+
return next;
|
|
200
|
+
};
|
|
201
|
+
const rootClient = getClient(defaultDirectory);
|
|
202
|
+
const store = deps.store ?? new BridgeStore(config.dbPath);
|
|
203
|
+
logger.debug({
|
|
204
|
+
configPath: config.configPath,
|
|
205
|
+
opencodeUrl: config.opencodeUrl,
|
|
206
|
+
opencodeDirectory: config.opencodeDirectory,
|
|
207
|
+
telegramBots: config.telegramBots.map((bot) => ({ id: bot.id, enabled: bot.enabled !== false })),
|
|
208
|
+
slackApps: config.slackApps.map((app) => ({ id: app.id, enabled: app.enabled !== false })),
|
|
209
|
+
groupsEnabled: config.groupsEnabled,
|
|
210
|
+
permissionMode: config.permissionMode,
|
|
211
|
+
toolUpdatesEnabled: config.toolUpdatesEnabled,
|
|
212
|
+
}, "bridge config");
|
|
213
|
+
const adapters = deps.adapters ?? new Map();
|
|
214
|
+
const usingInjectedAdapters = Boolean(deps.adapters);
|
|
215
|
+
if (!usingInjectedAdapters) {
|
|
216
|
+
const enabledTelegram = config.telegramBots.filter((bot) => bot.enabled !== false);
|
|
217
|
+
if (enabledTelegram.length === 0) {
|
|
218
|
+
logger.info("telegram adapters disabled");
|
|
219
|
+
reportStatus?.("Telegram adapters disabled.");
|
|
220
|
+
}
|
|
221
|
+
for (const bot of enabledTelegram) {
|
|
222
|
+
const key = adapterKey("telegram", bot.id);
|
|
223
|
+
logger.debug({ identityId: bot.id }, "telegram adapter enabled");
|
|
224
|
+
const base = createTelegramAdapter(bot, config, logger, handleInbound);
|
|
225
|
+
adapters.set(key, { ...base, key });
|
|
226
|
+
}
|
|
227
|
+
const enabledSlack = config.slackApps.filter((app) => app.enabled !== false);
|
|
228
|
+
if (enabledSlack.length === 0) {
|
|
229
|
+
logger.info("slack adapters disabled");
|
|
230
|
+
reportStatus?.("Slack adapters disabled.");
|
|
231
|
+
}
|
|
232
|
+
for (const app of enabledSlack) {
|
|
233
|
+
const key = adapterKey("slack", app.id);
|
|
234
|
+
logger.debug({ identityId: app.id }, "slack adapter enabled");
|
|
235
|
+
const base = createSlackAdapter(app, config, logger, handleInbound);
|
|
236
|
+
adapters.set(key, { ...base, key });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const keyForSession = (directory, sessionID) => `${directory}::${sessionID}`;
|
|
240
|
+
const sessionQueue = new Map();
|
|
241
|
+
const activeRuns = new Map();
|
|
242
|
+
const sessionModels = new Map();
|
|
243
|
+
const typingLoops = new Map();
|
|
244
|
+
const formatPeer = (_channel, peerId) => peerId;
|
|
245
|
+
const normalizeDirectory = (input) => {
|
|
246
|
+
const trimmed = input.trim();
|
|
247
|
+
if (!trimmed)
|
|
248
|
+
return "";
|
|
249
|
+
const unified = trimmed.replace(/\\/g, "/");
|
|
250
|
+
const withoutTrailing = unified.replace(/\/+$/, "");
|
|
251
|
+
const normalized = withoutTrailing || "/";
|
|
252
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
253
|
+
};
|
|
254
|
+
const workspaceRootNormalized = normalizeDirectory(workspaceRoot);
|
|
255
|
+
const isWithinWorkspaceRoot = (candidate) => {
|
|
256
|
+
const resolved = resolve(candidate || workspaceRoot);
|
|
257
|
+
const relativePath = relative(workspaceRoot, resolved);
|
|
258
|
+
if (!relativePath)
|
|
259
|
+
return true;
|
|
260
|
+
if (relativePath === ".")
|
|
261
|
+
return true;
|
|
262
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath))
|
|
263
|
+
return false;
|
|
264
|
+
const boundary = workspaceRoot.endsWith(sep) ? workspaceRoot : `${workspaceRoot}${sep}`;
|
|
265
|
+
return resolved === workspaceRoot || resolved.startsWith(boundary);
|
|
266
|
+
};
|
|
267
|
+
const resolveScopedDirectory = (input) => {
|
|
268
|
+
const trimmed = input.trim();
|
|
269
|
+
if (!trimmed)
|
|
270
|
+
return { ok: false, error: "Directory is required." };
|
|
271
|
+
const resolved = resolve(isAbsolute(trimmed) ? trimmed : join(workspaceRoot, trimmed));
|
|
272
|
+
if (!isWithinWorkspaceRoot(resolved)) {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: `Directory must stay within workspace root: ${workspaceRootNormalized}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return { ok: true, directory: normalizeDirectory(resolved) };
|
|
279
|
+
};
|
|
280
|
+
const formatModelLabel = (model) => model ? `${model.providerID}/${model.modelID}` : null;
|
|
281
|
+
const extractModelRef = (info) => {
|
|
282
|
+
if (!info || typeof info !== "object")
|
|
283
|
+
return null;
|
|
284
|
+
const record = info;
|
|
285
|
+
if (record.role !== "user")
|
|
286
|
+
return null;
|
|
287
|
+
if (!record.model || typeof record.model !== "object")
|
|
288
|
+
return null;
|
|
289
|
+
const model = record.model;
|
|
290
|
+
if (typeof model.providerID !== "string" || typeof model.modelID !== "string")
|
|
291
|
+
return null;
|
|
292
|
+
return { providerID: model.providerID, modelID: model.modelID };
|
|
293
|
+
};
|
|
294
|
+
const reportThinking = (run) => {
|
|
295
|
+
if (!reportStatus)
|
|
296
|
+
return;
|
|
297
|
+
const modelLabel = formatModelLabel(sessionModels.get(run.key));
|
|
298
|
+
const nextLabel = modelLabel ? `Thinking (${modelLabel})` : "Thinking...";
|
|
299
|
+
if (run.thinkingLabel === nextLabel && run.thinkingActive)
|
|
300
|
+
return;
|
|
301
|
+
run.thinkingLabel = nextLabel;
|
|
302
|
+
run.thinkingActive = true;
|
|
303
|
+
reportStatus(`[${CHANNEL_LABELS[run.channel]}/${run.identityId}] ${formatPeer(run.channel, run.peerId)} ${nextLabel}`);
|
|
304
|
+
};
|
|
305
|
+
const reportDone = (run) => {
|
|
306
|
+
if (!reportStatus || !run.thinkingActive)
|
|
307
|
+
return;
|
|
308
|
+
const modelLabel = formatModelLabel(sessionModels.get(run.key));
|
|
309
|
+
const suffix = modelLabel ? ` (${modelLabel})` : "";
|
|
310
|
+
reportStatus(`[${CHANNEL_LABELS[run.channel]}/${run.identityId}] ${formatPeer(run.channel, run.peerId)} Done${suffix}`);
|
|
311
|
+
run.thinkingActive = false;
|
|
312
|
+
};
|
|
313
|
+
const startTyping = (run) => {
|
|
314
|
+
const adapter = adapters.get(run.adapterKey);
|
|
315
|
+
if (!adapter?.sendTyping)
|
|
316
|
+
return;
|
|
317
|
+
if (typingLoops.has(run.key))
|
|
318
|
+
return;
|
|
319
|
+
const sendTyping = async () => {
|
|
320
|
+
try {
|
|
321
|
+
await adapter.sendTyping?.(run.peerId);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
logger.warn({ error, channel: run.channel, identityId: run.identityId }, "typing update failed");
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
void sendTyping();
|
|
328
|
+
const timer = setInterval(sendTyping, TYPING_INTERVAL_MS);
|
|
329
|
+
typingLoops.set(run.key, timer);
|
|
330
|
+
};
|
|
331
|
+
const stopTyping = (key) => {
|
|
332
|
+
const timer = typingLoops.get(key);
|
|
333
|
+
if (!timer)
|
|
334
|
+
return;
|
|
335
|
+
clearInterval(timer);
|
|
336
|
+
typingLoops.delete(key);
|
|
337
|
+
};
|
|
338
|
+
let opencodeHealthy = false;
|
|
339
|
+
let opencodeVersion;
|
|
340
|
+
const HEALTH_SLOW_INTERVAL_MS = 30_000;
|
|
341
|
+
const HEALTH_FAST_INTERVAL_MS = 1_000;
|
|
342
|
+
let healthIntervalMs = HEALTH_FAST_INTERVAL_MS;
|
|
343
|
+
let healthTimer = null;
|
|
344
|
+
async function refreshHealth() {
|
|
345
|
+
try {
|
|
346
|
+
const health = await rootClient.global.health();
|
|
347
|
+
opencodeHealthy = Boolean(health.healthy);
|
|
348
|
+
opencodeVersion = health.version;
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
logger.warn({ error }, "failed to reach opencode health");
|
|
352
|
+
opencodeHealthy = false;
|
|
353
|
+
}
|
|
354
|
+
// After initial startup, switch to a slower poll once OpenCode is healthy.
|
|
355
|
+
if (opencodeHealthy && healthIntervalMs !== HEALTH_SLOW_INTERVAL_MS) {
|
|
356
|
+
healthIntervalMs = HEALTH_SLOW_INTERVAL_MS;
|
|
357
|
+
if (healthTimer) {
|
|
358
|
+
clearInterval(healthTimer);
|
|
359
|
+
}
|
|
360
|
+
healthTimer = setInterval(refreshHealth, healthIntervalMs);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
await refreshHealth();
|
|
364
|
+
healthTimer = setInterval(refreshHealth, healthIntervalMs);
|
|
365
|
+
// Mutable runtime state for groups (persisted to config file)
|
|
366
|
+
let groupsEnabled = config.groupsEnabled;
|
|
367
|
+
const startOfToday = (now) => {
|
|
368
|
+
const day = new Date(now);
|
|
369
|
+
day.setHours(0, 0, 0, 0);
|
|
370
|
+
return day.getTime();
|
|
371
|
+
};
|
|
372
|
+
let activityDayStart = startOfToday(Date.now());
|
|
373
|
+
let inboundToday = 0;
|
|
374
|
+
let outboundToday = 0;
|
|
375
|
+
let lastInboundAt;
|
|
376
|
+
let lastOutboundAt;
|
|
377
|
+
const ensureActivityDay = (now) => {
|
|
378
|
+
const nextDayStart = startOfToday(now);
|
|
379
|
+
if (nextDayStart === activityDayStart)
|
|
380
|
+
return;
|
|
381
|
+
activityDayStart = nextDayStart;
|
|
382
|
+
inboundToday = 0;
|
|
383
|
+
outboundToday = 0;
|
|
384
|
+
};
|
|
385
|
+
const recordInboundActivity = (now) => {
|
|
386
|
+
ensureActivityDay(now);
|
|
387
|
+
inboundToday += 1;
|
|
388
|
+
lastInboundAt = now;
|
|
389
|
+
};
|
|
390
|
+
const recordOutboundActivity = (now) => {
|
|
391
|
+
ensureActivityDay(now);
|
|
392
|
+
outboundToday += 1;
|
|
393
|
+
lastOutboundAt = now;
|
|
394
|
+
};
|
|
395
|
+
await loadMessagingAgentConfig();
|
|
396
|
+
let stopHealthServer = null;
|
|
397
|
+
if (!deps.disableHealthServer && config.healthPort) {
|
|
398
|
+
stopHealthServer = await startHealthServer(config.healthPort, () => ({
|
|
399
|
+
ok: opencodeHealthy,
|
|
400
|
+
opencode: {
|
|
401
|
+
url: config.opencodeUrl,
|
|
402
|
+
healthy: opencodeHealthy,
|
|
403
|
+
version: opencodeVersion,
|
|
404
|
+
},
|
|
405
|
+
channels: {
|
|
406
|
+
telegram: Array.from(adapters.keys()).some((key) => key.startsWith("telegram:")),
|
|
407
|
+
// WhatsApp removed; keep field for backward compatibility.
|
|
408
|
+
whatsapp: false,
|
|
409
|
+
slack: Array.from(adapters.keys()).some((key) => key.startsWith("slack:")),
|
|
410
|
+
},
|
|
411
|
+
config: {
|
|
412
|
+
groupsEnabled,
|
|
413
|
+
},
|
|
414
|
+
activity: {
|
|
415
|
+
dayStart: activityDayStart,
|
|
416
|
+
inboundToday,
|
|
417
|
+
outboundToday,
|
|
418
|
+
...(typeof lastInboundAt === "number" ? { lastInboundAt } : {}),
|
|
419
|
+
...(typeof lastOutboundAt === "number" ? { lastOutboundAt } : {}),
|
|
420
|
+
...(typeof lastInboundAt === "number" || typeof lastOutboundAt === "number"
|
|
421
|
+
? { lastMessageAt: Math.max(lastInboundAt ?? 0, lastOutboundAt ?? 0) }
|
|
422
|
+
: {}),
|
|
423
|
+
},
|
|
424
|
+
agent: {
|
|
425
|
+
scope: "workspace",
|
|
426
|
+
path: latestAgentConfig.filePath,
|
|
427
|
+
loaded: latestAgentConfig.loaded,
|
|
428
|
+
...(latestAgentConfig.selectedAgent ? { selected: latestAgentConfig.selectedAgent } : {}),
|
|
429
|
+
},
|
|
430
|
+
}), logger, {
|
|
431
|
+
getGroupsEnabled: () => groupsEnabled,
|
|
432
|
+
setGroupsEnabled: async (enabled) => {
|
|
433
|
+
groupsEnabled = enabled;
|
|
434
|
+
// Also update config so adapters see the change
|
|
435
|
+
config.groupsEnabled = enabled;
|
|
436
|
+
// Persist to config file
|
|
437
|
+
const { config: current } = readConfigFile(config.configPath);
|
|
438
|
+
const next = {
|
|
439
|
+
...current,
|
|
440
|
+
groupsEnabled: enabled,
|
|
441
|
+
};
|
|
442
|
+
next.version = next.version ?? 1;
|
|
443
|
+
writeConfigFile(config.configPath, next);
|
|
444
|
+
config.configFile = next;
|
|
445
|
+
logger.info({ groupsEnabled: enabled }, "groups config updated");
|
|
446
|
+
return { groupsEnabled: enabled };
|
|
447
|
+
},
|
|
448
|
+
listTelegramIdentities: async () => {
|
|
449
|
+
return {
|
|
450
|
+
items: config.telegramBots.map((bot) => ({
|
|
451
|
+
id: bot.id,
|
|
452
|
+
enabled: bot.enabled !== false,
|
|
453
|
+
running: adapters.has(adapterKey("telegram", bot.id)),
|
|
454
|
+
})),
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
upsertTelegramIdentity: async (input) => {
|
|
458
|
+
const token = input.token?.trim() ?? "";
|
|
459
|
+
if (!token)
|
|
460
|
+
throw new Error("token is required");
|
|
461
|
+
const id = normalizeIdentityId(input.id);
|
|
462
|
+
if (id === "env")
|
|
463
|
+
throw new Error("identity id 'env' is reserved");
|
|
464
|
+
const enabled = input.enabled !== false;
|
|
465
|
+
const directoryInput = typeof input.directory === "string" ? input.directory.trim() : "";
|
|
466
|
+
// Persist to config file.
|
|
467
|
+
const { config: current } = readConfigFile(config.configPath);
|
|
468
|
+
const telegram = current.channels?.telegram;
|
|
469
|
+
const bots = Array.isArray(telegram?.bots) ? (telegram.bots ?? []) : [];
|
|
470
|
+
const nextBots = [];
|
|
471
|
+
let found = false;
|
|
472
|
+
for (const entry of bots) {
|
|
473
|
+
if (!entry || typeof entry !== "object")
|
|
474
|
+
continue;
|
|
475
|
+
const record = entry;
|
|
476
|
+
const entryId = normalizeIdentityId(typeof record.id === "string" ? record.id : "default");
|
|
477
|
+
if (entryId !== id) {
|
|
478
|
+
nextBots.push(entry);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
found = true;
|
|
482
|
+
const existingDirectory = typeof record.directory === "string" ? record.directory.trim() : "";
|
|
483
|
+
const directory = directoryInput || existingDirectory;
|
|
484
|
+
nextBots.push({ id, token, enabled, ...(directory ? { directory } : {}) });
|
|
485
|
+
}
|
|
486
|
+
if (!found) {
|
|
487
|
+
nextBots.push({ id, token, enabled, ...(directoryInput ? { directory: directoryInput } : {}) });
|
|
488
|
+
}
|
|
489
|
+
const next = {
|
|
490
|
+
...current,
|
|
491
|
+
channels: {
|
|
492
|
+
...current.channels,
|
|
493
|
+
telegram: {
|
|
494
|
+
...(current.channels?.telegram ?? {}),
|
|
495
|
+
enabled: true,
|
|
496
|
+
bots: nextBots,
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
next.version = next.version ?? 1;
|
|
501
|
+
writeConfigFile(config.configPath, next);
|
|
502
|
+
config.configFile = next;
|
|
503
|
+
// Update runtime identity list.
|
|
504
|
+
const existingIdx = config.telegramBots.findIndex((bot) => bot.id === id);
|
|
505
|
+
if (existingIdx >= 0) {
|
|
506
|
+
const prev = config.telegramBots[existingIdx];
|
|
507
|
+
const nextDirectory = directoryInput || prev?.directory || undefined;
|
|
508
|
+
config.telegramBots[existingIdx] = { id, token, enabled, ...(nextDirectory ? { directory: String(nextDirectory).trim() } : {}) };
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
config.telegramBots.push({ id, token, enabled, ...(directoryInput ? { directory: directoryInput } : {}) });
|
|
512
|
+
}
|
|
513
|
+
// Start/stop adapter.
|
|
514
|
+
const key = adapterKey("telegram", id);
|
|
515
|
+
const existing = adapters.get(key);
|
|
516
|
+
if (!enabled) {
|
|
517
|
+
if (existing) {
|
|
518
|
+
try {
|
|
519
|
+
await existing.stop();
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
logger.warn({ error, channel: "telegram", identityId: id }, "failed to stop telegram adapter");
|
|
523
|
+
}
|
|
524
|
+
adapters.delete(key);
|
|
525
|
+
}
|
|
526
|
+
return { id, enabled: false, applied: true };
|
|
527
|
+
}
|
|
528
|
+
if (existing) {
|
|
529
|
+
try {
|
|
530
|
+
await existing.stop();
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
logger.warn({ error, channel: "telegram", identityId: id }, "failed to stop existing telegram adapter");
|
|
534
|
+
}
|
|
535
|
+
adapters.delete(key);
|
|
536
|
+
}
|
|
537
|
+
const base = createTelegramAdapter({ id, token, enabled, ...(directoryInput ? { directory: directoryInput } : {}) }, config, logger, handleInbound);
|
|
538
|
+
const adapter = { ...base, key };
|
|
539
|
+
adapters.set(key, adapter);
|
|
540
|
+
const startResult = await startAdapterBounded(adapter, {
|
|
541
|
+
timeoutMs: 2_500,
|
|
542
|
+
onError: (error) => {
|
|
543
|
+
logger.error({ error, channel: "telegram", identityId: id }, "telegram adapter start failed");
|
|
544
|
+
adapters.delete(key);
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
if (startResult.status === "timeout") {
|
|
548
|
+
return { id, enabled: true, applied: false, starting: true };
|
|
549
|
+
}
|
|
550
|
+
if (startResult.status === "error") {
|
|
551
|
+
return { id, enabled: true, applied: false, error: String(startResult.error) };
|
|
552
|
+
}
|
|
553
|
+
return { id, enabled: true, applied: true };
|
|
554
|
+
},
|
|
555
|
+
deleteTelegramIdentity: async (rawId) => {
|
|
556
|
+
const id = normalizeIdentityId(rawId);
|
|
557
|
+
if (id === "env")
|
|
558
|
+
throw new Error("env identity cannot be deleted");
|
|
559
|
+
const { config: current } = readConfigFile(config.configPath);
|
|
560
|
+
const telegram = current.channels?.telegram;
|
|
561
|
+
const bots = Array.isArray(telegram?.bots) ? (telegram.bots ?? []) : [];
|
|
562
|
+
const nextBots = [];
|
|
563
|
+
let deleted = false;
|
|
564
|
+
for (const entry of bots) {
|
|
565
|
+
if (!entry || typeof entry !== "object")
|
|
566
|
+
continue;
|
|
567
|
+
const record = entry;
|
|
568
|
+
const entryId = normalizeIdentityId(typeof record.id === "string" ? record.id : "default");
|
|
569
|
+
if (entryId === id) {
|
|
570
|
+
deleted = true;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
nextBots.push(entry);
|
|
574
|
+
}
|
|
575
|
+
const next = {
|
|
576
|
+
...current,
|
|
577
|
+
channels: {
|
|
578
|
+
...current.channels,
|
|
579
|
+
telegram: {
|
|
580
|
+
...(current.channels?.telegram ?? {}),
|
|
581
|
+
bots: nextBots,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
next.version = next.version ?? 1;
|
|
586
|
+
writeConfigFile(config.configPath, next);
|
|
587
|
+
config.configFile = next;
|
|
588
|
+
config.telegramBots.splice(0, config.telegramBots.length, ...config.telegramBots.filter((bot) => bot.id !== id));
|
|
589
|
+
const key = adapterKey("telegram", id);
|
|
590
|
+
const existing = adapters.get(key);
|
|
591
|
+
if (existing) {
|
|
592
|
+
try {
|
|
593
|
+
await existing.stop();
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
logger.warn({ error, channel: "telegram", identityId: id }, "failed to stop telegram adapter");
|
|
597
|
+
}
|
|
598
|
+
adapters.delete(key);
|
|
599
|
+
}
|
|
600
|
+
return { id, deleted };
|
|
601
|
+
},
|
|
602
|
+
listSlackIdentities: async () => {
|
|
603
|
+
return {
|
|
604
|
+
items: config.slackApps.map((app) => ({
|
|
605
|
+
id: app.id,
|
|
606
|
+
enabled: app.enabled !== false,
|
|
607
|
+
running: adapters.has(adapterKey("slack", app.id)),
|
|
608
|
+
})),
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
upsertSlackIdentity: async (input) => {
|
|
612
|
+
const botToken = input.botToken?.trim() ?? "";
|
|
613
|
+
const appToken = input.appToken?.trim() ?? "";
|
|
614
|
+
if (!botToken || !appToken)
|
|
615
|
+
throw new Error("botToken and appToken are required");
|
|
616
|
+
const id = normalizeIdentityId(input.id);
|
|
617
|
+
if (id === "env")
|
|
618
|
+
throw new Error("identity id 'env' is reserved");
|
|
619
|
+
const enabled = input.enabled !== false;
|
|
620
|
+
const directoryInput = typeof input.directory === "string" ? input.directory.trim() : "";
|
|
621
|
+
const { config: current } = readConfigFile(config.configPath);
|
|
622
|
+
const slack = current.channels?.slack;
|
|
623
|
+
const apps = Array.isArray(slack?.apps) ? (slack.apps ?? []) : [];
|
|
624
|
+
const nextApps = [];
|
|
625
|
+
let found = false;
|
|
626
|
+
for (const entry of apps) {
|
|
627
|
+
if (!entry || typeof entry !== "object")
|
|
628
|
+
continue;
|
|
629
|
+
const record = entry;
|
|
630
|
+
const entryId = normalizeIdentityId(typeof record.id === "string" ? record.id : "default");
|
|
631
|
+
if (entryId !== id) {
|
|
632
|
+
nextApps.push(entry);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
found = true;
|
|
636
|
+
const existingDirectory = typeof record.directory === "string" ? record.directory.trim() : "";
|
|
637
|
+
const directory = directoryInput || existingDirectory;
|
|
638
|
+
nextApps.push({ id, botToken, appToken, enabled, ...(directory ? { directory } : {}) });
|
|
639
|
+
}
|
|
640
|
+
if (!found) {
|
|
641
|
+
nextApps.push({ id, botToken, appToken, enabled, ...(directoryInput ? { directory: directoryInput } : {}) });
|
|
642
|
+
}
|
|
643
|
+
const next = {
|
|
644
|
+
...current,
|
|
645
|
+
channels: {
|
|
646
|
+
...current.channels,
|
|
647
|
+
slack: {
|
|
648
|
+
...(current.channels?.slack ?? {}),
|
|
649
|
+
enabled: true,
|
|
650
|
+
apps: nextApps,
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
next.version = next.version ?? 1;
|
|
655
|
+
writeConfigFile(config.configPath, next);
|
|
656
|
+
config.configFile = next;
|
|
657
|
+
const existingIdx = config.slackApps.findIndex((app) => app.id === id);
|
|
658
|
+
if (existingIdx >= 0) {
|
|
659
|
+
const prev = config.slackApps[existingIdx];
|
|
660
|
+
const nextDirectory = directoryInput || prev?.directory || undefined;
|
|
661
|
+
config.slackApps[existingIdx] = {
|
|
662
|
+
id,
|
|
663
|
+
botToken,
|
|
664
|
+
appToken,
|
|
665
|
+
enabled,
|
|
666
|
+
...(nextDirectory ? { directory: String(nextDirectory).trim() } : {}),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
config.slackApps.push({ id, botToken, appToken, enabled, ...(directoryInput ? { directory: directoryInput } : {}) });
|
|
671
|
+
}
|
|
672
|
+
const key = adapterKey("slack", id);
|
|
673
|
+
const existing = adapters.get(key);
|
|
674
|
+
if (!enabled) {
|
|
675
|
+
if (existing) {
|
|
676
|
+
try {
|
|
677
|
+
await existing.stop();
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
logger.warn({ error, channel: "slack", identityId: id }, "failed to stop slack adapter");
|
|
681
|
+
}
|
|
682
|
+
adapters.delete(key);
|
|
683
|
+
}
|
|
684
|
+
return { id, enabled: false, applied: true };
|
|
685
|
+
}
|
|
686
|
+
if (existing) {
|
|
687
|
+
try {
|
|
688
|
+
await existing.stop();
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
logger.warn({ error, channel: "slack", identityId: id }, "failed to stop existing slack adapter");
|
|
692
|
+
}
|
|
693
|
+
adapters.delete(key);
|
|
694
|
+
}
|
|
695
|
+
const base = createSlackAdapter({ id, botToken, appToken, enabled, ...(directoryInput ? { directory: directoryInput } : {}) }, config, logger, handleInbound);
|
|
696
|
+
const adapter = { ...base, key };
|
|
697
|
+
adapters.set(key, adapter);
|
|
698
|
+
const startResult = await startAdapterBounded(adapter, {
|
|
699
|
+
timeoutMs: 2_500,
|
|
700
|
+
onError: (error) => {
|
|
701
|
+
logger.error({ error, channel: "slack", identityId: id }, "slack adapter start failed");
|
|
702
|
+
adapters.delete(key);
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
if (startResult.status === "timeout") {
|
|
706
|
+
return { id, enabled: true, applied: false, starting: true };
|
|
707
|
+
}
|
|
708
|
+
if (startResult.status === "error") {
|
|
709
|
+
return { id, enabled: true, applied: false, error: String(startResult.error) };
|
|
710
|
+
}
|
|
711
|
+
return { id, enabled: true, applied: true };
|
|
712
|
+
},
|
|
713
|
+
deleteSlackIdentity: async (rawId) => {
|
|
714
|
+
const id = normalizeIdentityId(rawId);
|
|
715
|
+
if (id === "env")
|
|
716
|
+
throw new Error("env identity cannot be deleted");
|
|
717
|
+
const { config: current } = readConfigFile(config.configPath);
|
|
718
|
+
const slack = current.channels?.slack;
|
|
719
|
+
const apps = Array.isArray(slack?.apps) ? (slack.apps ?? []) : [];
|
|
720
|
+
const nextApps = [];
|
|
721
|
+
let deleted = false;
|
|
722
|
+
for (const entry of apps) {
|
|
723
|
+
if (!entry || typeof entry !== "object")
|
|
724
|
+
continue;
|
|
725
|
+
const record = entry;
|
|
726
|
+
const entryId = normalizeIdentityId(typeof record.id === "string" ? record.id : "default");
|
|
727
|
+
if (entryId === id) {
|
|
728
|
+
deleted = true;
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
nextApps.push(entry);
|
|
732
|
+
}
|
|
733
|
+
const next = {
|
|
734
|
+
...current,
|
|
735
|
+
channels: {
|
|
736
|
+
...current.channels,
|
|
737
|
+
slack: {
|
|
738
|
+
...(current.channels?.slack ?? {}),
|
|
739
|
+
apps: nextApps,
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
next.version = next.version ?? 1;
|
|
744
|
+
writeConfigFile(config.configPath, next);
|
|
745
|
+
config.configFile = next;
|
|
746
|
+
config.slackApps.splice(0, config.slackApps.length, ...config.slackApps.filter((app) => app.id !== id));
|
|
747
|
+
const key = adapterKey("slack", id);
|
|
748
|
+
const existing = adapters.get(key);
|
|
749
|
+
if (existing) {
|
|
750
|
+
try {
|
|
751
|
+
await existing.stop();
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
logger.warn({ error, channel: "slack", identityId: id }, "failed to stop slack adapter");
|
|
755
|
+
}
|
|
756
|
+
adapters.delete(key);
|
|
757
|
+
}
|
|
758
|
+
return { id, deleted };
|
|
759
|
+
},
|
|
760
|
+
listBindings: async (filters) => {
|
|
761
|
+
const channelRaw = filters?.channel?.trim().toLowerCase();
|
|
762
|
+
const identityIdRaw = filters?.identityId?.trim();
|
|
763
|
+
let channel;
|
|
764
|
+
if (channelRaw) {
|
|
765
|
+
if (channelRaw === "telegram" || channelRaw === "slack") {
|
|
766
|
+
channel = channelRaw;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
throw new Error("Invalid channel");
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const identityId = identityIdRaw ? normalizeIdentityId(identityIdRaw) : undefined;
|
|
773
|
+
const bindings = store.listBindings({ ...(channel ? { channel } : {}), ...(identityId ? { identityId } : {}) });
|
|
774
|
+
return {
|
|
775
|
+
items: bindings.map((entry) => ({
|
|
776
|
+
channel: entry.channel,
|
|
777
|
+
identityId: entry.identity_id,
|
|
778
|
+
peerId: entry.peer_id,
|
|
779
|
+
directory: entry.directory,
|
|
780
|
+
updatedAt: entry.updated_at,
|
|
781
|
+
})),
|
|
782
|
+
};
|
|
783
|
+
},
|
|
784
|
+
setBinding: async (input) => {
|
|
785
|
+
const channel = input.channel.trim().toLowerCase();
|
|
786
|
+
if (channel !== "telegram" && channel !== "slack") {
|
|
787
|
+
throw new Error("Invalid channel");
|
|
788
|
+
}
|
|
789
|
+
const identityId = normalizeIdentityId(input.identityId);
|
|
790
|
+
const peerKey = input.peerId.trim();
|
|
791
|
+
const directory = input.directory.trim();
|
|
792
|
+
if (!peerKey || !directory) {
|
|
793
|
+
throw new Error("peerId and directory are required");
|
|
794
|
+
}
|
|
795
|
+
const scoped = resolveScopedDirectory(directory);
|
|
796
|
+
if (!scoped.ok) {
|
|
797
|
+
const error = new Error(scoped.error);
|
|
798
|
+
error.status = 400;
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
const normalizedDir = scoped.directory;
|
|
802
|
+
store.upsertBinding(channel, identityId, peerKey, normalizedDir);
|
|
803
|
+
store.deleteSession(channel, identityId, peerKey);
|
|
804
|
+
ensureEventSubscription(normalizedDir);
|
|
805
|
+
},
|
|
806
|
+
clearBinding: async (input) => {
|
|
807
|
+
const channel = input.channel.trim().toLowerCase();
|
|
808
|
+
if (channel !== "telegram" && channel !== "slack") {
|
|
809
|
+
throw new Error("Invalid channel");
|
|
810
|
+
}
|
|
811
|
+
const identityId = normalizeIdentityId(input.identityId);
|
|
812
|
+
const peerKey = input.peerId.trim();
|
|
813
|
+
if (!peerKey) {
|
|
814
|
+
throw new Error("peerId is required");
|
|
815
|
+
}
|
|
816
|
+
store.deleteBinding(channel, identityId, peerKey);
|
|
817
|
+
store.deleteSession(channel, identityId, peerKey);
|
|
818
|
+
},
|
|
819
|
+
sendMessage: async (input) => {
|
|
820
|
+
const channelRaw = input.channel.trim().toLowerCase();
|
|
821
|
+
if (channelRaw !== "telegram" && channelRaw !== "slack") {
|
|
822
|
+
throw new Error("Invalid channel");
|
|
823
|
+
}
|
|
824
|
+
const channel = channelRaw;
|
|
825
|
+
const identityId = input.identityId?.trim() ? normalizeIdentityId(input.identityId) : undefined;
|
|
826
|
+
const directoryInput = (input.directory ?? "").trim();
|
|
827
|
+
const peerId = (input.peerId ?? "").trim();
|
|
828
|
+
const autoBind = input.autoBind === true;
|
|
829
|
+
const text = input.text ?? "";
|
|
830
|
+
if (!text.trim()) {
|
|
831
|
+
throw new Error("text is required");
|
|
832
|
+
}
|
|
833
|
+
if (!directoryInput && !peerId) {
|
|
834
|
+
throw new Error("directory or peerId is required");
|
|
835
|
+
}
|
|
836
|
+
const normalizedDir = directoryInput ? (() => {
|
|
837
|
+
const scoped = resolveScopedDirectory(directoryInput);
|
|
838
|
+
if (!scoped.ok) {
|
|
839
|
+
const error = new Error(scoped.error);
|
|
840
|
+
error.status = 400;
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
return scoped.directory;
|
|
844
|
+
})() : "";
|
|
845
|
+
const resolveSendIdentityId = () => {
|
|
846
|
+
if (identityId)
|
|
847
|
+
return identityId;
|
|
848
|
+
if (normalizedDir) {
|
|
849
|
+
const configured = listIdentityConfigs(channel).find((entry) => {
|
|
850
|
+
if (!entry.directory)
|
|
851
|
+
return false;
|
|
852
|
+
if (!adapters.has(adapterKey(channel, entry.id)))
|
|
853
|
+
return false;
|
|
854
|
+
return normalizeDirectory(entry.directory) === normalizedDir;
|
|
855
|
+
});
|
|
856
|
+
if (configured?.id)
|
|
857
|
+
return configured.id;
|
|
858
|
+
}
|
|
859
|
+
const active = Array.from(adapters.values()).find((adapter) => adapter.name === channel);
|
|
860
|
+
return active?.identityId;
|
|
861
|
+
};
|
|
862
|
+
const targetIdentityId = resolveSendIdentityId();
|
|
863
|
+
if (peerId && !targetIdentityId) {
|
|
864
|
+
return {
|
|
865
|
+
channel,
|
|
866
|
+
directory: normalizedDir || workspaceRootNormalized,
|
|
867
|
+
peerId,
|
|
868
|
+
attempted: 0,
|
|
869
|
+
sent: 0,
|
|
870
|
+
reason: `No ${channel} adapter is running for direct send`,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (peerId && targetIdentityId) {
|
|
874
|
+
const adapter = adapters.get(adapterKey(channel, targetIdentityId));
|
|
875
|
+
if (!adapter) {
|
|
876
|
+
return {
|
|
877
|
+
channel,
|
|
878
|
+
directory: normalizedDir || workspaceRootNormalized,
|
|
879
|
+
identityId: targetIdentityId,
|
|
880
|
+
peerId,
|
|
881
|
+
attempted: 1,
|
|
882
|
+
sent: 0,
|
|
883
|
+
failures: [{ identityId: targetIdentityId, peerId, error: "Adapter not running" }],
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
if (autoBind && normalizedDir) {
|
|
887
|
+
store.upsertBinding(channel, targetIdentityId, peerId, normalizedDir);
|
|
888
|
+
store.deleteSession(channel, targetIdentityId, peerId);
|
|
889
|
+
ensureEventSubscription(normalizedDir);
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
await sendText(channel, targetIdentityId, peerId, text, { kind: "system", display: false });
|
|
893
|
+
return {
|
|
894
|
+
channel,
|
|
895
|
+
directory: normalizedDir || workspaceRootNormalized,
|
|
896
|
+
identityId: targetIdentityId,
|
|
897
|
+
peerId,
|
|
898
|
+
attempted: 1,
|
|
899
|
+
sent: 1,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
return {
|
|
904
|
+
channel,
|
|
905
|
+
directory: normalizedDir || workspaceRootNormalized,
|
|
906
|
+
identityId: targetIdentityId,
|
|
907
|
+
peerId,
|
|
908
|
+
attempted: 1,
|
|
909
|
+
sent: 0,
|
|
910
|
+
failures: [{
|
|
911
|
+
identityId: targetIdentityId,
|
|
912
|
+
peerId,
|
|
913
|
+
error: error instanceof Error ? error.message : String(error),
|
|
914
|
+
}],
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const bindings = store.listBindings({
|
|
919
|
+
channel,
|
|
920
|
+
...(identityId ? { identityId } : {}),
|
|
921
|
+
directory: normalizedDir,
|
|
922
|
+
});
|
|
923
|
+
if (bindings.length === 0) {
|
|
924
|
+
return {
|
|
925
|
+
channel,
|
|
926
|
+
directory: normalizedDir,
|
|
927
|
+
...(identityId ? { identityId } : {}),
|
|
928
|
+
attempted: 0,
|
|
929
|
+
sent: 0,
|
|
930
|
+
reason: `No bound conversations for ${channel}${identityId ? `/${identityId}` : ""} at directory ${normalizedDir}`,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
const failures = [];
|
|
934
|
+
let attempted = 0;
|
|
935
|
+
let sent = 0;
|
|
936
|
+
for (const binding of bindings) {
|
|
937
|
+
attempted += 1;
|
|
938
|
+
const adapter = adapters.get(adapterKey(channel, binding.identity_id));
|
|
939
|
+
if (!adapter) {
|
|
940
|
+
failures.push({
|
|
941
|
+
identityId: binding.identity_id,
|
|
942
|
+
peerId: binding.peer_id,
|
|
943
|
+
error: "Adapter not running",
|
|
944
|
+
});
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
await sendText(channel, binding.identity_id, binding.peer_id, text, { kind: "system", display: false });
|
|
949
|
+
sent += 1;
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
failures.push({
|
|
953
|
+
identityId: binding.identity_id,
|
|
954
|
+
peerId: binding.peer_id,
|
|
955
|
+
error: error instanceof Error ? error.message : String(error),
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
channel,
|
|
961
|
+
directory: normalizedDir,
|
|
962
|
+
...(identityId ? { identityId } : {}),
|
|
963
|
+
attempted,
|
|
964
|
+
sent,
|
|
965
|
+
...(failures.length ? { failures } : {}),
|
|
966
|
+
};
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
const eventSubscriptions = new Map();
|
|
971
|
+
const ensureEventSubscription = (directory) => {
|
|
972
|
+
if (deps.disableEventStream)
|
|
973
|
+
return;
|
|
974
|
+
const resolved = directory.trim() || defaultDirectory;
|
|
975
|
+
if (!resolved)
|
|
976
|
+
return;
|
|
977
|
+
if (eventSubscriptions.has(resolved))
|
|
978
|
+
return;
|
|
979
|
+
const abort = new AbortController();
|
|
980
|
+
eventSubscriptions.set(resolved, abort);
|
|
981
|
+
const client = getClient(resolved);
|
|
982
|
+
void (async () => {
|
|
983
|
+
const subscription = await client.event.subscribe(undefined, { signal: abort.signal });
|
|
984
|
+
for await (const raw of subscription.stream) {
|
|
985
|
+
const event = normalizeEvent(raw);
|
|
986
|
+
if (!event)
|
|
987
|
+
continue;
|
|
988
|
+
if (event.type === "message.updated") {
|
|
989
|
+
if (event.properties && typeof event.properties === "object") {
|
|
990
|
+
const record = event.properties;
|
|
991
|
+
const info = record.info;
|
|
992
|
+
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : null;
|
|
993
|
+
const model = extractModelRef(info);
|
|
994
|
+
if (sessionID && model) {
|
|
995
|
+
const key = keyForSession(resolved, sessionID);
|
|
996
|
+
sessionModels.set(key, model);
|
|
997
|
+
const run = activeRuns.get(key);
|
|
998
|
+
if (run)
|
|
999
|
+
reportThinking(run);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (event.type === "session.status") {
|
|
1004
|
+
if (event.properties && typeof event.properties === "object") {
|
|
1005
|
+
const record = event.properties;
|
|
1006
|
+
const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
|
|
1007
|
+
const status = record.status;
|
|
1008
|
+
if (sessionID && (status?.type === "busy" || status?.type === "retry")) {
|
|
1009
|
+
const run = activeRuns.get(keyForSession(resolved, sessionID));
|
|
1010
|
+
if (run) {
|
|
1011
|
+
reportThinking(run);
|
|
1012
|
+
startTyping(run);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (event.type === "session.idle") {
|
|
1018
|
+
if (event.properties && typeof event.properties === "object") {
|
|
1019
|
+
const record = event.properties;
|
|
1020
|
+
const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
|
|
1021
|
+
if (sessionID) {
|
|
1022
|
+
const key = keyForSession(resolved, sessionID);
|
|
1023
|
+
stopTyping(key);
|
|
1024
|
+
const run = activeRuns.get(key);
|
|
1025
|
+
if (run)
|
|
1026
|
+
reportDone(run);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (event.type === "message.part.updated") {
|
|
1031
|
+
const part = event.properties?.part;
|
|
1032
|
+
if (!part?.sessionID)
|
|
1033
|
+
continue;
|
|
1034
|
+
const run = activeRuns.get(keyForSession(resolved, part.sessionID));
|
|
1035
|
+
if (!run || !run.toolUpdatesEnabled)
|
|
1036
|
+
continue;
|
|
1037
|
+
if (part.type !== "tool")
|
|
1038
|
+
continue;
|
|
1039
|
+
const callId = part.callID;
|
|
1040
|
+
if (!callId)
|
|
1041
|
+
continue;
|
|
1042
|
+
const state = part.state;
|
|
1043
|
+
const status = state?.status ?? "unknown";
|
|
1044
|
+
if (run.seenToolStates.get(callId) === status)
|
|
1045
|
+
continue;
|
|
1046
|
+
run.seenToolStates.set(callId, status);
|
|
1047
|
+
const label = TOOL_LABELS[part.tool] ?? part.tool;
|
|
1048
|
+
const title = state.title || truncateText(formatInputSummary(state.input ?? {}), 120) || "running";
|
|
1049
|
+
let message = `[tool] ${label} ${status}: ${title}`;
|
|
1050
|
+
if (status === "completed" && state.output) {
|
|
1051
|
+
const output = truncateText(state.output.trim(), config.toolOutputLimit);
|
|
1052
|
+
if (output)
|
|
1053
|
+
message += `\n${output}`;
|
|
1054
|
+
}
|
|
1055
|
+
await sendText(run.channel, run.identityId, run.peerId, message, { kind: "tool" });
|
|
1056
|
+
}
|
|
1057
|
+
if (event.type === "permission.asked") {
|
|
1058
|
+
const permission = event.properties;
|
|
1059
|
+
if (!permission?.id || !permission.sessionID)
|
|
1060
|
+
continue;
|
|
1061
|
+
const response = config.permissionMode === "deny" ? "reject" : "always";
|
|
1062
|
+
await client.permission.respond({
|
|
1063
|
+
sessionID: permission.sessionID,
|
|
1064
|
+
permissionID: permission.id,
|
|
1065
|
+
response,
|
|
1066
|
+
});
|
|
1067
|
+
if (response === "reject") {
|
|
1068
|
+
const run = activeRuns.get(keyForSession(resolved, permission.sessionID));
|
|
1069
|
+
if (run) {
|
|
1070
|
+
await sendText(run.channel, run.identityId, run.peerId, "Permission denied. Update configuration to allow tools.", {
|
|
1071
|
+
kind: "system",
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
})().catch((error) => {
|
|
1078
|
+
if (abort.signal.aborted)
|
|
1079
|
+
return;
|
|
1080
|
+
logger.error({ error, directory: resolved }, "event stream closed");
|
|
1081
|
+
});
|
|
1082
|
+
};
|
|
1083
|
+
ensureEventSubscription(defaultDirectory);
|
|
1084
|
+
async function sendText(channel, identityId, peerId, text, options = {}) {
|
|
1085
|
+
const adapter = adapters.get(adapterKey(channel, identityId));
|
|
1086
|
+
if (!adapter)
|
|
1087
|
+
return;
|
|
1088
|
+
recordOutboundActivity(Date.now());
|
|
1089
|
+
const kind = options.kind ?? "system";
|
|
1090
|
+
logger.debug({ channel, identityId, peerId, kind, length: text.length }, "sendText requested");
|
|
1091
|
+
if (options.display !== false) {
|
|
1092
|
+
reporter?.onOutbound?.({ channel, identityId, peerId, text, kind });
|
|
1093
|
+
}
|
|
1094
|
+
// CHECK IF IT'S A FILE COMMAND
|
|
1095
|
+
if (text.startsWith("FILE:")) {
|
|
1096
|
+
const filePath = text.substring(5).trim();
|
|
1097
|
+
if (adapter.sendFile) {
|
|
1098
|
+
await adapter.sendFile(peerId, filePath);
|
|
1099
|
+
return; // Stop here, don't send text
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const chunks = chunkText(text, adapter.maxTextLength);
|
|
1103
|
+
for (const chunk of chunks) {
|
|
1104
|
+
logger.info({ channel, peerId, length: chunk.length }, "sending message");
|
|
1105
|
+
await adapter.sendText(peerId, chunk);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
async function handleInbound(message) {
|
|
1109
|
+
const adapter = adapters.get(adapterKey(message.channel, message.identityId));
|
|
1110
|
+
if (!adapter)
|
|
1111
|
+
return;
|
|
1112
|
+
recordInboundActivity(Date.now());
|
|
1113
|
+
let inbound = message;
|
|
1114
|
+
logger.debug({
|
|
1115
|
+
channel: inbound.channel,
|
|
1116
|
+
identityId: inbound.identityId,
|
|
1117
|
+
peerId: inbound.peerId,
|
|
1118
|
+
fromMe: inbound.fromMe,
|
|
1119
|
+
length: inbound.text.length,
|
|
1120
|
+
preview: truncateText(inbound.text.trim(), 120),
|
|
1121
|
+
}, "inbound received");
|
|
1122
|
+
logger.info({ channel: inbound.channel, identityId: inbound.identityId, peerId: inbound.peerId, length: inbound.text.length }, "received message");
|
|
1123
|
+
const peerKey = inbound.peerId;
|
|
1124
|
+
// Handle bot commands
|
|
1125
|
+
const trimmedText = inbound.text.trim();
|
|
1126
|
+
if (trimmedText.startsWith("/")) {
|
|
1127
|
+
const commandHandled = await handleCommand(inbound.channel, inbound.identityId, peerKey, inbound.peerId, trimmedText);
|
|
1128
|
+
if (commandHandled)
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
reporter?.onInbound?.({
|
|
1132
|
+
channel: inbound.channel,
|
|
1133
|
+
identityId: inbound.identityId,
|
|
1134
|
+
peerId: inbound.peerId,
|
|
1135
|
+
text: inbound.text,
|
|
1136
|
+
fromMe: inbound.fromMe,
|
|
1137
|
+
});
|
|
1138
|
+
const binding = store.getBinding(inbound.channel, inbound.identityId, peerKey);
|
|
1139
|
+
const session = store.getSession(inbound.channel, inbound.identityId, peerKey);
|
|
1140
|
+
const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId);
|
|
1141
|
+
const boundDirectoryCandidate = binding?.directory?.trim() || session?.directory?.trim() || identityDirectory || defaultDirectory;
|
|
1142
|
+
const hasExplicitBinding = Boolean(binding?.directory?.trim() || session?.directory?.trim() || identityDirectory);
|
|
1143
|
+
if (!boundDirectoryCandidate || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectoryCandidate))) {
|
|
1144
|
+
await sendText(inbound.channel, inbound.identityId, inbound.peerId, "No workspace directory configured for this identity. Ask your OpenWork host to set it, or reply with /dir <path>.", { kind: "system" });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const scopedBound = resolveScopedDirectory(boundDirectoryCandidate);
|
|
1148
|
+
if (!scopedBound.ok) {
|
|
1149
|
+
await sendText(inbound.channel, inbound.identityId, inbound.peerId, scopedBound.error, { kind: "system" });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const boundDirectory = scopedBound.directory;
|
|
1153
|
+
if (!binding?.directory?.trim()) {
|
|
1154
|
+
store.upsertBinding(inbound.channel, inbound.identityId, peerKey, boundDirectory);
|
|
1155
|
+
}
|
|
1156
|
+
ensureEventSubscription(boundDirectory);
|
|
1157
|
+
const sessionID = session?.session_id && normalizeDirectory(session?.directory ?? "") === normalizeDirectory(boundDirectory)
|
|
1158
|
+
? session.session_id
|
|
1159
|
+
: await createSession({
|
|
1160
|
+
channel: inbound.channel,
|
|
1161
|
+
identityId: inbound.identityId,
|
|
1162
|
+
peerId: inbound.peerId,
|
|
1163
|
+
peerKey,
|
|
1164
|
+
directory: boundDirectory,
|
|
1165
|
+
});
|
|
1166
|
+
const key = keyForSession(boundDirectory, sessionID);
|
|
1167
|
+
logger.debug({
|
|
1168
|
+
sessionID,
|
|
1169
|
+
channel: inbound.channel,
|
|
1170
|
+
peerId: inbound.peerId,
|
|
1171
|
+
reused: Boolean(session?.session_id),
|
|
1172
|
+
}, "session resolved");
|
|
1173
|
+
enqueue(key, async () => {
|
|
1174
|
+
const runState = {
|
|
1175
|
+
key,
|
|
1176
|
+
directory: boundDirectory,
|
|
1177
|
+
sessionID,
|
|
1178
|
+
channel: inbound.channel,
|
|
1179
|
+
identityId: inbound.identityId,
|
|
1180
|
+
adapterKey: adapterKey(inbound.channel, inbound.identityId),
|
|
1181
|
+
peerId: inbound.peerId,
|
|
1182
|
+
peerKey,
|
|
1183
|
+
toolUpdatesEnabled: config.toolUpdatesEnabled,
|
|
1184
|
+
seenToolStates: new Map(),
|
|
1185
|
+
};
|
|
1186
|
+
activeRuns.set(key, runState);
|
|
1187
|
+
reportThinking(runState);
|
|
1188
|
+
startTyping(runState);
|
|
1189
|
+
try {
|
|
1190
|
+
const effectiveModel = getUserModel(inbound.channel, inbound.identityId, peerKey, config.model);
|
|
1191
|
+
const messagingAgent = await loadMessagingAgentConfig();
|
|
1192
|
+
const promptText = messagingAgent.instructions
|
|
1193
|
+
? [
|
|
1194
|
+
"You are handling a Slack/Telegram message via OpenWork.",
|
|
1195
|
+
`Workspace agent file: ${messagingAgent.filePath}`,
|
|
1196
|
+
...(messagingAgent.selectedAgent ? [`Selected OpenCode agent: ${messagingAgent.selectedAgent}`] : []),
|
|
1197
|
+
"Follow these workspace messaging instructions:",
|
|
1198
|
+
messagingAgent.instructions,
|
|
1199
|
+
"",
|
|
1200
|
+
"Incoming user message:",
|
|
1201
|
+
inbound.text,
|
|
1202
|
+
].join("\n")
|
|
1203
|
+
: inbound.text;
|
|
1204
|
+
logger.debug({
|
|
1205
|
+
sessionID,
|
|
1206
|
+
length: inbound.text.length,
|
|
1207
|
+
model: effectiveModel,
|
|
1208
|
+
agent: messagingAgent.selectedAgent,
|
|
1209
|
+
}, "prompt start");
|
|
1210
|
+
const response = await getClient(boundDirectory).session.prompt({
|
|
1211
|
+
sessionID,
|
|
1212
|
+
parts: [{ type: "text", text: promptText }],
|
|
1213
|
+
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
1214
|
+
...(messagingAgent.selectedAgent ? { agent: messagingAgent.selectedAgent } : {}),
|
|
1215
|
+
});
|
|
1216
|
+
const parts = response.parts ?? [];
|
|
1217
|
+
const textParts = parts.filter((part) => part.type === "text" && !part.ignored);
|
|
1218
|
+
logger.debug({
|
|
1219
|
+
sessionID,
|
|
1220
|
+
partCount: parts.length,
|
|
1221
|
+
textCount: textParts.length,
|
|
1222
|
+
partTypes: parts.map((p) => p.type),
|
|
1223
|
+
ignoredCount: parts.filter((p) => p.ignored).length,
|
|
1224
|
+
}, "prompt response");
|
|
1225
|
+
const reply = parts
|
|
1226
|
+
.filter((part) => part.type === "text" && !part.ignored)
|
|
1227
|
+
.map((part) => part.text ?? "")
|
|
1228
|
+
.join("\n")
|
|
1229
|
+
.trim();
|
|
1230
|
+
if (reply) {
|
|
1231
|
+
logger.debug({ sessionID, replyLength: reply.length }, "reply built");
|
|
1232
|
+
await sendText(inbound.channel, inbound.identityId, inbound.peerId, reply, { kind: "reply" });
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
logger.debug({ sessionID }, "reply empty");
|
|
1236
|
+
await sendText(inbound.channel, inbound.identityId, inbound.peerId, "No response generated. Try again.", {
|
|
1237
|
+
kind: "system",
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
catch (error) {
|
|
1242
|
+
// Log full error details for debugging
|
|
1243
|
+
const errorDetails = {
|
|
1244
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1245
|
+
name: error instanceof Error ? error.name : undefined,
|
|
1246
|
+
stack: error instanceof Error ? error.stack?.split("\n").slice(0, 3).join("\n") : undefined,
|
|
1247
|
+
cause: error instanceof Error ? error.cause : undefined,
|
|
1248
|
+
status: error?.status ?? error?.statusCode ?? undefined,
|
|
1249
|
+
};
|
|
1250
|
+
logger.error({ error: errorDetails, sessionID }, "prompt failed");
|
|
1251
|
+
// Extract meaningful error details
|
|
1252
|
+
let errorMessage = "Error: failed to reach OpenCode.";
|
|
1253
|
+
if (error instanceof Error) {
|
|
1254
|
+
const msg = error.message || "";
|
|
1255
|
+
// Check for common error patterns
|
|
1256
|
+
if (msg.includes("401") || msg.includes("Unauthorized")) {
|
|
1257
|
+
errorMessage = "Error: OpenCode authentication failed (401). Check credentials.";
|
|
1258
|
+
}
|
|
1259
|
+
else if (msg.includes("403") || msg.includes("Forbidden")) {
|
|
1260
|
+
errorMessage = "Error: OpenCode access forbidden (403).";
|
|
1261
|
+
}
|
|
1262
|
+
else if (msg.includes("404") || msg.includes("Not Found")) {
|
|
1263
|
+
errorMessage = "Error: OpenCode endpoint not found (404).";
|
|
1264
|
+
}
|
|
1265
|
+
else if (msg.includes("429") || msg.includes("rate limit")) {
|
|
1266
|
+
errorMessage = "Error: Rate limited. Please wait and try again.";
|
|
1267
|
+
}
|
|
1268
|
+
else if (msg.includes("500") || msg.includes("Internal Server")) {
|
|
1269
|
+
errorMessage = "Error: OpenCode server error (500).";
|
|
1270
|
+
}
|
|
1271
|
+
else if (msg.includes("model") || msg.includes("provider")) {
|
|
1272
|
+
errorMessage = `Error: Model/provider issue - ${msg.slice(0, 100)}`;
|
|
1273
|
+
}
|
|
1274
|
+
else if (msg.includes("ECONNREFUSED") || msg.includes("connection")) {
|
|
1275
|
+
errorMessage = "Error: Cannot connect to OpenCode. Is it running?";
|
|
1276
|
+
}
|
|
1277
|
+
else if (msg.trim()) {
|
|
1278
|
+
// Include the actual error message (truncated)
|
|
1279
|
+
errorMessage = `Error: ${msg.slice(0, 150)}`;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
await sendText(inbound.channel, inbound.identityId, inbound.peerId, errorMessage, {
|
|
1283
|
+
kind: "system",
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
finally {
|
|
1287
|
+
stopTyping(key);
|
|
1288
|
+
reportDone(runState);
|
|
1289
|
+
activeRuns.delete(key);
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
async function handleCommand(channel, identityId, peerKey, peerId, text) {
|
|
1294
|
+
const parts = text.slice(1).split(/\s+/);
|
|
1295
|
+
const command = parts[0]?.toLowerCase();
|
|
1296
|
+
const args = parts.slice(1);
|
|
1297
|
+
// Model switching commands
|
|
1298
|
+
if (command && MODEL_PRESETS[command]) {
|
|
1299
|
+
const model = MODEL_PRESETS[command];
|
|
1300
|
+
setUserModel(channel, identityId, peerKey, model);
|
|
1301
|
+
await sendText(channel, identityId, peerId, `Model switched to ${model.providerID}/${model.modelID}`, {
|
|
1302
|
+
kind: "system",
|
|
1303
|
+
});
|
|
1304
|
+
logger.info({ channel, peerId: peerKey, model }, "model switched via command");
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
// /model command - show current model
|
|
1308
|
+
if (command === "model") {
|
|
1309
|
+
const current = getUserModel(channel, identityId, peerKey, config.model);
|
|
1310
|
+
const modelStr = current ? `${current.providerID}/${current.modelID}` : "default";
|
|
1311
|
+
await sendText(channel, identityId, peerId, `Current model: ${modelStr}`, { kind: "system" });
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1314
|
+
// /reset command - clear model override and session
|
|
1315
|
+
if (command === "reset") {
|
|
1316
|
+
setUserModel(channel, identityId, peerKey, undefined);
|
|
1317
|
+
store.deleteSession(channel, identityId, peerKey);
|
|
1318
|
+
await sendText(channel, identityId, peerId, "Session and model reset. Send a message to start fresh.", {
|
|
1319
|
+
kind: "system",
|
|
1320
|
+
});
|
|
1321
|
+
logger.info({ channel, peerId: peerKey }, "session and model reset");
|
|
1322
|
+
return true;
|
|
1323
|
+
}
|
|
1324
|
+
if (command === "dir" || command === "cd") {
|
|
1325
|
+
const next = args.join(" ").trim();
|
|
1326
|
+
if (!next) {
|
|
1327
|
+
const binding = store.getBinding(channel, identityId, peerKey);
|
|
1328
|
+
const current = binding?.directory?.trim() || store.getSession(channel, identityId, peerKey)?.directory?.trim() || defaultDirectory;
|
|
1329
|
+
await sendText(channel, identityId, peerId, `Current directory: ${current || "(none)"}`, { kind: "system" });
|
|
1330
|
+
return true;
|
|
1331
|
+
}
|
|
1332
|
+
const scoped = resolveScopedDirectory(next);
|
|
1333
|
+
if (!scoped.ok) {
|
|
1334
|
+
await sendText(channel, identityId, peerId, scoped.error, { kind: "system" });
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
const normalized = scoped.directory;
|
|
1338
|
+
store.upsertBinding(channel, identityId, peerKey, normalized);
|
|
1339
|
+
store.deleteSession(channel, identityId, peerKey);
|
|
1340
|
+
ensureEventSubscription(normalized);
|
|
1341
|
+
await sendText(channel, identityId, peerId, `Directory set to: ${normalized}`, { kind: "system" });
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1344
|
+
if (command === "agent") {
|
|
1345
|
+
const config = await loadMessagingAgentConfig();
|
|
1346
|
+
await sendText(channel, identityId, peerId, [
|
|
1347
|
+
`Scope: workspace`,
|
|
1348
|
+
`Agent file: ${config.filePath}`,
|
|
1349
|
+
`OpenCode agent: ${config.selectedAgent ?? "(none)"}`,
|
|
1350
|
+
`Status: ${config.loaded ? "loaded" : "missing or empty"}`,
|
|
1351
|
+
].join("\n"), { kind: "system" });
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
// /help command
|
|
1355
|
+
if (command === "help") {
|
|
1356
|
+
const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir <path> - bind this chat to a workspace directory\n/dir - show current directory\n/agent - show workspace agent scope/path\n/model - show current\n/reset - start fresh\n/help - this`;
|
|
1357
|
+
await sendText(channel, identityId, peerId, helpText, { kind: "system" });
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
// Unknown command - don't handle, let it pass through as a message
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
async function createSession(input) {
|
|
1364
|
+
const title = `opencode-router ${input.channel}/${input.identityId} ${input.peerId}`;
|
|
1365
|
+
const session = await getClient(input.directory).session.create({
|
|
1366
|
+
title,
|
|
1367
|
+
permission: buildPermissionRules(config.permissionMode),
|
|
1368
|
+
});
|
|
1369
|
+
const sessionID = session.id;
|
|
1370
|
+
if (!sessionID)
|
|
1371
|
+
throw new Error("Failed to create session");
|
|
1372
|
+
store.upsertSession(input.channel, input.identityId, input.peerKey, sessionID, input.directory);
|
|
1373
|
+
logger.info({ sessionID, channel: input.channel, identityId: input.identityId, peerId: input.peerKey, directory: input.directory }, "session created");
|
|
1374
|
+
reportStatus?.(`${CHANNEL_LABELS[input.channel]}/${input.identityId} session created for ${formatPeer(input.channel, input.peerId)} (ID: ${sessionID}).`);
|
|
1375
|
+
await sendText(input.channel, input.identityId, input.peerId, "🧭 Session started.", { kind: "system" });
|
|
1376
|
+
return sessionID;
|
|
1377
|
+
}
|
|
1378
|
+
function enqueue(key, task) {
|
|
1379
|
+
const previous = sessionQueue.get(key) ?? Promise.resolve();
|
|
1380
|
+
const next = previous
|
|
1381
|
+
.then(task)
|
|
1382
|
+
.catch((error) => {
|
|
1383
|
+
logger.error({ error }, "session task failed");
|
|
1384
|
+
})
|
|
1385
|
+
.finally(() => {
|
|
1386
|
+
if (sessionQueue.get(key) === next) {
|
|
1387
|
+
sessionQueue.delete(key);
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
sessionQueue.set(key, next);
|
|
1391
|
+
}
|
|
1392
|
+
for (const adapter of Array.from(adapters.values())) {
|
|
1393
|
+
const startResult = await startAdapterBounded(adapter, {
|
|
1394
|
+
timeoutMs: 8_000,
|
|
1395
|
+
onError: (error) => {
|
|
1396
|
+
logger.error({ error, channel: adapter.name, identityId: adapter.identityId }, "adapter start failed");
|
|
1397
|
+
adapters.delete(adapter.key);
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
if (startResult.status === "timeout") {
|
|
1401
|
+
logger.warn({ channel: adapter.name, identityId: adapter.identityId, timeoutMs: 8_000 }, "adapter start timed out");
|
|
1402
|
+
reportStatus?.(`${CHANNEL_LABELS[adapter.name]}/${adapter.identityId} adapter starting...`);
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (startResult.status === "error") {
|
|
1406
|
+
reportStatus?.(`${CHANNEL_LABELS[adapter.name]}/${adapter.identityId} adapter failed to start.`);
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
reportStatus?.(`${CHANNEL_LABELS[adapter.name]}/${adapter.identityId} adapter started.`);
|
|
1410
|
+
}
|
|
1411
|
+
logger.info({ channels: Array.from(adapters.keys()) }, "bridge started");
|
|
1412
|
+
reportStatus?.(`Bridge running. Logs: ${config.logFile}`);
|
|
1413
|
+
return {
|
|
1414
|
+
async stop() {
|
|
1415
|
+
if (healthTimer) {
|
|
1416
|
+
clearInterval(healthTimer);
|
|
1417
|
+
healthTimer = null;
|
|
1418
|
+
}
|
|
1419
|
+
if (stopHealthServer)
|
|
1420
|
+
stopHealthServer();
|
|
1421
|
+
for (const abort of eventSubscriptions.values()) {
|
|
1422
|
+
abort.abort();
|
|
1423
|
+
}
|
|
1424
|
+
eventSubscriptions.clear();
|
|
1425
|
+
for (const timer of typingLoops.values()) {
|
|
1426
|
+
clearInterval(timer);
|
|
1427
|
+
}
|
|
1428
|
+
typingLoops.clear();
|
|
1429
|
+
for (const adapter of adapters.values()) {
|
|
1430
|
+
await adapter.stop();
|
|
1431
|
+
}
|
|
1432
|
+
store.close();
|
|
1433
|
+
await delay(50);
|
|
1434
|
+
},
|
|
1435
|
+
async dispatchInbound(message) {
|
|
1436
|
+
const identityId = (message.identityId ?? "default").trim() || "default";
|
|
1437
|
+
await handleInbound({
|
|
1438
|
+
channel: message.channel,
|
|
1439
|
+
identityId,
|
|
1440
|
+
peerId: message.peerId,
|
|
1441
|
+
text: message.text,
|
|
1442
|
+
raw: message.raw ?? null,
|
|
1443
|
+
fromMe: message.fromMe,
|
|
1444
|
+
});
|
|
1445
|
+
// For tests and programmatic callers: wait for the session queue to drain.
|
|
1446
|
+
const peerKey = message.peerId;
|
|
1447
|
+
const session = store.getSession(message.channel, identityId, peerKey);
|
|
1448
|
+
const sessionID = session?.session_id;
|
|
1449
|
+
const directory = session?.directory?.trim() || store.getBinding(message.channel, identityId, peerKey)?.directory?.trim() || defaultDirectory;
|
|
1450
|
+
const pending = sessionID && directory ? sessionQueue.get(keyForSession(directory, sessionID)) : null;
|
|
1451
|
+
if (pending) {
|
|
1452
|
+
await pending;
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
};
|
|
1456
|
+
}
|