opencode-router 0.11.114 → 0.11.115

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/README.md CHANGED
@@ -63,6 +63,7 @@ Important for direct sends and bindings:
63
63
  - Telegram targets must use numeric `chat_id` values.
64
64
  - `@username` values are not valid direct `peerId` targets for router sends.
65
65
  - If a user has not started a chat with the bot yet, Telegram may return `chat not found`.
66
+ - Private Telegram identities can require first-chat pairing with `/pair <code>` before commands are accepted.
66
67
 
67
68
  ## Slack (Socket Mode)
68
69
 
package/dist/bridge.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
+ import { createHash } from "node:crypto";
2
3
  import { readFile, stat } from "node:fs/promises";
3
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { readConfigFile, writeConfigFile } from "./config.js";
@@ -90,6 +91,30 @@ function invalidTelegramPeerIdError() {
90
91
  error.status = 400;
91
92
  return error;
92
93
  }
94
+ const PAIRING_CODE_HASH_PATTERN = /^[a-f0-9]{64}$/;
95
+ function normalizeTelegramAccess(value) {
96
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
97
+ return raw === "private" ? "private" : "public";
98
+ }
99
+ function normalizePairingCodeHash(value) {
100
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
101
+ if (!PAIRING_CODE_HASH_PATTERN.test(raw))
102
+ return "";
103
+ return raw;
104
+ }
105
+ function normalizePairingCodeValue(value) {
106
+ return value.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
107
+ }
108
+ function hashPairingCode(value) {
109
+ return createHash("sha256").update(normalizePairingCodeValue(value)).digest("hex");
110
+ }
111
+ function extractPairingCodeFromCommand(text) {
112
+ const trimmed = text.trim();
113
+ const match = trimmed.match(/^\/pair(?:@[A-Za-z0-9_]+)?\s+(.+)$/i);
114
+ if (!match?.[1])
115
+ return "";
116
+ return normalizePairingCodeValue(match[1]);
117
+ }
93
118
  function normalizeIdentityId(value) {
94
119
  const trimmed = (value ?? "").trim();
95
120
  if (!trimmed)
@@ -194,6 +219,22 @@ export async function startBridge(config, logger, reporter, deps = {}) {
194
219
  const app = config.slackApps.find((entry) => entry.id === id);
195
220
  return typeof app?.directory === "string" ? String(app.directory).trim() : "";
196
221
  };
222
+ const resolveTelegramIdentityAccess = (identityId) => {
223
+ const id = identityId.trim();
224
+ if (!id) {
225
+ return { access: "public", pairingCodeHash: "" };
226
+ }
227
+ const bot = config.telegramBots.find((entry) => entry.id === id);
228
+ if (!bot) {
229
+ return { access: "public", pairingCodeHash: "" };
230
+ }
231
+ const access = normalizeTelegramAccess(bot.access);
232
+ const pairingCodeHash = normalizePairingCodeHash(bot.pairingCodeHash);
233
+ if (access !== "private") {
234
+ return { access: "public", pairingCodeHash: "" };
235
+ }
236
+ return { access: "private", pairingCodeHash };
237
+ };
197
238
  const listIdentityConfigs = (channel) => {
198
239
  if (channel === "telegram") {
199
240
  return config.telegramBots.map((bot) => ({ id: bot.id, directory: (bot.directory ?? "").trim() }));
@@ -465,6 +506,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
465
506
  id: bot.id,
466
507
  enabled: bot.enabled !== false,
467
508
  running: adapters.has(adapterKey("telegram", bot.id)),
509
+ access: normalizeTelegramAccess(bot.access),
510
+ pairingRequired: normalizeTelegramAccess(bot.access) === "private",
468
511
  })),
469
512
  };
470
513
  },
@@ -477,6 +520,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
477
520
  throw new Error("identity id 'env' is reserved");
478
521
  const enabled = input.enabled !== false;
