@0xmaxma/claude-gateway 1.3.24 → 1.3.25

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.
@@ -150,35 +150,6 @@ async function verifyTelegramToken(token) {
150
150
  return null;
151
151
  }
152
152
  }
153
- /**
154
- * Non-blocking Telegram getUpdates check.
155
- * Returns match details + nextOffset on code match, { nextOffset } on no match, null on error.
156
- * Always advancing the offset ensures we never re-process seen messages across poll calls.
157
- */
158
- async function checkTelegramCode(token, expectedCode, offset) {
159
- try {
160
- const url = `${TELEGRAM_API_BASE}/bot${token}/getUpdates?offset=${offset}&timeout=0&limit=100`;
161
- const res = await fetch(url);
162
- const data = await res.json();
163
- if (!data.ok)
164
- return null;
165
- let nextOffset = offset;
166
- for (const upd of data.result) {
167
- nextOffset = upd.update_id + 1;
168
- if (upd.message?.chat.type === 'private' &&
169
- upd.message.text?.trim().toUpperCase() === expectedCode.toUpperCase()) {
170
- const chatId = String(upd.message.chat.id);
171
- const senderId = upd.message.from ? String(upd.message.from.id) : chatId;
172
- return { found: true, chatId, senderId, nextOffset };
173
- }
174
- }
175
- return { found: false, nextOffset };
176
- }
177
- catch (err) {
178
- console.error('[wizard/verify] getUpdates failed:', err.message);
179
- return null;
180
- }
181
- }
182
153
  // ---------------------------------------------------------------------------
183
154
  // In-memory rate limiter for media uploads (per API key)
184
155
  // ---------------------------------------------------------------------------
@@ -506,6 +477,18 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
506
477
  telegram_token_preview: cfg.telegram?.botToken ? maskToken(cfg.telegram.botToken) : null,
507
478
  discord_token_preview: cfg.discord?.botToken ? maskToken(cfg.discord.botToken) : null,
508
479
  telegram_dm_policy: cfg.telegram?.botToken ? readTelegramAccess(id).dmPolicy : null,
480
+ // Orthogonal pairing toggle (mirrors line_pairing below). Absent ⇒ on.
481
+ telegram_pairing: cfg.telegram?.botToken ? readTelegramAccess(id).pairing : null,
482
+ // Group tier (mirrors line_group_* below). Null when Telegram not connected.
483
+ telegram_group_policy: cfg.telegram?.botToken ? readTelegramAccess(id).groupPolicy : null,
484
+ telegram_group_allowlist: cfg.telegram?.botToken ? readTelegramAccess(id).groupAllowlist : null,
485
+ telegram_require_mention: cfg.telegram?.botToken ? readTelegramAccess(id).requireMention : null,
486
+ discord_dm_policy: cfg.discord?.botToken ? readDiscordAccess(id).dmPolicy : null,
487
+ discord_pairing: cfg.discord?.botToken ? readDiscordAccess(id).pairing : null,
488
+ // Discord approves at guild level — guildAllowlist IS the group allowlist.
489
+ discord_group_policy: cfg.discord?.botToken ? readDiscordAccess(id).groupPolicy : null,
490
+ discord_guild_allowlist: cfg.discord?.botToken ? readDiscordAccess(id).guildAllowlist : null,
491
+ discord_require_mention: cfg.discord?.botToken ? readDiscordAccess(id).requireMention : null,
509
492
  line_connected: !!cfg.line?.channelSecret,
510
493
  line_token_preview: cfg.line?.channelAccessToken ? maskToken(cfg.line.channelAccessToken) : null,
511
494
  line_webhook_path: cfg.line?.channelSecret ? `/webhooks/line/${id}` : null,
@@ -668,15 +651,54 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
668
651
  try {
669
652
  const raw = fs.readFileSync(accessFile, 'utf8');
670
653
  const parsed = JSON.parse(raw);
654
+ // Migrate the legacy 4-value dmPolicy (pairing folded in) to the split
655
+ // model. SECURITY: a legacy 'allowlist' file was locked down → pairing:false
656
+ // (an absent pairing on an existing file means pre-split); 'pairing' → mint on.
657
+ // Mirrors migrateAccess() in mcp/tools/telegram/pure.ts — keep in sync.
658
+ const legacy = parsed.dmPolicy;
659
+ let dmPolicy;
660
+ let pairing;
661
+ if (legacy === 'pairing') {
662
+ dmPolicy = 'allowlist';
663
+ pairing = true;
664
+ }
665
+ else {
666
+ dmPolicy = legacy ?? 'allowlist';
667
+ pairing = parsed.pairing ?? false;
668
+ }
669
+ // Group tier: flatten legacy per-group `groups` map to a flat allowlist,
670
+ // behavior-preserving (groups were closed-by-default). Mirrors pure.ts.
671
+ // A group's per-user `allowFrom` override has no flat-model equivalent,
672
+ // but it's a real restriction — preserve it (mirrors
673
+ // deriveLegacyGroupAllowFrom in pure.ts, keep in sync) rather than
674
+ // silently dropping it and widening the group to every member.
675
+ const groupAllowlist = parsed.groupAllowlist ?? Object.keys(parsed.groups ?? {});
676
+ const groupPolicy = parsed.groupPolicy ?? 'allowlist';
677
+ const requireMention = parsed.requireMention ?? true;
678
+ let legacyGroupAllowFrom = parsed.legacyGroupAllowFrom;
679
+ if (!legacyGroupAllowFrom && parsed.groups) {
680
+ const derived = {};
681
+ for (const [groupId, g] of Object.entries(parsed.groups)) {
682
+ if (g.allowFrom && g.allowFrom.length > 0)
683
+ derived[groupId] = [...g.allowFrom];
684
+ }
685
+ if (Object.keys(derived).length > 0)
686
+ legacyGroupAllowFrom = derived;
687
+ }
671
688
  return {
672
- dmPolicy: parsed.dmPolicy ?? 'pairing',
689
+ dmPolicy,
690
+ pairing,
673
691
  allowFrom: parsed.allowFrom ?? [],
674
- groups: parsed.groups ?? {},
692
+ groupPolicy,
693
+ groupAllowlist,
694
+ requireMention,
695
+ legacyGroupAllowFrom,
675
696
  pending: parsed.pending ?? {},
676
697
  };
677
698
  }
678
699
  catch {
679
- return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} };
700
+ // Brand-new agent (no file): closed base + pairing on (capture owner id).
701
+ return { dmPolicy: 'allowlist', pairing: true, allowFrom: [], groupPolicy: 'allowlist', groupAllowlist: [], requireMention: true, pending: {} };
680
702
  }
681
703
  }
682
704
  function writeTelegramAccess(agentId, access) {
@@ -689,6 +711,70 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
689
711
  throw new Error(`Failed to write Telegram access config: ${err.message}`);
690
712
  }
691
713
  }
714
+ function getDiscordStateDir(agentId) {
715
+ const agentsBase = getAgentsBaseDir();
716
+ const cfg = agentConfigs.get(agentId);
717
+ const workspace = cfg?.workspace
718
+ ? (cfg.workspace.startsWith('~') ? path.join(os.homedir(), cfg.workspace.slice(1)) : cfg.workspace)
719
+ : path.join(agentsBase, agentId, 'workspace');
720
+ return path.join(workspace, '.discord-state');
721
+ }
722
+ function readDiscordAccess(agentId) {
723
+ const accessFile = path.join(getDiscordStateDir(agentId), 'access.json');
724
+ try {
725
+ const raw = fs.readFileSync(accessFile, 'utf8');
726
+ const parsed = JSON.parse(raw);
727
+ // Migrate the legacy fused dmPolicy ('pairing' folded pairing in) to the
728
+ // split model. SECURITY: a legacy 'allowlist' file was locked down →
729
+ // pairing:false (an absent pairing on an existing file means pre-split);
730
+ // 'pairing' → mint on. Mirrors migrateAccess() in
731
+ // mcp/tools/discord/access.ts — keep in sync.
732
+ const legacy = parsed.dmPolicy;
733
+ let dmPolicy;
734
+ let pairing;
735
+ if (legacy === 'pairing') {
736
+ dmPolicy = 'allowlist';
737
+ pairing = true;
738
+ }
739
+ else {
740
+ dmPolicy = legacy ?? 'allowlist';
741
+ pairing = parsed.pairing ?? false;
742
+ }
743
+ // Guild tier migration is behavior-preserving: today an empty
744
+ // guildAllowlist means "deliver to all guilds" → derive 'open' when empty,
745
+ // 'allowlist' when non-empty; requireMention defaults false for existing
746
+ // files (no prior mention gate). Mirrors discord/access.ts migrateAccess.
747
+ const guildAllowlist = parsed.guildAllowlist ?? [];
748
+ const groupPolicy = parsed.groupPolicy
749
+ ?? (guildAllowlist.length > 0 ? 'allowlist' : 'open');
750
+ const requireMention = parsed.requireMention ?? false;
751
+ return {
752
+ dmPolicy,
753
+ pairing,
754
+ allowFrom: parsed.allowFrom ?? [],
755
+ groupPolicy,
756
+ requireMention,
757
+ guildAllowlist,
758
+ channelAllowlist: parsed.channelAllowlist ?? [],
759
+ roleAllowlist: parsed.roleAllowlist ?? [],
760
+ pending: parsed.pending ?? {},
761
+ };
762
+ }
763
+ catch {
764
+ // Brand-new agent (no file): secure defaults (mirrors defaultAccess()).
765
+ return { dmPolicy: 'allowlist', pairing: true, allowFrom: [], groupPolicy: 'allowlist', requireMention: true, guildAllowlist: [], channelAllowlist: [], roleAllowlist: [], pending: {} };
766
+ }
767
+ }
768
+ function writeDiscordAccess(agentId, access) {
769
+ const stateDir = getDiscordStateDir(agentId);
770
+ try {
771
+ fs.mkdirSync(stateDir, { recursive: true });
772
+ fs.writeFileSync(path.join(stateDir, 'access.json'), JSON.stringify(access, null, 2));
773
+ }
774
+ catch (err) {
775
+ throw new Error(`Failed to write Discord access config: ${err.message}`);
776
+ }
777
+ }
692
778
  /**
693
779
  * POST /api/v1/agents/wizard/start
694
780
  * Start wizard: call Claude to generate workspace files, return wizardId + preview.
@@ -988,101 +1074,68 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
988
1074
  return;
989
1075
  }
990
1076
  }
991
- const pairingCode = (0, crypto_1.randomBytes)(3).toString('hex').toUpperCase();
992
- wizard_state_1.wizardStore.update(wizardId, {
993
- step: 'pairing',
994
- channel: channel,
995
- botToken,
996
- pairingCode,
997
- updateOffset: 0,
998
- });
999
- res.json({
1000
- channel,
1001
- botName,
1002
- pairingCode,
1003
- instruction: `Send this code as a DM to ${botName} to complete pairing`,
1004
- });
1005
- });
1006
- /**
1007
- * POST /api/v1/agents/wizard/:wizardId/channel/verify
1008
- * Poll for pairing code. Client polls this endpoint until { success: true }.
1009
- */
1010
- router.post('/v1/agents/wizard/:wizardId/channel/verify', auth, async (req, res) => {
1011
- const apiKey = req.apiKey;
1012
- if (!(0, auth_1.isAdmin)(apiKey)) {
1013
- res.status(403).json({ error: 'Admin key required' });
1014
- return;
1015
- }
1077
+ // Token-only connect: persist the bot token to the agent config now. The
1078
+ // wizard no longer mints/relays a pairing code — pairing is incoming-first
1079
+ // and happens later on the edit-page Channels card (user DMs the bot → code
1080
+ // lands in Pending → admin approves). This mirrors the LINE flow.
1016
1081
  if (!configPath) {
1017
1082
  res.status(501).json({ error: 'Agent management not available (no configPath)' });
1018
1083
  return;
1019
1084
  }
1020
- const { wizardId } = req.params;
1021
- const wizard = wizard_state_1.wizardStore.get(wizardId);
1022
- if (!wizard) {
1023
- res.status(404).json({ error: 'Wizard not found or expired' });
1024
- return;
1025
- }
1026
- if (wizard.step !== 'pairing') {
1027
- res.status(409).json({ error: `Expected step 'pairing', got '${wizard.step}'` });
1028
- return;
1029
- }
1030
- if (wizard.channel !== 'telegram') {
1031
- res.status(501).json({ error: 'Discord pairing verification via API is not yet supported' });
1032
- return;
1033
- }
1034
- const result = await checkTelegramCode(wizard.botToken, wizard.pairingCode, wizard.updateOffset ?? 0);
1035
- // Always advance offset on non-error responses to avoid re-processing seen messages
1036
- if (!result) {
1037
- // Network/API error — keep current offset; client may retry
1038
- res.json({ success: false, pending: true });
1039
- return;
1040
- }
1041
- if (!result.found) {
1042
- wizard_state_1.wizardStore.update(wizardId, { updateOffset: result.nextOffset });
1043
- res.json({ success: false, pending: true });
1044
- return;
1045
- }
1046
- // Code matched — commit config first, then advance offset so a retry can still succeed
1047
- // if the config write failed mid-way
1048
1085
  try {
1049
1086
  await writeAgentsToConfig(configPath, (agents) => {
1050
1087
  const agent = agents.find((a) => a.id === wizard.agentId);
1051
- if (agent)
1052
- agent.telegram = { botToken: wizard.botToken };
1088
+ if (agent) {
1089
+ if (channel === 'telegram')
1090
+ agent.telegram = { botToken };
1091
+ else
1092
+ agent.discord = { botToken };
1093
+ }
1053
1094
  });
1054
1095
  }
1055
1096
  catch (err) {
1056
1097
  res.status(500).json({ error: `Failed to update config: ${err.message}` });
1057
1098
  return;
1058
1099
  }
1059
- wizard_state_1.wizardStore.update(wizardId, { updateOffset: result.nextOffset, step: 'complete' });
1060
- const agentsBase = getAgentsBaseDir();
1061
- const telegramStateDir = path.join(agentsBase, wizard.agentId, 'workspace', '.telegram-state');
1062
- try {
1063
- fs.mkdirSync(telegramStateDir, { recursive: true });
1064
- const access = JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [result.senderId], groups: {}, pending: {} }, null, 2);
1065
- await fsp.writeFile(path.join(telegramStateDir, 'access.json'), access, { mode: 0o600 });
1066
- }
1067
- catch (err) {
1068
- console.error(`[wizard] access.json write failed for '${wizard.agentId}': ${err.message}`);
1069
- }
1070
- try {
1071
- await fetch(`${TELEGRAM_API_BASE}/bot${wizard.botToken}/sendMessage`, {
1072
- method: 'POST',
1073
- headers: { 'Content-Type': 'application/json' },
1074
- body: JSON.stringify({ chat_id: result.chatId, text: "You're connected! Send me a message to get started." }),
1075
- });
1100
+ // Seed a secure access.json immediately for a brand-new Discord connection.
1101
+ // Without this, the receiver's env-derived fallback (DISCORD_GUILD_ALLOWLIST
1102
+ // empty groupPolicy 'open') answers in any server with no pairing/approval —
1103
+ // only DMs were ever gated. Skip if a file already exists (don't clobber a
1104
+ // prior connect/reconnect).
1105
+ if (channel === 'discord') {
1106
+ const accessFile = path.join(getDiscordStateDir(wizard.agentId), 'access.json');
1107
+ if (!fs.existsSync(accessFile)) {
1108
+ try {
1109
+ writeDiscordAccess(wizard.agentId, {
1110
+ dmPolicy: 'allowlist', pairing: true, allowFrom: [],
1111
+ groupPolicy: 'allowlist', requireMention: true,
1112
+ guildAllowlist: [], channelAllowlist: [], roleAllowlist: [], pending: {},
1113
+ });
1114
+ }
1115
+ catch { /* non-fatal receiver still has a (less safe) env fallback */ }
1116
+ }
1076
1117
  }
1077
- catch { /* non-fatal */ }
1078
- // Hot-start the receiver so the agent responds immediately without a gateway restart
1118
+ wizard_state_1.wizardStore.update(wizardId, {
1119
+ step: 'complete',
1120
+ channel: channel,
1121
+ botToken,
1122
+ });
1123
+ // Hot-start the receiver so the bot comes online immediately without a
1124
+ // gateway restart. The first DM from the owner then produces a pairing code
1125
+ // they approve on the edit-page Channels card.
1079
1126
  const runner = agentRunners.get(wizard.agentId);
1080
1127
  if (runner) {
1081
- runner.updateAgentConfig({ ...runner.getAgentConfig(), telegram: { botToken: wizard.botToken } });
1082
- runner.startTelegramReceiver();
1128
+ const base = runner.getAgentConfig();
1129
+ if (channel === 'telegram') {
1130
+ runner.updateAgentConfig({ ...base, telegram: { botToken } });
1131
+ runner.startTelegramReceiver();
1132
+ }
1133
+ else {
1134
+ runner.updateAgentConfig({ ...base, discord: { botToken } });
1135
+ runner.startDiscordReceiver();
1136
+ }
1083
1137
  }
1084
- wizard_state_1.wizardStore.delete(wizardId);
1085
- res.json({ success: true, agentId: wizard.agentId });
1138
+ res.json({ channel, botName, connected: true });
1086
1139
  });