479
522
  const directoryInput = typeof input.directory === "string" ? input.directory.trim() : "";
523
+ const requestedAccess = typeof input.access === "string" && input.access.trim() ? normalizeTelegramAccess(input.access) : undefined;
524
+ const requestedPairingCodeHash = normalizePairingCodeHash(input.pairingCodeHash);
480
525
  // Persist to config file.
481
526
  const { config: current } = readConfigFile(config.configPath);
482
527
  const telegram = current.channels?.telegram;
@@ -495,10 +540,36 @@ export async function startBridge(config, logger, reporter, deps = {}) {
495
540
  found = true;
496
541
  const existingDirectory = typeof record.directory === "string" ? record.directory.trim() : "";
497
542
  const directory = directoryInput || existingDirectory;
498
- nextBots.push({ id, token, enabled, ...(directory ? { directory } : {}) });
543
+ const existingAccess = normalizeTelegramAccess(record.access);
544
+ const existingPairingCodeHash = normalizePairingCodeHash(record.pairingCodeHash);
545
+ const access = requestedAccess ?? existingAccess;
546
+ const pairingCodeHash = access === "private" ? requestedPairingCodeHash || existingPairingCodeHash : "";
547
+ if (access === "private" && !pairingCodeHash) {
548
+ throw new Error("pairingCodeHash is required when Telegram access is private");
549
+ }
550
+ nextBots.push({
551
+ id,
552
+ token,
553
+ enabled,
554
+ ...(directory ? { directory } : {}),
555
+ access,
556
+ ...(access === "private" ? { pairingCodeHash } : {}),
557
+ });
499
558
  }
500
559
  if (!found) {
501
- nextBots.push({ id, token, enabled, ...(directoryInput ? { directory: directoryInput } : {}) });
560
+ const access = requestedAccess ?? "public";
561
+ const pairingCodeHash = access === "private" ? requestedPairingCodeHash : "";
562
+ if (access === "private" && !pairingCodeHash) {
563
+ throw new Error("pairingCodeHash is required when Telegram access is private");
564
+ }
565
+ nextBots.push({
566
+ id,
567
+ token,
568
+ enabled,
569
+ ...(directoryInput ? { directory: directoryInput } : {}),
570
+ access,
571
+ ...(access === "private" ? { pairingCodeHash } : {}),
572
+ });
502
573
  }
503
574
  const next = {
504
575
  ...current,
@@ -516,13 +587,41 @@ export async function startBridge(config, logger, reporter, deps = {}) {
516
587
  config.configFile = next;
517
588
  // Update runtime identity list.
518
589
  const existingIdx = config.telegramBots.findIndex((bot) => bot.id === id);
590
+ let runtimeAccess = requestedAccess ?? "public";
591
+ let runtimePairingCodeHash = requestedPairingCodeHash;
519
592
  if (existingIdx >= 0) {
520
593
  const prev = config.telegramBots[existingIdx];
521
594
  const nextDirectory = directoryInput || prev?.directory || undefined;
522
- config.telegramBots[existingIdx] = { id, token, enabled, ...(nextDirectory ? { directory: String(nextDirectory).trim() } : {}) };
595
+ const prevAccess = normalizeTelegramAccess(prev?.access);
596
+ const prevPairingCodeHash = normalizePairingCodeHash(prev?.pairingCodeHash);
597
+ runtimeAccess = requestedAccess ?? prevAccess;
598
+ runtimePairingCodeHash = runtimeAccess === "private" ? requestedPairingCodeHash || prevPairingCodeHash : "";
599
+ if (runtimeAccess === "private" && !runtimePairingCodeHash) {
600
+ throw new Error("pairingCodeHash is required when Telegram access is private");
601
+ }
602
+ config.telegramBots[existingIdx] = {
603
+ id,
604
+ token,
605
+ enabled,
606
+ ...(nextDirectory ? { directory: String(nextDirectory).trim() } : {}),
607
+ access: runtimeAccess,
608
+ ...(runtimeAccess === "private" ? { pairingCodeHash: runtimePairingCodeHash } : {}),
609
+ };
523
610
  }
524
611
  else {
525
- config.telegramBots.push({ id, token, enabled, ...(directoryInput ? { directory: directoryInput } : {}) });
612
+ runtimeAccess = requestedAccess ?? "public";
613
+ runtimePairingCodeHash = runtimeAccess === "private" ? requestedPairingCodeHash : "";
614
+ if (runtimeAccess === "private" && !runtimePairingCodeHash) {
615
+ throw new Error("pairingCodeHash is required when Telegram access is private");
616
+ }
617
+ config.telegramBots.push({
618
+ id,
619
+ token,
620
+ enabled,
621
+ ...(directoryInput ? { directory: directoryInput } : {}),
622
+ access: runtimeAccess,
623
+ ...(runtimeAccess === "private" ? { pairingCodeHash: runtimePairingCodeHash } : {}),
624
+ });
526
625
  }
527
626
  // Start/stop adapter.
528
627
  const key = adapterKey("telegram", id);
@@ -537,7 +636,13 @@ export async function startBridge(config, logger, reporter, deps = {}) {
537
636
  }
538
637
  adapters.delete(key);
539
638
  }
540
- return { id, enabled: false, applied: true };
639
+ return {
640
+ id,
641
+ enabled: false,
642
+ access: runtimeAccess,
643
+ pairingRequired: runtimeAccess === "private",
644
+ applied: true,
645
+ };
541
646
  }
542
647
  if (existing) {
543
648
  try {
@@ -548,7 +653,16 @@ export async function startBridge(config, logger, reporter, deps = {}) {
548
653
  }
549
654
  adapters.delete(key);
550
655
  }
551
- const base = createTelegramAdapter({ id, token, enabled, ...(directoryInput ? { directory: directoryInput } : {}) }, config, logger, handleInbound);
656
+ const base = createTelegramAdapter({
657
+ id,
658
+ token,
659
+ enabled,
660
+ ...(directoryInput ? { directory: directoryInput } : {}),
661
+ access: runtimeAccess,
662
+ ...(runtimeAccess === "private" && runtimePairingCodeHash
663
+ ? { pairingCodeHash: runtimePairingCodeHash }
664
+ : {}),
665
+ }, config, logger, handleInbound);
552
666
  const adapter = { ...base, key };
553
667
  adapters.set(key, adapter);
554
668
  const startResult = await startAdapterBounded(adapter, {
@@ -559,12 +673,32 @@ export async function startBridge(config, logger, reporter, deps = {}) {
559
673
  },
560
674
  });
561
675
  if (startResult.status === "timeout") {
562
- return { id, enabled: true, applied: false, starting: true };
676
+ return {
677
+ id,
678
+ enabled: true,
679
+ access: runtimeAccess,
680
+ pairingRequired: runtimeAccess === "private",
681
+ applied: false,
682
+ starting: true,
683
+ };
563
684
  }
564
685
  if (startResult.status === "error") {
565
- return { id, enabled: true, applied: false, error: String(startResult.error) };
686
+ return {
687
+ id,
688
+ enabled: true,
689
+ access: runtimeAccess,
690
+ pairingRequired: runtimeAccess === "private",
691
+ applied: false,
692
+ error: String(startResult.error),
693
+ };
566
694
  }
567
- return { id, enabled: true, applied: true };
695
+ return {
696
+ id,
697
+ enabled: true,
698
+ access: runtimeAccess,
699
+ pairingRequired: runtimeAccess === "private",
700
+ applied: true,
701
+ };
568
702
  },
569
703
  deleteTelegramIdentity: async (rawId) => {
570
704
  const id = normalizeIdentityId(rawId);
@@ -1135,6 +1269,50 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1135
1269
  await adapter.sendText(peerId, chunk);
1136
1270
  }