1087
1140
  /**
1088
1141
  * POST /api/v1/agents/wizard/:wizardId/complete
@@ -1342,7 +1395,24 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1342
1395
  if (discord_bot_token !== undefined) {
1343
1396
  const token = typeof discord_bot_token === 'string' ? discord_bot_token.trim() : null;
1344
1397
  if (token) {
1398
+ const isNewConnection = !cfg.discord?.botToken;
1345
1399
  cfg.discord = { ...(cfg.discord ?? {}), botToken: token };
1400
+ // Seed a secure access.json for a brand-new connection — see matching
1401
+ // comment on the wizard /channel handler for why this can't be left to
1402
+ // the receiver's env-derived fallback.
1403
+ if (isNewConnection) {
1404
+ const accessFile = path.join(getDiscordStateDir(agentId), 'access.json');
1405
+ if (!fs.existsSync(accessFile)) {
1406
+ try {
1407
+ writeDiscordAccess(agentId, {
1408
+ dmPolicy: 'allowlist', pairing: true, allowFrom: [],
1409
+ groupPolicy: 'allowlist', requireMention: true,
1410
+ guildAllowlist: [], channelAllowlist: [], roleAllowlist: [], pending: {},
1411
+ });
1412
+ }
1413
+ catch { /* non-fatal — receiver still has a (less safe) env fallback */ }
1414
+ }
1415
+ }
1346
1416
  // Hot-start receiver if not already running