1137
1271
  }
1272
+ async function handleTelegramPairingGate(input) {
1273
+ const access = resolveTelegramIdentityAccess(input.identityId);
1274
+ if (access.access !== "private") {
1275
+ return "continue";
1276
+ }
1277
+ const hasKnownBinding = Boolean(input.bindingDirectory?.trim() || input.sessionDirectory?.trim());
1278
+ if (hasKnownBinding) {
1279
+ return "continue";
1280
+ }
1281
+ const pairingCode = extractPairingCodeFromCommand(input.text);
1282
+ if (!pairingCode) {
1283
+ await sendText("telegram", input.identityId, input.peerId, "This Telegram bot is private. Ask your OpenWork host for the pairing code, then send /pair <code>.", { kind: "system" });
1284
+ return "handled";
1285
+ }
1286
+ if (!access.pairingCodeHash) {
1287
+ await sendText("telegram", input.identityId, input.peerId, "This Telegram bot is private but missing a pairing code. Ask your OpenWork host to reconnect it.", { kind: "system" });
1288
+ return "handled";
1289
+ }
1290
+ if (hashPairingCode(pairingCode) !== access.pairingCodeHash) {
1291
+ await sendText("telegram", input.identityId, input.peerId, "Invalid pairing code. Try again with /pair <code>.", {
1292
+ kind: "system",
1293
+ });
1294
+ return "handled";
1295
+ }
1296
+ const identityDirectory = resolveIdentityDirectory("telegram", input.identityId);
1297
+ const boundDirectoryCandidate = identityDirectory || defaultDirectory;
1298
+ const hasExplicitBinding = Boolean(identityDirectory);
1299
+ if (!boundDirectoryCandidate || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectoryCandidate))) {
1300
+ await sendText("telegram", input.identityId, input.peerId, "No workspace directory configured for this identity. Ask your OpenWork host to set it, or reply with /dir <path>.", { kind: "system" });
1301
+ return "handled";
1302
+ }
1303
+ const scopedBound = resolveScopedDirectory(boundDirectoryCandidate);
1304
+ if (!scopedBound.ok) {
1305
+ await sendText("telegram", input.identityId, input.peerId, scopedBound.error, { kind: "system" });
1306
+ return "handled";
1307
+ }
1308
+ const boundDirectory = scopedBound.directory;
1309
+ store.upsertBinding("telegram", input.identityId, input.peerKey, boundDirectory);
1310
+ store.deleteSession("telegram", input.identityId, input.peerKey);
1311
+ ensureEventSubscription(boundDirectory);
1312
+ logger.info({ channel: "telegram", identityId: input.identityId, peerId: input.peerKey, directory: boundDirectory }, "telegram private identity paired");
1313
+ await sendText("telegram", input.identityId, input.peerId, "Pairing successful. This chat is now linked to your worker.", { kind: "system" });
1314
+ return "handled";
1315
+ }
1138
1316
  async function handleInbound(message) {
1139
1317
  const adapter = adapters.get(adapterKey(message.channel, message.identityId));
1140
1318
  if (!adapter)
@@ -1151,8 +1329,24 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1151
1329
  }, "inbound received");
1152
1330
  logger.info({ channel: inbound.channel, identityId: inbound.identityId, peerId: inbound.peerId, length: inbound.text.length }, "received message");
1153
1331
  const peerKey = inbound.peerId;
1154
- // Handle bot commands
1155
1332
  const trimmedText = inbound.text.trim();
1333
+ let binding = store.getBinding(inbound.channel, inbound.identityId, peerKey);
1334
+ let session = store.getSession(inbound.channel, inbound.identityId, peerKey);
1335
+ if (inbound.channel === "telegram") {
1336
+ const pairingGate = await handleTelegramPairingGate({
1337
+ identityId: inbound.identityId,
1338
+ peerKey,
1339
+ peerId: inbound.peerId,
1340
+ text: trimmedText,
1341
+ ...(binding?.directory?.trim() ? { bindingDirectory: binding.directory } : {}),
1342
+ ...(session?.directory?.trim() ? { sessionDirectory: session.directory ?? undefined } : {}),
1343
+ });
1344
+ if (pairingGate === "handled")
1345
+ return;
1346
+ binding = store.getBinding(inbound.channel, inbound.identityId, peerKey);
1347
+ session = store.getSession(inbound.channel, inbound.identityId, peerKey);
1348
+ }
1349
+ // Handle bot commands
1156
1350
  if (trimmedText.startsWith("/")) {
1157
1351
  const commandHandled = await handleCommand(inbound.channel, inbound.identityId, peerKey, inbound.peerId, trimmedText);
1158
1352
  if (commandHandled)
@@ -1165,8 +1359,6 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1165
1359
  text: inbound.text,
1166
1360
  fromMe: inbound.fromMe,
1167
1361
  });
1168
- const binding = store.getBinding(inbound.channel, inbound.identityId, peerKey);
1169
- const session = store.getSession(inbound.channel, inbound.identityId, peerKey);
1170
1362
  const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId);
1171
1363
  const boundDirectoryCandidate = binding?.directory?.trim() || session?.directory?.trim() || identityDirectory || defaultDirectory;
1172
1364
  const hasExplicitBinding = Boolean(binding?.directory?.trim() || session?.directory?.trim() || identityDirectory);
@@ -1180,7 +1372,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1180
1372
  return;
1181
1373
  }
1182
1374
  const boundDirectory = scopedBound.directory;
1183
- if (!binding?.directory?.trim()) {
1375
+ const shouldAutoBind = !(inbound.channel === "telegram" && resolveTelegramIdentityAccess(inbound.identityId).access === "private");
1376
+ if (shouldAutoBind && !binding?.directory?.trim()) {
1184
1377
  store.upsertBinding(inbound.channel, inbound.identityId, peerKey, boundDirectory);
1185
1378
  }
1186
1379
  ensureEventSubscription(boundDirectory);
@@ -1353,6 +1546,28 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1353
1546
  logger.info({ channel, peerId: peerKey }, "session and model reset");
1354
1547
  return true;
1355
1548
  }