1347
1417
  const runner = agentRunners.get(agentId);
1348
1418
  if (runner) {
@@ -1429,6 +1499,15 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1429
1499
  telegram_token_preview: cfg.telegram?.botToken ? maskToken(cfg.telegram.botToken) : null,
1430
1500
  discord_token_preview: cfg.discord?.botToken ? maskToken(cfg.discord.botToken) : null,
1431
1501
  telegram_dm_policy: cfg.telegram?.botToken ? readTelegramAccess(agentId).dmPolicy : null,
1502
+ telegram_pairing: cfg.telegram?.botToken ? readTelegramAccess(agentId).pairing : null,
1503
+ telegram_group_policy: cfg.telegram?.botToken ? readTelegramAccess(agentId).groupPolicy : null,
1504
+ telegram_group_allowlist: cfg.telegram?.botToken ? readTelegramAccess(agentId).groupAllowlist : null,
1505
+ telegram_require_mention: cfg.telegram?.botToken ? readTelegramAccess(agentId).requireMention : null,
1506
+ discord_dm_policy: cfg.discord?.botToken ? readDiscordAccess(agentId).dmPolicy : null,
1507
+ discord_pairing: cfg.discord?.botToken ? readDiscordAccess(agentId).pairing : null,
1508
+ discord_group_policy: cfg.discord?.botToken ? readDiscordAccess(agentId).groupPolicy : null,
1509
+ discord_guild_allowlist: cfg.discord?.botToken ? readDiscordAccess(agentId).guildAllowlist : null,
1510
+ discord_require_mention: cfg.discord?.botToken ? readDiscordAccess(agentId).requireMention : null,
1432
1511
  line_connected: !!cfg.line?.channelSecret,
1433
1512
  line_token_preview: cfg.line?.channelAccessToken ? maskToken(cfg.line.channelAccessToken) : null,
1434
1513
  line_webhook_path: cfg.line?.channelSecret ? `/webhooks/line/${agentId}` : null,
@@ -1461,12 +1540,15 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1461
1540
  catch { /* non-fatal cleanup */ }
1462
1541
  }
1463
1542
  const pending = Object.entries(access.pending)
1464
- .map(([code, p]) => ({ code, senderId: p.senderId, chatId: p.chatId, createdAt: p.createdAt, expiresAt: p.expiresAt }));
1543
+ .map(([code, p]) => ({ code, senderId: p.senderId, chatId: p.chatId, createdAt: p.createdAt, expiresAt: p.expiresAt, kind: p.kind ?? 'dm' }));
1465
1544
  res.json({ pending });
1466
1545
  });
1467
1546
  /**
1468
1547
  * POST /api/v1/agents/:agentId/telegram/approve
1469
- * Approve a pending Telegram pairing by code.
1548
+ * Approve a pending Telegram pairing by code. Kind-aware (mirrors LINE): a
1549
+ * 'group' knock moves its chatId into groupAllowlist (no approved/ handshake —
1550
+ * a group has no single recipient); a 'dm' knock allowlists the sender and
1551
+ * drops the approved/<senderId> file so the receiver sends a confirmation.
1470
1552
  */