1549
+ if (command === "pair") {
1550
+ if (channel !== "telegram") {
1551
+ await sendText(channel, identityId, peerId, "Pairing is only available for Telegram private bots.", {
1552
+ kind: "system",
1553
+ });
1554
+ return true;
1555
+ }
1556
+ const binding = store.getBinding(channel, identityId, peerKey);
1557
+ const session = store.getSession(channel, identityId, peerKey);
1558
+ const pairingGate = await handleTelegramPairingGate({
1559
+ identityId,
1560
+ peerKey,
1561
+ peerId,
1562
+ text,
1563
+ ...(binding?.directory?.trim() ? { bindingDirectory: binding.directory } : {}),
1564
+ ...(session?.directory?.trim() ? { sessionDirectory: session.directory ?? undefined } : {}),
1565
+ });
1566
+ if (pairingGate === "handled")
1567
+ return true;
1568
+ await sendText(channel, identityId, peerId, "This chat is already paired.", { kind: "system" });
1569
+ return true;
1570
+ }
1356
1571
  if (command === "dir" || command === "cd") {
1357
1572
  const next = args.join(" ").trim();
1358
1573
  if (!next) {
@@ -1385,7 +1600,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1385
1600
  }
1386
1601
  // /help command
1387
1602
  if (command === "help") {
1388
- 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`;
1603
+ const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/pair <code> - pair this chat with a private Telegram bot\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`;
1389
1604
  await sendText(channel, identityId, peerId, helpText, { kind: "system" });
1390
1605
  return true;
1391
1606
  }
package/dist/config.js CHANGED
@@ -73,6 +73,17 @@ function normalizeId(value) {
73
73
  const safe = trimmed.replace(/[^a-zA-Z0-9_.-]+/g, "-");
74
74
  return safe.replace(/^-+|-+$/g, "").slice(0, 48) || "default";
75
75
  }
76
+ const PAIRING_CODE_HASH_PATTERN = /^[a-f0-9]{64}$/;
77
+ function normalizeTelegramAccess(value) {
78
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
79
+ return raw === "private" ? "private" : "public";
80
+ }
81
+ function normalizePairingCodeHash(value) {
82
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
83
+ if (!PAIRING_CODE_HASH_PATTERN.test(raw))
84
+ return "";
85
+ return raw;
86
+ }
76
87
  function coerceTelegramBots(file) {
77
88
  const telegram = file.channels?.telegram;
78
89
  const bots = Array.isArray(telegram?.bots) ? telegram.bots : [];
@@ -86,11 +97,14 @@ function coerceTelegramBots(file) {
86
97
  continue;
87
98
  const id = normalizeId(typeof record.id === "string" ? record.id : "default");
88
99
  const directory = typeof record.directory === "string" ? record.directory.trim() : "";
100
+ const access = normalizeTelegramAccess(record.access);
101
+ const pairingCodeHash = normalizePairingCodeHash(record.pairingCodeHash);
89
102
  normalized.push({
90
103
  id,
91
104
  token,
92
105
  enabled: record.enabled === undefined ? true : record.enabled === true,
93
106
  ...(directory ? { directory } : {}),
107
+ ...(access === "private" ? { access, ...(pairingCodeHash ? { pairingCodeHash } : {}) } : { access: "public" }),
94
108
  });
95
109
  }
96
110
  if (normalized.length)
package/dist/health.js CHANGED
@@ -155,6 +155,8 @@ export async function startHealthServer(port, getStatus, logger, handlers = {})
155
155
  const token = typeof payload.token === "string" ? payload.token.trim() : "";
156
156
  const id = typeof payload.id === "string" ? payload.id.trim() : undefined;
157
157
  const directory = typeof payload.directory === "string" ? payload.directory.trim() : undefined;
158
+ const access = typeof payload.access === "string" ? payload.access.trim() : undefined;
159
+ const pairingCodeHash = typeof payload.pairingCodeHash === "string" ? payload.pairingCodeHash.trim() : undefined;
158
160
  const enabled = payload.enabled === undefined ? undefined : payload.enabled === true || payload.enabled === "true";
159
161
  if (!token) {
160
162
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -166,14 +168,18 @@ export async function startHealthServer(port, getStatus, logger, handlers = {})
166
168
  token,
167
169
  ...(enabled === undefined ? {} : { enabled }),
168
170
  ...(directory ? { directory } : {}),
171
+ ...(access ? { access: access } : {}),
172
+ ...(pairingCodeHash ? { pairingCodeHash } : {}),
169
173
  });
170
174
  res.writeHead(200, { "Content-Type": "application/json" });
171
175
  res.end(JSON.stringify({ ok: true, telegram: result }));
172
176
  return;
173
177
  }
174
178
  catch (error) {
175
- res.writeHead(500, { "Content-Type": "application/json" });
176
- res.end(JSON.stringify({ ok: false, error: String(error) }));
179
+ const statusRaw = error?.status;
180
+ const status = typeof statusRaw === "number" && statusRaw >= 400 && statusRaw < 600 ? statusRaw : 500;
181
+ res.writeHead(status, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify({ ok: false, error: String(error instanceof Error ? error.message : error) }));
177
183
  return;
178
184
  }
179
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-router",
3
- "version": "0.11.114",
3
+ "version": "0.11.115",
4
4
  "description": "opencode-router: Slack + Telegram bridge + directory routing for a running opencode server",
5
5
  "private": false,
6
6
  "type": "module",