1471
1553
  router.post('/v1/agents/:agentId/telegram/approve', auth, (req, res) => {
1472
1554
  const { agentId } = req.params;
@@ -1490,20 +1572,29 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1490
1572
  res.status(404).json({ error: 'Pairing code not found or expired' });
1491
1573
  return;
1492
1574
  }
1493
- if (!access.allowFrom.includes(entry.senderId))
1494
- access.allowFrom.push(entry.senderId);
1575
+ const isGroup = entry.kind === 'group';
1576
+ if (isGroup) {
1577
+ if (!access.groupAllowlist.includes(entry.chatId))
1578
+ access.groupAllowlist.push(entry.chatId);
1579
+ }
1580
+ else {
1581
+ if (!access.allowFrom.includes(entry.senderId))
1582
+ access.allowFrom.push(entry.senderId);
1583
+ }
1495
1584
  delete access.pending[code];
1496
1585
  try {
1497
1586
  writeTelegramAccess(agentId, access);
1498
- const approvedDir = path.join(getTelegramStateDir(agentId), 'approved');
1499
- fs.mkdirSync(approvedDir, { recursive: true });
1500
- fs.writeFileSync(path.join(approvedDir, entry.senderId), entry.chatId);
1587
+ if (!isGroup) {
1588
+ const approvedDir = path.join(getTelegramStateDir(agentId), 'approved');
1589
+ fs.mkdirSync(approvedDir, { recursive: true });
1590
+ fs.writeFileSync(path.join(approvedDir, entry.senderId), entry.chatId);
1591
+ }
1501
1592
  }
1502
1593
  catch (err) {
1503
1594
  res.status(500).json({ error: `Failed to approve pairing: ${err.message}` });
1504
1595
  return;
1505
1596
  }
1506
- res.json({ ok: true, senderId: entry.senderId });
1597
+ res.json({ ok: true, senderId: entry.senderId, groupId: isGroup ? entry.chatId : undefined });
1507
1598
  });
1508
1599
  /**
1509
1600
  * POST /api/v1/agents/:agentId/telegram/deny
@@ -1541,10 +1632,13 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1541
1632
  res.json({ ok: true });
1542
1633
  });
1543
1634
  /**
1544
- * POST /api/v1/agents/:agentId/telegram/init-pairing
1545
- * Write sentinel file so the next private message auto-approves sender as owner.
1635
+ * PATCH /api/v1/agents/:agentId/telegram/policy
1636
+ * Update the Telegram DM policy, the orthogonal pairing toggle, the group
1637
+ * policy, and/or the group mention gate.
1638
+ * Body: { dmPolicy?, pairing?, groupPolicy?: 'open'|'allowlist'|'disabled', requireMention?: boolean }.
1639
+ * At least one field must be present; each is applied only if provided.
1546
1640
  */
1547
- router.post('/v1/agents/:agentId/telegram/init-pairing', auth, (req, res) => {
1641
+ router.patch('/v1/agents/:agentId/telegram/policy', auth, (req, res) => {
1548
1642
  const { agentId } = req.params;
1549
1643
  const apiKey = req.apiKey;
1550
1644
  if (!(0, auth_1.isAdmin)(apiKey)) {
@@ -1555,67 +1649,37 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1555
1649
  res.status(404).json({ error: `Agent '${agentId}' not found` });
1556
1650
  return;
1557
1651
  }
1558
- const stateDir = getTelegramStateDir(agentId);
1559
- try {
1560
- fs.mkdirSync(stateDir, { recursive: true });
1561
- fs.writeFileSync(path.join(stateDir, 'awaiting-owner'), '');
1562
- }
1563
- catch (err) {
1564
- res.status(500).json({ error: `Failed to write sentinel: ${err.message}` });
1565
- return;
1566
- }
1567
- res.json({ ok: true });
1568
- });
1569
- /**
1570
- * GET /api/v1/agents/:agentId/telegram/pairing-status
1571
- * Returns whether init-pairing sentinel is still active.
1572
- */
1573
- router.get('/v1/agents/:agentId/telegram/pairing-status', auth, (req, res) => {
1574
- const { agentId } = req.params;
1575
- const apiKey = req.apiKey;
1576
- if (!(0, auth_1.isAdmin)(apiKey)) {
1577
- res.status(403).json({ error: 'Admin key required' });
1652
+ const { dmPolicy, pairing, groupPolicy, requireMention } = req.body;
1653
+ const valid = ['open', 'allowlist', 'disabled'];
1654
+ if (dmPolicy !== undefined && !valid.includes(dmPolicy)) {
1655
+ res.status(400).json({ error: `dmPolicy must be one of: ${valid.join(', ')}` });
1578
1656
  return;
1579
1657
  }
1580
- if (!agentConfigs.has(agentId)) {
1581
- res.status(404).json({ error: `Agent '${agentId}' not found` });
1658
+ if (pairing !== undefined && typeof pairing !== 'boolean') {
1659
+ res.status(400).json({ error: 'pairing must be a boolean' });
1582
1660
  return;
1583
1661
  }
1584
- const sentinelPath = path.join(getTelegramStateDir(agentId), 'awaiting-owner');
1585
- let waiting = false;
1586
- try {
1587
- const stat = fs.statSync(sentinelPath);
1588
- waiting = Date.now() - stat.mtimeMs < 10 * 60 * 1000;
1589
- if (!waiting)
1590
- fs.rmSync(sentinelPath, { force: true });
1591
- }
1592
- catch { /* ENOENT — not waiting */ }
1593
- const access = readTelegramAccess(agentId);
1594
- res.json({ waiting, allowFrom: access.allowFrom });
1595
- });
1596
- /**
1597
- * PATCH /api/v1/agents/:agentId/telegram/policy
1598
- * Update the Telegram DM policy for an agent.
1599
- */
1600
- router.patch('/v1/agents/:agentId/telegram/policy', auth, (req, res) => {
1601
- const { agentId } = req.params;
1602
- const apiKey = req.apiKey;
1603
- if (!(0, auth_1.isAdmin)(apiKey)) {
1604
- res.status(403).json({ error: 'Admin key required' });
1662
+ if (groupPolicy !== undefined && !valid.includes(groupPolicy)) {
1663
+ res.status(400).json({ error: `groupPolicy must be one of: ${valid.join(', ')}` });
1605
1664
  return;
1606
1665
  }
1607
- if (!agentConfigs.has(agentId)) {
1608
- res.status(404).json({ error: `Agent '${agentId}' not found` });
1666
+ if (requireMention !== undefined && typeof requireMention !== 'boolean') {
1667
+ res.status(400).json({ error: 'requireMention must be a boolean' });
1609
1668
  return;
1610
1669
  }
1611
- const { dmPolicy } = req.body;
1612
- const valid = ['open', 'pairing', 'allowlist', 'disabled'];
1613
- if (!dmPolicy || !valid.includes(dmPolicy)) {
1614
- res.status(400).json({ error: `dmPolicy must be one of: ${valid.join(', ')}` });
1670
+ if (dmPolicy === undefined && pairing === undefined && groupPolicy === undefined && requireMention === undefined) {
1671
+ res.status(400).json({ error: 'provide dmPolicy, pairing, groupPolicy and/or requireMention' });
1615
1672
  return;
1616
1673
  }
1617
1674
  const access = readTelegramAccess(agentId);
1618
- access.dmPolicy = dmPolicy;
1675
+ if (dmPolicy !== undefined)
1676
+ access.dmPolicy = dmPolicy;
1677
+ if (pairing !== undefined)
1678
+ access.pairing = pairing;
1679
+ if (groupPolicy !== undefined)
1680
+ access.groupPolicy = groupPolicy;
1681
+ if (requireMention !== undefined)
1682
+ access.requireMention = requireMention;
1619
1683
  try {
1620
1684
  writeTelegramAccess(agentId, access);
1621
1685
  }
@@ -1623,7 +1687,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1623
1687
  res.status(500).json({ error: `Failed to update policy: ${err.message}` });
1624
1688
  return;
1625
1689
  }
1626
- res.json({ ok: true, dmPolicy });
1690
+ res.json({ ok: true, dmPolicy: access.dmPolicy, pairing: access.pairing, groupPolicy: access.groupPolicy, requireMention: access.requireMention });
1627
1691
  });
1628
1692
  /**
1629
1693
  * GET /api/v1/agents/:agentId/telegram/allowlist
@@ -1713,6 +1777,333 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1713
1777
  }
1714
1778
  res.json({ ok: true });
1715
1779
  });
1780
+ /**
1781
+ * GET /api/v1/agents/:agentId/telegram/group/allowlist
1782
+ * Return the allowlisted group ids for an agent's Telegram channel. Admin only.
1783
+ */
1784
+ router.get('/v1/agents/:agentId/telegram/group/allowlist', auth, (req, res) => {
1785
+ const { agentId } = req.params;
1786
+ const apiKey = req.apiKey;
1787
+ if (!(0, auth_1.isAdmin)(apiKey)) {
1788
+ res.status(403).json({ error: 'Admin key required' });
1789
+ return;
1790
+ }
1791
+ if (!agentConfigs.has(agentId)) {
1792
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
1793
+ return;
1794
+ }
1795
+ const access = readTelegramAccess(agentId);
1796
+ res.json({ groupAllowlist: access.groupAllowlist });
1797
+ });
1798
+ /**
1799
+ * DELETE /api/v1/agents/:agentId/telegram/group/allow/:groupId
1800
+ * Remove a group from the group allowlist. Admin only. Telegram group ids are
1801
+ * negative (e.g. -1001234567890) so the validation allows a leading minus.
1802
+ */
1803
+ router.delete('/v1/agents/:agentId/telegram/group/allow/:groupId', auth, (req, res) => {
1804
+ const apiKey = req.apiKey;
1805
+ if (!(0, auth_1.isAdmin)(apiKey)) {
1806
+ res.status(403).json({ error: 'Admin key required' });
1807
+ return;
1808
+ }
1809
+ const { agentId, groupId } = req.params;
1810
+ if (!/^-?\d+$/.test(groupId)) {
1811
+ res.status(400).json({ error: 'Invalid groupId: must be a numeric Telegram chat ID' });
1812
+ return;
1813
+ }
1814
+ if (!agentConfigs.has(agentId)) {
1815
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
1816
+ return;
1817
+ }
1818
+ const access = readTelegramAccess(agentId);
1819
+ access.groupAllowlist = access.groupAllowlist.filter((id) => id !== groupId);
1820
+ // Also drop any legacy per-sender restriction for this group so a later
1821
+ // re-add (via a fresh pairing knock) doesn't resurrect a stale allowlist.
1822
+ if (access.legacyGroupAllowFrom)
1823
+ delete access.legacyGroupAllowFrom[groupId];
1824
+ try {
1825
+ writeTelegramAccess(agentId, access);
1826
+ }
1827
+ catch (err) {
1828
+ res.status(500).json({ error: `Failed to update group allowlist: ${err.message}` });
1829
+ return;
1830
+ }
1831
+ res.json({ ok: true });
1832
+ });
1833
+ /**
1834
+ * GET /api/v1/agents/:agentId/discord/pending
1835
+ * List pending Discord pairing requests (non-expired).
1836
+ */
1837
+ router.get('/v1/agents/:agentId/discord/pending', auth, (req, res) => {
1838
+ const { agentId } = req.params;
1839
+ const apiKey = req.apiKey;
1840
+ if (!(0, auth_1.isAdmin)(apiKey)) {
1841
+ res.status(403).json({ error: 'Admin key required' });
1842
+ return;
1843
+ }
1844
+ if (!agentConfigs.has(agentId)) {
1845
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
1846
+ return;
1847
+ }
1848
+ const access = readDiscordAccess(agentId);
1849
+ const now = Date.now();
1850
+ const expired = Object.keys(access.pending).filter((code) => access.pending[code].expiresAt <= now);
1851
+ if (expired.length > 0) {
1852
+ expired.forEach((code) => { delete access.pending[code]; });
1853
+ try {
1854
+ writeDiscordAccess(agentId, access);
1855
+ }
1856
+ catch { /* non-fatal cleanup */ }
1857
+ }
1858
+ const pending = Object.entries(access.pending)
1859
+ .map(([code, p]) => ({ code, senderId: p.senderId, channelId: p.channelId, createdAt: p.createdAt, expiresAt: p.expiresAt, kind: p.kind ?? 'dm', guildId: p.guildId }));
1860
+ res.json({ pending });
1861
+ });
1862
+ /**
1863
+ * POST /api/v1/agents/:agentId/discord/approve
1864
+ * Approve a pending Discord pairing by code. Kind-aware (mirrors LINE): a
1865
+ * 'guild' knock moves its guildId into guildAllowlist (no approved/ handshake
1866
+ * — a guild has no single recipient); a 'dm' knock allowlists the sender and
1867
+ * drops the approved/<senderId> file for the "You're connected!" reply.
1868
+ */
1869
+ router.post('/v1/agents/:agentId/discord/approve', auth, (req, res) => {
1870
+ const { agentId } = req.params;
1871
+ const apiKey = req.apiKey;
1872
+ if (!(0, auth_1.isAdmin)(apiKey)) {
1873
+ res.status(403).json({ error: 'Admin key required' });
1874
+ return;
1875
+ }
1876
+ if (!agentConfigs.has(agentId)) {
1877
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
1878
+ return;
1879
+ }
1880
+ const { code } = req.body;
1881
+ if (!code) {
1882
+ res.status(400).json({ error: 'code required' });
1883
+ return;
1884
+ }
1885
+ const access = readDiscordAccess(agentId);
1886
+ const entry = access.pending[code];
1887
+ if (!entry || entry.expiresAt < Date.now()) {
1888
+ res.status(404).json({ error: 'Pairing code not found or expired' });
1889
+ return;
1890
+ }
1891
+ const isGuild = entry.kind === 'guild';
1892
+ if (isGuild) {
1893
+ if (entry.guildId && !access.guildAllowlist.includes(entry.guildId))
1894
+ access.guildAllowlist.push(entry.guildId);
1895
+ }
1896
+ else {
1897
+ if (!access.allowFrom.includes(entry.senderId))
1898
+ access.allowFrom.push(entry.senderId);
1899
+ }
1900
+ delete access.pending[code];
1901
+ try {
1902
+ writeDiscordAccess(agentId, access);
1903
+ if (!isGuild) {
1904
+ // Handshake consumed by module.ts:checkApprovals() — file name is the
1905
+ // senderId, content is the channelId to DM "You're connected!".
1906
+ const approvedDir = path.join(getDiscordStateDir(agentId), 'approved');
1907
+ fs.mkdirSync(approvedDir, { recursive: true });
1908
+ fs.writeFileSync(path.join(approvedDir, entry.senderId), entry.channelId);
1909
+ }
1910
+ }
1911
+ catch (err) {
1912
+ res.status(500).json({ error: `Failed to approve pairing: ${err.message}` });
1913
+ return;
1914
+ }
1915
+ res.json({ ok: true, senderId: entry.senderId, guildId: isGuild ? entry.guildId : undefined });
1916
+ });
1917
+ /**
1918
+ * POST /api/v1/agents/:agentId/discord/deny
1919
+ * Deny and remove a pending Discord pairing by code.
1920
+ */
1921
+ router.post('/v1/agents/:agentId/discord/deny', auth, (req, res) => {
1922
+ const { agentId } = req.params;
1923
+ const apiKey = req.apiKey;
1924
+ if (!(0, auth_1.isAdmin)(apiKey)) {
1925
+ res.status(403).json({ error: 'Admin key required' });
1926
+ return;
1927
+ }
1928
+ if (!agentConfigs.has(agentId)) {
1929
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
1930
+ return;
1931
+ }
1932
+ const { code } = req.body;
1933
+ if (!code) {
1934
+ res.status(400).json({ error: 'code required' });
1935
+ return;
1936
+ }
1937
+ const access = readDiscordAccess(agentId);
1938
+ if (!access.pending[code]) {
1939
+ res.status(404).json({ error: 'Pairing code not found' });
1940
+ return;
1941
+ }
1942
+ delete access.pending[code];
1943
+ try {
1944
+ writeDiscordAccess(agentId, access);
1945
+ }
1946
+ catch (err) {
1947
+ res.status(500).json({ error: `Failed to deny pairing: ${err.message}` });
1948
+ return;
1949
+ }
1950
+ res.json({ ok: true });
1951
+ });
1952
+ /**
1953
+ * PATCH /api/v1/agents/:agentId/discord/policy
1954
+ * Update the Discord DM policy, the orthogonal pairing toggle, the guild
1955
+ * policy, and/or the guild mention gate.
1956
+ * Body: { dmPolicy?, pairing?, groupPolicy?: 'open'|'allowlist'|'disabled', requireMention?: boolean }.
1957
+ * At least one field must be present; each is applied only if provided.
1958
+ */
1959
+ router.patch('/v1/agents/:agentId/discord/policy', auth, (req, res) => {
1960
+ const { agentId } = req.params;
1961
+ const apiKey = req.apiKey;
1962
+ if (!(0, auth_1.isAdmin)(apiKey)) {
1963
+ res.status(403).json({ error: 'Admin key required' });
1964
+ return;
1965
+ }
1966
+ if (!agentConfigs.has(agentId)) {
1967
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
1968
+ return;
1969
+ }
1970
+ const { dmPolicy, pairing, groupPolicy, requireMention } = req.body;
1971
+ const valid = ['open', 'allowlist', 'disabled'];
1972
+ if (dmPolicy !== undefined && !valid.includes(dmPolicy)) {
1973
+ res.status(400).json({ error: `dmPolicy must be one of: ${valid.join(', ')}` });
1974
+ return;
1975
+ }
1976
+ if (pairing !== undefined && typeof pairing !== 'boolean') {
1977
+ res.status(400).json({ error: 'pairing must be a boolean' });
1978
+ return;
1979
+ }
1980
+ if (groupPolicy !== undefined && !valid.includes(groupPolicy)) {
1981
+ res.status(400).json({ error: `groupPolicy must be one of: ${valid.join(', ')}` });
1982
+ return;
1983
+ }
1984
+ if (requireMention !== undefined && typeof requireMention !== 'boolean') {
1985
+ res.status(400).json({ error: 'requireMention must be a boolean' });
1986
+ return;
1987
+ }
1988
+ if (dmPolicy === undefined && pairing === undefined && groupPolicy === undefined && requireMention === undefined) {
1989
+ res.status(400).json({ error: 'provide dmPolicy, pairing, groupPolicy and/or requireMention' });
1990
+ return;
1991
+ }
1992
+ const access = readDiscordAccess(agentId);
1993
+ if (dmPolicy !== undefined)
1994
+ access.dmPolicy = dmPolicy;
1995
+ if (pairing !== undefined)
1996
+ access.pairing = pairing;
1997
+ if (groupPolicy !== undefined)
1998
+ access.groupPolicy = groupPolicy;
1999
+ if (requireMention !== undefined)
2000
+ access.requireMention = requireMention;
2001
+ try {
2002
+ writeDiscordAccess(agentId, access);
2003
+ }
2004
+ catch (err) {
2005
+ res.status(500).json({ error: `Failed to update policy: ${err.message}` });
2006
+ return;
2007
+ }
2008
+ res.json({ ok: true, dmPolicy: access.dmPolicy, pairing: access.pairing, groupPolicy: access.groupPolicy, requireMention: access.requireMention });
2009
+ });
2010
+ /**
2011
+ * GET /api/v1/agents/:agentId/discord/allowlist
2012
+ * Return all users in allowFrom for an agent's Discord channel.
2013
+ */
2014
+ router.get('/v1/agents/:agentId/discord/allowlist', auth, (req, res) => {
2015
+ const { agentId } = req.params;
2016
+ const apiKey = req.apiKey;
2017
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2018
+ res.status(403).json({ error: 'Admin key required' });
2019
+ return;
2020
+ }
2021
+ if (!agentConfigs.has(agentId)) {
2022
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2023
+ return;
2024
+ }
2025
+ const access = readDiscordAccess(agentId);
2026
+ res.json({ allowFrom: access.allowFrom });
2027
+ });
2028
+ /**
2029
+ * DELETE /api/v1/agents/:agentId/discord/allow/:userId
2030
+ * Remove a user from the allowFrom list. Admin only.
2031
+ */
2032
+ router.delete('/v1/agents/:agentId/discord/allow/:userId', auth, (req, res) => {
2033
+ const apiKey = req.apiKey;
2034
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2035
+ res.status(403).json({ error: 'Admin key required' });
2036
+ return;
2037
+ }
2038
+ const { agentId, userId } = req.params;
2039
+ if (!/^\d+$/.test(userId)) {
2040
+ res.status(400).json({ error: 'Invalid userId: must be a numeric Discord user ID' });
2041
+ return;
2042
+ }
2043
+ if (!agentConfigs.has(agentId)) {
2044
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2045
+ return;
2046
+ }
2047
+ const access = readDiscordAccess(agentId);
2048
+ access.allowFrom = access.allowFrom.filter((id) => id !== userId);
2049
+ try {
2050
+ writeDiscordAccess(agentId, access);
2051
+ }
2052
+ catch (err) {
2053
+ res.status(500).json({ error: `Failed to update allowlist: ${err.message}` });
2054
+ return;
2055
+ }
2056
+ res.json({ ok: true });
2057
+ });
2058
+ /**
2059
+ * GET /api/v1/agents/:agentId/discord/guild/allowlist
2060
+ * Return the allowlisted guild ids for an agent's Discord channel. Admin only.
2061
+ */
2062
+ router.get('/v1/agents/:agentId/discord/guild/allowlist', auth, (req, res) => {
2063
+ const { agentId } = req.params;
2064
+ const apiKey = req.apiKey;
2065
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2066
+ res.status(403).json({ error: 'Admin key required' });
2067
+ return;
2068
+ }
2069
+ if (!agentConfigs.has(agentId)) {
2070
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2071
+ return;
2072
+ }
2073
+ const access = readDiscordAccess(agentId);
2074
+ res.json({ guildAllowlist: access.guildAllowlist });
2075
+ });
2076
+ /**
2077
+ * DELETE /api/v1/agents/:agentId/discord/guild/allow/:guildId
2078
+ * Remove a guild from the guild allowlist. Admin only. Discord guild ids are
2079
+ * numeric snowflakes (no leading minus).
2080
+ */
2081
+ router.delete('/v1/agents/:agentId/discord/guild/allow/:guildId', auth, (req, res) => {
2082
+ const apiKey = req.apiKey;
2083
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2084
+ res.status(403).json({ error: 'Admin key required' });
2085
+ return;
2086
+ }
2087
+ const { agentId, guildId } = req.params;
2088
+ if (!/^\d+$/.test(guildId)) {
2089
+ res.status(400).json({ error: 'Invalid guildId: must be a numeric Discord guild ID' });
2090
+ return;
2091
+ }
2092
+ if (!agentConfigs.has(agentId)) {
2093
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2094
+ return;
2095
+ }
2096
+ const access = readDiscordAccess(agentId);
2097
+ access.guildAllowlist = access.guildAllowlist.filter((id) => id !== guildId);
2098
+ try {
2099
+ writeDiscordAccess(agentId, access);
2100
+ }
2101
+ catch (err) {
2102
+ res.status(500).json({ error: `Failed to update guild allowlist: ${err.message}` });
2103
+ return;
2104
+ }
2105
+ res.json({ ok: true });
2106
+ });
1716
2107
  /**
1717
2108
  * DELETE /api/v1/agents/:agentId
1718
2109
  *