diff --git a/.gitleaks.toml b/.gitleaks.toml index 14c67407e..4eb3d082c 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -41,6 +41,9 @@ regexes = [ # ANCHORED to the exact captured value, so any other id still fails. '''^345678901234567890$''', '''^456789012345678901$''', + # A made-up Discord application id an intermediate #3469 commit carried in + # discord.relayCommands.test.js (the scan reads every commit in a PR). + '''^123456789012345678$''', ] paths = [ diff --git a/backend/__tests__/unit/routes/discord.relayCommands.test.js b/backend/__tests__/unit/routes/discord.relayCommands.test.js new file mode 100644 index 000000000..0b2c03346 --- /dev/null +++ b/backend/__tests__/unit/routes/discord.relayCommands.test.js @@ -0,0 +1,288 @@ +// F133 (D249): the Discord bridge commands /mode /status /mute /unmute /tldr +// /summary /help, through the signed HTTP interactions endpoint and the shared +// chat-relay core. Every accepted request here is signed with a real Ed25519 +// key, the way Discord signs them. +const request = require('supertest'); +const express = require('express'); +const nacl = require('tweetnacl'); + +const keyPair = nacl.sign.keyPair(); +process.env.DISCORD_PUBLIC_KEY = Buffer.from(keyPair.publicKey).toString('hex'); +process.env.DISCORD_APPLICATION_ID = '345678901234567890'; + +// A Mongo stand-in that honours the query it is given: equality on dotted +// paths and `$exists: false`. The binding check is then tested against a +// query that really filters, not a mock that returns whatever it is told. +const mockRows = []; +const mockGet = (doc, path) => path.split('.').reduce((v, k) => (v == null ? v : v[k]), doc); +const mockMatches = (doc, query) => Object.entries(query).every(([path, want]) => { + const have = mockGet(doc, path); + if (want && typeof want === 'object' && '$exists' in want) return want.$exists ? have !== undefined : have === undefined; + return have === want; +}); +jest.mock('../../../models/Integration', () => ({ + find: jest.fn((query) => ({ lean: () => Promise.resolve(mockRows.filter((row) => mockMatches(row, query))) })), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn().mockResolvedValue({}), +})); +jest.mock('../../../models/DiscordIntegration', () => ({})); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +const User = require('../../../models/User'); +jest.mock('../../../models/Pod', () => { + const pod = { _id: 'pod-1', name: 'Launch', createdBy: 'user-owner', members: ['user-owner'] }; + return { + findById: jest.fn(() => ({ + select: () => ({ lean: () => Promise.resolve(pod) }), + lean: () => Promise.resolve(pod), + })), + find: jest.fn(), + }; +}); +jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() })); +jest.mock('../../../services/discordService', () => jest.fn()); +jest.mock('../../../services/discordMultiCommandService', () => ({ + ...jest.requireActual('../../../services/discordMultiCommandService'), + runCommandForIntegration: jest.fn(), +})); +jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn().mockResolvedValue({}) })); + +const axios = require('axios'); +const Integration = require('../../../models/Integration'); +const Summary = require('../../../models/Summary'); +const { runCommandForIntegration } = require('../../../services/discordMultiCommandService'); +const discordRoutes = require('../../../routes/discord'); + +const app = express(); +app.use('/api/discord/interactions', express.raw({ type: '*/*' })); +app.use('/api/discord', discordRoutes); + +const INSTALLER = '111111111111111111'; +const STRANGER = '222222222222222222'; +const GUILD = '333333333333333333'; +const CHANNEL = '444444444444444444'; +const OTHER_CHANNEL = '555555555555555555'; + +const row = (config = {}) => ({ + _id: 'row-1', + type: 'discord', + isActive: true, + status: 'connected', + podId: 'pod-1', + createdBy: 'user-owner', + config: { + serverId: GUILD, + channelId: CHANNEL, + discordUserId: INSTALLER, + linkedUserId: 'user-owner', + liveRelay: true, + ...config, + }, +}); + +const send = (body, { secretKey = keyPair.secretKey } = {}) => { + const raw = JSON.stringify(body); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = Buffer.from(nacl.sign.detached(Buffer.from(`${timestamp}${raw}`), secretKey)).toString('hex'); + return request(app) + .post('/api/discord/interactions') + .set('Content-Type', 'application/json') + .set('X-Signature-Ed25519', signature) + .set('X-Signature-Timestamp', timestamp) + .send(raw); +}; + +const command = (name, { + actor = INSTALLER, channel = CHANNEL, guild = GUILD, options, + token = 'interaction-token', applicationId = '345678901234567890', +} = {}) => send({ + type: 2, + id: 'interaction-1', + token, + application_id: applicationId, + guild_id: guild, + channel_id: channel, + member: { user: { id: actor } }, + data: { type: 1, name, ...(options ? { options } : {}) }, +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockRows.length = 0; + mockRows.push(row()); + // The pod's creator: the bind rule /tldr re-checks (F143) passes for them. + User.findById.mockResolvedValue({ _id: 'user-owner', role: 'user' }); +}); + +describe('who may change settings', () => { + test.each([['mirror', true], ['attention', false]])('the installer sets /mode %s on the row', async (mode, flag) => { + const res = await command('mode', { options: [{ name: 'mode', value: mode }] }); + expect(res.status).toBe(200); + expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith('row-1', { + $set: { 'config.relayAllAgentMessages': flag }, + }); + expect(res.body.type).toBe(4); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).toContain(`**${mode}**`); + }); + + test('someone else in the channel is refused, ephemerally, and told who can change it', async () => { + const res = await command('mode', { actor: STRANGER, options: [{ name: 'mode', value: 'mirror' }] }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).toContain(`<@${INSTALLER}>`); + // The refusal names the installer without pinging them. + expect(res.body.data.allowed_mentions).toEqual({ parse: [] }); + }); + + test('/mute and /unmute from someone else are refused too', async () => { + const mute = await command('mute', { actor: STRANGER, options: [{ name: 'duration', value: '30m' }] }); + const unmute = await command('unmute', { actor: STRANGER }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(mute.body.data.content).toContain('can change its relay settings'); + expect(unmute.body.data.content).toContain('can change its relay settings'); + }); + + test('a row with no proven Discord account admits no one', async () => { + mockRows[0] = row({ discordUserId: undefined }); + const res = await command('mode', { options: [{ name: 'mode', value: 'mirror' }] }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.body.data.content).toContain("doesn't record their Discord account"); + }); + + test('/mode with no choice is a read, open to anyone', async () => { + mockRows[0] = row({ relayAllAgentMessages: true }); + const res = await command('mode', { actor: STRANGER }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.body.data.content).toContain('Relay mode: **mirror**'); + }); +}); + +describe('/mute and /unmute', () => { + test('/mute 30m sets relayMutedUntil thirty minutes out', async () => { + const before = Date.now(); + const res = await command('mute', { options: [{ name: 'duration', value: '30m' }] }); + const [[id, update]] = Integration.findByIdAndUpdate.mock.calls; + expect(id).toBe('row-1'); + const until = update.$set['config.relayMutedUntil'].getTime(); + expect(until).toBeGreaterThanOrEqual(before + 30 * 60_000); + expect(until).toBeLessThanOrEqual(Date.now() + 30 * 60_000); + expect(res.body.data.content).toContain('Muted for 30 min'); + expect(res.body.data.flags).toBe(64); + }); + + test('/unmute clears relayMutedUntil', async () => { + await command('unmute'); + expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith('row-1', { $unset: { 'config.relayMutedUntil': '' } }); + }); +}); + +test('/status reports mode, mute and relay state, to anyone, ephemerally', async () => { + const until = new Date(Date.now() + 45 * 60_000); + mockRows[0] = row({ relayAllAgentMessages: true, relayMutedUntil: until, leadAgentUsername: 'nova' }); + const res = await command('status', { actor: STRANGER }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).toContain('Pod: **Launch**'); + expect(res.body.data.content).toContain('Mode: **mirror**'); + expect(res.body.data.content).toContain('Relay: on'); + expect(res.body.data.content).toContain(`muted until `); + expect(res.body.data.content).toContain('Lead agent: nova'); +}); + +test('/status says when relay is on but the connection is not', async () => { + mockRows[0] = { ...row(), status: 'disconnected' }; + const res = await command('status'); + expect(res.body.data.content).toContain('Relay: on, but the connection is disconnected'); +}); + +describe('channel binding', () => { + test.each([ + ['another channel in the same guild', { channel: OTHER_CHANNEL }], + ['the same channel id under another guild', { guild: '666666666666666666' }], + ])('a command from %s is refused and changes nothing', async (_label, where) => { + const res = await command('mode', { ...where, options: [{ name: 'mode', value: 'mirror' }] }); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).toContain("isn't connected to a Commonly pod"); + }); + + test('/tldr in an unbound channel posts nothing', async () => { + Summary.findOne.mockReturnValue({ sort: () => Promise.resolve({ title: 'T', content: 'secret pod text' }) }); + const res = await command('tldr', { channel: OTHER_CHANNEL }); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).not.toContain('secret pod text'); + }); +}); + +test('a bad signature gets 401 before anything is read', async () => { + const res = await send({ + type: 2, guild_id: GUILD, channel_id: CHANNEL, member: { user: { id: INSTALLER } }, data: { type: 1, name: 'mode' }, + }, { secretKey: nacl.sign.keyPair().secretKey }); + expect(res.status).toBe(401); + expect(Integration.find).not.toHaveBeenCalled(); +}); + +test('PING is answered with PONG', async () => { + const res = await send({ type: 1 }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ type: 1 }); +}); + +test('/tldr from a row the pod creator did not bind is refused, ephemerally', async () => { + User.findById.mockResolvedValue({ _id: 'user-member', role: 'user' }); + mockRows[0] = { ...row(), createdBy: 'user-member' }; + Summary.findOne.mockReturnValue({ sort: () => Promise.resolve({ title: 'T', content: 'pod text' }) }); + const res = await command('tldr'); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).toContain("Only the pod's creator can share"); +}); + +test('/tldr in the bound channel posts the summary publicly, with no mentions and escaped markdown', async () => { + Summary.findOne.mockReturnValue({ + sort: () => Promise.resolve({ title: 'Week **one**', content: 'Ship it @everyone, see [x](https://evil)' }), + }); + const res = await command('tldr', { actor: STRANGER }); + expect(res.body.type).toBe(4); + expect(res.body.data.flags).toBe(0); + expect(res.body.data.allowed_mentions).toEqual({ parse: [] }); + expect(res.body.data.content).toContain('**Week \\*\\*one\\*\\***'); + expect(res.body.data.content).not.toMatch(/@everyone/); + expect(res.body.data.content).toContain('\\[x\\]\\(https://evil\\)'); + expect(res.body.data.content.length).toBeLessThanOrEqual(2000); +}); + +test('/summary defers, then edits the reply with the result', async () => { + mockRows[0] = row({ webhookListenerEnabled: true }); + runCommandForIntegration.mockResolvedValue({ result: { success: true, content: 'โœ… Queued 4 Discord message(s).' } }); + const res = await command('summary', { actor: STRANGER }); + expect(res.body).toEqual({ type: 5, data: { flags: 64 } }); + await new Promise((resolve) => { setImmediate(resolve); }); + await new Promise((resolve) => { setImmediate(resolve); }); + expect(runCommandForIntegration).toHaveBeenCalledWith(expect.objectContaining({ commandName: 'discord-push' })); + expect(axios.patch).toHaveBeenCalledWith( + 'https://discord.com/api/v10/webhooks/345678901234567890/interaction-token/messages/@original', + { content: 'โœ… Queued 4 Discord message(s).', allowed_mentions: { parse: [] } }, + expect.any(Object), + ); +}); + +test.each([ + ['an application id that is not a snowflake', { applicationId: '../../evil' }], + ['a token carrying a path', { token: 'tok/../../x' }], +])('/summary never edits a reply through %s', async (_label, overrides) => { + mockRows[0] = row({ webhookListenerEnabled: true }); + runCommandForIntegration.mockResolvedValue({ result: { success: true, content: 'ok' } }); + const res = await command('summary', { actor: STRANGER, ...overrides }); + expect(res.body).toEqual({ type: 5, data: { flags: 64 } }); + await new Promise((resolve) => { setImmediate(resolve); }); + await new Promise((resolve) => { setImmediate(resolve); }); + expect(axios.patch).not.toHaveBeenCalled(); +}); + +test('/help answers anywhere, ephemerally', async () => { + const res = await command('help', { channel: OTHER_CHANNEL }); + expect(res.body.data.flags).toBe(64); + expect(res.body.data.content).toContain('/mode'); + expect(res.body.data.content).toContain('/tldr'); +}); diff --git a/backend/__tests__/unit/scripts/deployDiscordCommands.test.js b/backend/__tests__/unit/scripts/deployDiscordCommands.test.js new file mode 100644 index 000000000..495d32004 --- /dev/null +++ b/backend/__tests__/unit/scripts/deployDiscordCommands.test.js @@ -0,0 +1,42 @@ +// F133: the Discord command set is registered as ONE bulk overwrite (PUT) of +// the application's global commands, carrying every bridge command. +jest.mock('axios', () => ({ put: jest.fn(), post: jest.fn(), get: jest.fn() })); + +const axios = require('axios'); +const DiscordCommandDeployment = require('../../../scripts/deploy-discord-commands'); + +const BRIDGE = ['mode', 'status', 'mute', 'unmute', 'tldr', 'summary', 'help']; + +beforeEach(() => { + jest.clearAllMocks(); + process.env.DISCORD_CLIENT_ID = 'app-1'; + process.env.DISCORD_BOT_TOKEN = 'bot-token'; + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => jest.restoreAllMocks()); + +test('one PUT to the global commands endpoint, carrying all seven bridge commands', async () => { + axios.put.mockResolvedValue({ status: 200 }); + const result = await new DiscordCommandDeployment().deploy(); + expect(result.success).toBe(true); + expect(axios.put).toHaveBeenCalledTimes(1); + expect(axios.post).not.toHaveBeenCalled(); + const [url, commands, options] = axios.put.mock.calls[0]; + expect(url).toBe('https://discord.com/api/v10/applications/app-1/commands'); + expect(options.headers.Authorization).toBe('Bot bot-token'); + const names = commands.map((c) => c.name); + expect(names).toEqual(expect.arrayContaining(BRIDGE)); + expect(new Set(names).size).toBe(names.length); + // The route answers what is registered: the set and the handler share one list. + const { DISCORD_RELAY_COMMAND_NAMES } = require('../../../services/discordRelayCommandService'); + expect([...DISCORD_RELAY_COMMAND_NAMES].sort()).toEqual([...BRIDGE].sort()); +}); + +test('/mode offers exactly the two modes, and /mute takes a duration', () => { + const commands = require('../../../config/discordApplicationCommands.json'); + const mode = commands.find((c) => c.name === 'mode'); + expect(mode.options[0].choices.map((c) => c.value)).toEqual(['mirror', 'attention']); + const mute = commands.find((c) => c.name === 'mute'); + expect(mute.options[0]).toMatchObject({ name: 'duration', type: 3, required: false }); +}); diff --git a/backend/__tests__/unit/services/chatRelayCore.commands.test.js b/backend/__tests__/unit/services/chatRelayCore.commands.test.js new file mode 100644 index 000000000..3439c8a8c --- /dev/null +++ b/backend/__tests__/unit/services/chatRelayCore.commands.test.js @@ -0,0 +1,54 @@ +// F133: the command half of the chat-relay core that Discord now shares with +// Telegram: one parse, mute durations with units, and the authority gate on +// every settings write. +const { applyRelayCommand, parseRelayCommand } = require('../../../services/chatRelay/chatRelayCore'); + +const NOW = new Date('2026-11-04T12:00:00Z'); +const MIN = 60_000; +const integration = { _id: 'i', config: { relayAllAgentMessages: false } }; + +describe('parseRelayCommand', () => { + test('names a bridge command in any case, and lowercases only the mode', () => { + expect(parseRelayCommand('MODE', ' Mirror ')).toEqual({ name: 'mode', arg: 'mirror' }); + expect(parseRelayCommand('mute', '2H')).toEqual({ name: 'mute', arg: '2H' }); + expect(parseRelayCommand('status')).toEqual({ name: 'status' }); + expect(parseRelayCommand('unmute')).toEqual({ name: 'unmute' }); + expect(parseRelayCommand('help')).toEqual({ name: 'help' }); + }); + + test('anything else is not a bridge command', () => { + expect(parseRelayCommand('tldr')).toBeNull(); + expect(parseRelayCommand('')).toBeNull(); + expect(parseRelayCommand(undefined)).toBeNull(); + }); +}); + +test.each([ + ['30m', 30], ['30 min', 30], ['2h', 120], ['1 hour', 60], ['48h', 1440], ['45', 45], ['0m', 60], + [' 2h ', 120], ['0' + '\t'.repeat(50000) + 'x', 60], +])('mute %p is %i minutes', (arg, minutes) => { + const outcome = applyRelayCommand({ name: 'mute', arg }, integration, NOW); + expect(outcome).toMatchObject({ kind: 'mute', minutes, until: new Date(NOW.getTime() + minutes * MIN) }); +}); + +describe('the authority gate', () => { + test.each([ + [{ name: 'mode', arg: 'mirror' }], + [{ name: 'mute', arg: '30' }], + [{ name: 'unmute' }], + ])('a write %p without authority is refused and carries no update', (command) => { + const outcome = applyRelayCommand(command, integration, NOW, { mayChangeSettings: false }); + expect(outcome).toEqual({ kind: 'refused', command: command.name }); + }); + + test('reads pass without authority', () => { + expect(applyRelayCommand({ name: 'status' }, integration, NOW, { mayChangeSettings: false }).kind).toBe('status'); + expect(applyRelayCommand({ name: 'mode' }, integration, NOW, { mayChangeSettings: false }).kind).toBe('mode-show'); + expect(applyRelayCommand({ name: 'help' }, null, NOW, { mayChangeSettings: false }).kind).toBe('help'); + }); + + test('with authority, or with none asked for, the write goes through', () => { + expect(applyRelayCommand({ name: 'unmute' }, integration, NOW, { mayChangeSettings: true }).kind).toBe('unmute'); + expect(applyRelayCommand({ name: 'unmute' }, integration, NOW).kind).toBe('unmute'); + }); +}); diff --git a/backend/config/discordApplicationCommands.json b/backend/config/discordApplicationCommands.json new file mode 100644 index 000000000..3289b48df --- /dev/null +++ b/backend/config/discordApplicationCommands.json @@ -0,0 +1,82 @@ +[ + { + "name": "mode", + "description": "Show this channel's relay mode, or switch it", + "type": 1, + "options": [ + { + "name": "mode", + "description": "mirror copies every agent message here; attention sends only what needs you", + "type": 3, + "required": false, + "choices": [ + { "name": "mirror", "value": "mirror" }, + { "name": "attention", "value": "attention" } + ] + } + ] + }, + { + "name": "status", + "description": "The pod, relay mode and mute state for this channel", + "type": 1 + }, + { + "name": "mute", + "description": "Pause relay to this channel (default 60 minutes, at most 24 hours)", + "type": 1, + "options": [ + { + "name": "duration", + "description": "How long, e.g. 30m or 2h. A bare number is minutes.", + "type": 3, + "required": false + } + ] + }, + { + "name": "unmute", + "description": "Resume relay to this channel", + "type": 1 + }, + { + "name": "tldr", + "description": "Post the pod's latest summary here", + "type": 1 + }, + { + "name": "summary", + "description": "Summarize this channel's recent chat into the pod", + "type": 1 + }, + { + "name": "help", + "description": "List the Commonly commands for this channel", + "type": 1 + }, + { + "name": "commonly-summary", + "description": "Get the most recent summary from the linked chat pod", + "type": 1 + }, + { + "name": "discord-status", + "description": "Show the status of Discord integration", + "type": 1 + }, + { + "name": "discord-enable", + "description": "Enable webhook listener for Discord channel", + "type": 1 + }, + { + "name": "discord-disable", + "description": "Disable webhook listener for Discord channel", + "type": 1 + }, + { + "name": "discord-push", + "description": "Push Discord activity from last hour to Commonly pod now", + "type": 1 + } +] diff --git a/backend/routes/discord.ts b/backend/routes/discord.ts index 0ab68b060..c190f4b5c 100644 --- a/backend/routes/discord.ts +++ b/backend/routes/discord.ts @@ -26,6 +26,7 @@ import { listIntegrationsRateLimit, writeIntegrationsRateLimit } from '../middle import { platformIpRateLimit } from '../middleware/platformRateLimit'; import { invalidDiscordIdError, isDiscordSnowflake, isSupplied } from '../utils/discordBinding'; import { assertCanBindSurface, SurfaceBindRefusedError } from '../services/chatRelay/bindSurface'; +import { discordApplicationCommands, handleDiscordRelayCommand, isDiscordRelayCommand } from '../services/discordRelayCommandService'; import { mintDiscordInstallProof, mintDiscordInstallState, readDiscordInstallProof, readDiscordInstallState, } from '../utils/discordInstallProof'; @@ -176,6 +177,25 @@ router.post('/interactions', async (req: AuthReq & { body: Buffer }, res: Res) = return res.status(500).json({ type: 4, data: { content: 'Failed to process installation' } }); } + // The Commonly bridge commands (F133). They share the core's semantics with + // Telegram's and act only on the row bound to this exact channel, so they + // never reach the guild-wide fallback the legacy commands below keep. + if (type === 2 && isDiscordRelayCommand(data?.name)) { + try { + const { response, after } = await handleDiscordRelayCommand(interaction); + res.json(response); + if (after) { + after().catch((error: Error) => { + console.error('[discord-commands] deferred reply failed:', error.message); + }); + } + return undefined; + } catch (error) { + console.error('[discord-commands] command failed:', (error as Error).message); + return res.json({ type: 4, data: { content: 'โŒ That command did not run. Try again shortly.', flags: 64 } }); + } + } + if (type === 2 && data?.type === 1) { const channelId = (interaction as { channel_id?: string }).channel_id; try { @@ -324,7 +344,7 @@ router.get('/health', async (_req: AuthReq, res: Res) => { const response = await axios.get(url, { headers: { Authorization: `Bot ${botToken}` } }); if (response.status === 200) { const registeredCommands = (response.data as Array<{ name: string }>).map((cmd) => cmd.name); - const expectedCommands = ['commonly-summary', 'discord-status', 'discord-enable', 'discord-disable']; + const expectedCommands = discordApplicationCommands().map((cmd) => cmd.name); const missingCommands = expectedCommands.filter((cmd) => !registeredCommands.includes(cmd)); return res.json({ timestamp: new Date().toISOString(), status: missingCommands.length === 0 ? 'healthy' : 'degraded', globalCommands: { registered: registeredCommands, missing: missingCommands, total: expectedCommands.length }, summary: { total: expectedCommands.length, registered: registeredCommands.length, failed: missingCommands.length } }); } diff --git a/backend/routes/webhooks/telegram.ts b/backend/routes/webhooks/telegram.ts index 3dd96d247..18f9f9f7e 100644 --- a/backend/routes/webhooks/telegram.ts +++ b/backend/routes/webhooks/telegram.ts @@ -19,7 +19,7 @@ const { } = require('../../services/webhookDeliveryService'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { cloudflareIpRateLimitKeyGenerator } = require('../../middleware/ipRateLimit'); -import { applyRelayCommand } from '../../services/chatRelay/chatRelayCore'; +import { applyRelayCommand, parseRelayCommand } from '../../services/chatRelay/chatRelayCore'; import { assertCanBindSurface, SurfaceBindRefusedError, surfaceAudienceOf } from '../../services/chatRelay/bindSurface'; import type { RelayCommand, RelayCommandOutcome } from '../../services/chatRelay/chatRelayCore'; @@ -50,16 +50,12 @@ const HELP_COMMAND = '/help'; const normalizeCommand = (raw = '') => raw.split('@')[0].toLowerCase(); -// Telegram's spelling of the shared bridge commands. `/mode` accepts any case -// of its argument; `/mute` hands its argument to parseInt as typed. -const parseTelegramRelayCommand = (command: string | null, arg?: string): RelayCommand | null => { - if (command === MODE_COMMAND) return { name: 'mode', arg: (arg || '').toLowerCase() }; - if (command === STATUS_COMMAND) return { name: 'status' }; - if (command === MUTE_COMMAND) return { name: 'mute', arg }; - if (command === UNMUTE_COMMAND) return { name: 'unmute' }; - if (command === HELP_COMMAND) return { name: 'help' }; - return null; -}; +// Telegram's spelling of the shared bridge commands is a leading slash on the +// core's names; the parse itself is the core's, shared with Discord. +const TELEGRAM_RELAY_COMMANDS = new Set([MODE_COMMAND, STATUS_COMMAND, MUTE_COMMAND, UNMUTE_COMMAND, HELP_COMMAND]); +const parseTelegramRelayCommand = (command: string | null, arg?: string): RelayCommand | null => ( + command && TELEGRAM_RELAY_COMMANDS.has(command) ? parseRelayCommand(command.slice(1), arg) : null +); const getMessageFromUpdate = (update: any) => update?.message || update?.channel_post || null; diff --git a/backend/scripts/deploy-discord-commands.js b/backend/scripts/deploy-discord-commands.js index 560a8693f..02b7ce633 100644 --- a/backend/scripts/deploy-discord-commands.js +++ b/backend/scripts/deploy-discord-commands.js @@ -1,4 +1,5 @@ const axios = require("axios"); +const DISCORD_APPLICATION_COMMANDS = require("../config/discordApplicationCommands.json"); /** * Discord Command Deployment Script @@ -66,34 +67,10 @@ class DiscordCommandDeployment { async registerGlobalCommands() { const { DISCORD_CLIENT_ID, DISCORD_BOT_TOKEN } = process.env; - // Define the slash commands - const commands = [ - { - name: "commonly-summary", - description: "Get the most recent summary from the linked chat pod", - type: 1, // CHAT_INPUT - }, - { - name: "discord-status", - description: "Show the status of Discord integration", - type: 1, - }, - { - name: "discord-enable", - description: "Enable webhook listener for Discord channel", - type: 1, - }, - { - name: "discord-disable", - description: "Disable webhook listener for Discord channel", - type: 1, - }, - { - name: "discord-push", - description: "Push Discord activity from last hour to Commonly pod now", - type: 1, - }, - ]; + // The full command set, one bulk overwrite (PUT): the Commonly bridge + // commands (F133) and the legacy ones. The same file is what the + // interactions route answers, so the menu and the handler cannot drift. + const commands = DISCORD_APPLICATION_COMMANDS; console.log(`๐Ÿ”ง Registering ${commands.length} global commands...`); @@ -223,13 +200,9 @@ class DiscordCommandDeployment { if (response.status === 200) { const registeredCommands = response.data; - const expectedCommands = [ - "commonly-summary", - "discord-status", - "discord-enable", - "discord-disable", - "discord-push", - ]; + const expectedCommands = DISCORD_APPLICATION_COMMANDS.map( + (cmd) => cmd.name, + ); const foundCommands = registeredCommands.map((cmd) => cmd.name); const missingCommands = expectedCommands.filter( diff --git a/backend/services/chatRelay/chatRelayCore.ts b/backend/services/chatRelay/chatRelayCore.ts index 4937fbefc..2247c7711 100644 --- a/backend/services/chatRelay/chatRelayCore.ts +++ b/backend/services/chatRelay/chatRelayCore.ts @@ -114,21 +114,63 @@ export type RelayCommandOutcome = lead: string | null; } | { kind: 'mute'; minutes: number; until: Date; update: RelayConfigUpdate } - | { kind: 'unmute'; update: RelayConfigUpdate }; + | { kind: 'unmute'; update: RelayConfigUpdate } + /** A settings change from someone the platform could not prove may make it. */ + | { kind: 'refused'; command: RelayCommand['name'] }; const DEFAULT_MUTE_MINUTES = 60; const MAX_MUTE_MINUTES = 24 * 60; /** - * Pure: what a command means for this connector, and the config write it - * needs. The caller applies `update` with its own model call and renders the - * outcome in its platform's words. `arg` is compared as given โ€” a platform that - * accepts `/mode Mirror` lowercases before calling, one that does not, doesn't. + * Platform-neutral parse: a command's bare name (no slash, no bot suffix) and + * its one argument, as a platform received them. Null for a name that is not a + * bridge command, so the platform keeps its own handling for it. `/mode` takes + * its argument in any case; `/mute` hands its argument on as typed. */ -export const applyRelayCommand = ( +export const parseRelayCommand = (name: string | null | undefined, arg?: string): RelayCommand | null => { + const normalized = String(name || '').trim().toLowerCase(); + if (normalized === 'mode') return { name: 'mode', arg: (arg || '').trim().toLowerCase() }; + if (normalized === 'status') return { name: 'status' }; + if (normalized === 'mute') return { name: 'mute', arg }; + if (normalized === 'unmute') return { name: 'unmute' }; + if (normalized === 'help') return { name: 'help' }; + return null; +}; + +// Read against a trimmed argument of at most 16 characters, with no +// adjacent optional whitespace, so the match is linear. +const MUTE_DURATION = /^(\d{1,5}) ?(m|min|mins|minutes?|h|hr|hrs|hours?)?$/i; +const MUTE_ARG_MAX = 16; + +/** + * The minutes a `/mute` argument asks for: a bare number is minutes, and `30m` + * or `2h` carry their unit. Anything else falls back to parseInt, as before + * units were read, so `2h` no longer means two minutes but `soon` still means + * the default. + */ +const muteMinutesOf = (arg?: string): number => { + const trimmed = String(arg || '').trim().slice(0, MUTE_ARG_MAX); + const match = MUTE_DURATION.exec(trimmed); + const asked = match + ? parseInt(match[1], 10) * (match[2] && /^h/i.test(match[2]) ? 60 : 1) + : parseInt(trimmed || String(DEFAULT_MUTE_MINUTES), 10); + return Math.min(Math.max(asked || DEFAULT_MUTE_MINUTES, 1), MAX_MUTE_MINUTES); +}; + +export interface RelayCommandAuthority { + /** + * Whether the platform proved the caller may change this connector's relay + * settings. Omitted means the platform's surface already proves it (a + * private Telegram chat is its linked user's alone). `false` turns every + * outcome that writes into `refused`; reads are unaffected. + */ + mayChangeSettings?: boolean; +} + +const decideRelayCommand = ( command: RelayCommand, integration: ChatRelayIntegration | null | undefined, - now: Date = new Date(), + now: Date, ): RelayCommandOutcome => { if (command.name === 'help') return { kind: 'help' }; if (!integration) return { kind: 'not-linked' }; @@ -142,10 +184,7 @@ export const applyRelayCommand = ( }; } if (command.name === 'mute') { - const minutes = Math.min( - Math.max(parseInt(command.arg || String(DEFAULT_MUTE_MINUTES), 10) || DEFAULT_MUTE_MINUTES, 1), - MAX_MUTE_MINUTES, - ); + const minutes = muteMinutesOf(command.arg); const until = new Date(now.getTime() + minutes * 60_000); return { kind: 'mute', minutes, until, update: { $set: { 'config.relayMutedUntil': until } } }; } @@ -163,6 +202,28 @@ export const applyRelayCommand = ( }; }; +/** + * Pure: what a command means for this connector, and the config write it + * needs. The caller applies `update` with its own model call and renders the + * outcome in its platform's words. `arg` is compared as given โ€” a platform that + * accepts `/mode Mirror` lowercases before calling, one that does not, doesn't. + */ +export const applyRelayCommand = ( + command: RelayCommand, + integration: ChatRelayIntegration | null | undefined, + now: Date = new Date(), + authority: RelayCommandAuthority = {}, +): RelayCommandOutcome => { + const outcome = decideRelayCommand(command, integration, now); + // One rule for every platform: a write needs the authority the platform + // proved. Shown here rather than per platform so a new surface cannot ship + // its settings commands open by forgetting the check. + if ('update' in outcome && authority.mayChangeSettings === false) { + return { kind: 'refused', command: command.name }; + } + return outcome; +}; + // --------------------------------------------------------------------------- // Outbound โ€” one agent message to one bound connector. // --------------------------------------------------------------------------- diff --git a/backend/services/discordBridgeService.ts b/backend/services/discordBridgeService.ts index 810bba34f..752371985 100644 --- a/backend/services/discordBridgeService.ts +++ b/backend/services/discordBridgeService.ts @@ -304,15 +304,15 @@ const findLiveIntegration = async (podId: string): Promise value.replace(/([\\*_~`|>[\]()])/g, '\\$1'); +export const escapeDiscordMarkdown = (value: string): string => value.replace(/([\\*_~`|>[\]()])/g, '\\$1'); // `allowed_mentions` already stops every ping. This also keeps the words from // reading as one: a zero-width space after the @ renders the same and is no // longer the mass-mention token. -const defuseMassMentions = (value: string): string => value.replace(/@(everyone|here)/gi, '@โ€‹$1'); +export const defuseMassMentions = (value: string): string => value.replace(/@(everyone|here)/gi, '@โ€‹$1'); /** Cut to `limit` characters with an ellipsis, never leaving half a surrogate pair. */ -const fitWithin = (value: string, limit: number): string => { +export const fitWithin = (value: string, limit: number): string => { if (value.length <= limit) return value; if (limit <= 1) return 'โ€ฆ'.slice(0, Math.max(0, limit)); let cut = value.slice(0, limit - 1); @@ -562,6 +562,9 @@ module.exports = { renderDiscordAgentLine, renderDiscordDecisionCard, DISCORD_CONTENT_LIMIT, + escapeDiscordMarkdown, + defuseMassMentions, + fitWithin, isIgnoredDiscordMessage, renderDiscordInboundContent, relayDiscordMessageToPod, diff --git a/backend/services/discordCommandService.ts b/backend/services/discordCommandService.ts index f18f5177f..ef70a7a84 100644 --- a/backend/services/discordCommandService.ts +++ b/backend/services/discordCommandService.ts @@ -97,6 +97,36 @@ class DiscordCommandService { this.integration = null; } + /** + * The pod's latest chat summary, as a bound Discord channel may see it. The + * read behind both `/commonly-summary` and `/tldr` (F133). + * + * F143: the summary goes to everyone in the guild channel, so the row must + * have been bound by someone with authority over the pod (its creator or an + * admin). Rows plain members bound before #3436 are refused until the pod's + * creator reconnects them. + */ + static async latestPodSummaryFor( + integration: Pick, + ): Promise<{ kind: 'refused' } | { kind: 'none' } | { kind: 'summary'; summary: SummaryLike }> { + const podRef = integration.podId as unknown as { _id?: unknown } | null; + const podId = podRef?._id ?? podRef; + const pod = await Pod.findById(podId).lean(); + try { + await assertCanBindSurface({ + userId: integration.createdBy, pod, type: 'discord', config: integration.config || {}, + }); + } catch (error) { + if (!(error instanceof SurfaceBindRefusedError)) throw error; + return { kind: 'refused' }; + } + const summary = await Summary.findOne({ + type: 'chats', + podId, + }).sort({ createdAt: -1 }) as SummaryLike | null; + return summary ? { kind: 'summary', summary } : { kind: 'none' }; + } + async initialize(): Promise { try { if (this.integrationId) { @@ -154,37 +184,21 @@ class DiscordCommandService { }; } - // F143: the summary goes to everyone in the guild channel, so the row - // must have been bound by someone with authority over the pod (its - // creator or an admin). Rows plain members bound before #3436 get this - // refusal and no summary until the pod's creator reconnects them. - const podRef = this.integration.podId as unknown as { _id?: unknown } | null; - const podId = podRef?._id ?? podRef; - const pod = await Pod.findById(podId).lean(); - try { - await assertCanBindSurface({ - userId: this.integration.createdBy, pod, type: 'discord', config: this.integration.config || {}, - }); - } catch (error) { - if (!(error instanceof SurfaceBindRefusedError)) throw error; + const found = await DiscordCommandService.latestPodSummaryFor(this.integration); + if (found.kind === 'refused') { return { success: false, refused: true, content: "Only the pod's creator can share this pod's summary with a channel. Ask them to reconnect it.", }; } - - const latestSummary = await Summary.findOne({ - type: 'chats', - podId, - }).sort({ createdAt: -1 }) as SummaryLike | null; - - if (!latestSummary) { + if (found.kind === 'none') { return { success: true, content: '๐Ÿ“ No recent summaries available for this chat pod.', }; } + const latestSummary = found.summary; const formattedSummary = this.formatSummaryForDiscord(latestSummary); diff --git a/backend/services/discordRelayCommandService.ts b/backend/services/discordRelayCommandService.ts new file mode 100644 index 000000000..33b44fae9 --- /dev/null +++ b/backend/services/discordRelayCommandService.ts @@ -0,0 +1,352 @@ +// Discord bridge commands (D249, F133): /mode /status /mute /unmute /tldr +// /summary /help, the same commands a Telegram chat has, as Discord +// application commands. +// +// They arrive on the HTTP interactions endpoint (routes/discord.ts), which +// verifies Discord's Ed25519 signature before anything here runs. What a +// command MEANS โ€” the mode flag, the mute window, the status read, and the rule +// that a settings write needs proven authority โ€” is the shared chat-relay +// core's (chatRelay/chatRelayCore). What stays here is what only Discord knows: +// +// - a guild channel is a SHARED surface, so the core's authority gate is fed +// a proof: the caller's Discord id must be the row's `config.discordUserId`, +// the installer the OAuth callback proved with `users/@me` (F131). That is +// the same person the Connectors toggle and the PATCH admit; +// - a command acts only on rows bound to the guild AND channel it was typed +// in, never on another channel's or another pod's row; +// - replies are interaction responses: settings and reads that concern one +// person are ephemeral (flags 64); /tldr posts to the channel and can ping +// no one (`allowed_mentions: { parse: [] }`, markdown escaped); +// - Discord's 3-second deadline: /summary generates, so it defers (type 5) +// and edits the deferred reply when the work is done. + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Integration = require('../models/Integration'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Pod = require('../models/Pod'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const axios = require('axios'); +import { applyRelayCommand, parseRelayCommand } from './chatRelay/chatRelayCore'; +import type { RelayCommandOutcome } from './chatRelay/chatRelayCore'; +import { + DISCORD_CONTENT_LIMIT, defuseMassMentions, escapeDiscordMarkdown, fitWithin, +} from './discordBridgeService'; +import DISCORD_APPLICATION_COMMANDS from '../config/discordApplicationCommands.json'; + +const LOG = '[discord-commands]'; +const EPHEMERAL = 64; +const NO_MENTIONS = { parse: [] as string[] }; + +/** The bridge commands this module answers; the rest of the set is legacy. */ +export const DISCORD_RELAY_COMMAND_NAMES = ['mode', 'status', 'mute', 'unmute', 'tldr', 'summary', 'help'] as const; +const RELAY_COMMANDS = new Set(DISCORD_RELAY_COMMAND_NAMES); + +/** The full set registered with Discord, bridge and legacy, in one bulk overwrite. */ +export const discordApplicationCommands = (): Array<{ name: string }> => DISCORD_APPLICATION_COMMANDS; + +export const isDiscordRelayCommand = (name: unknown): boolean => RELAY_COMMANDS.has(String(name || '')); + +export interface DiscordInteraction { + type?: number; + id?: string; + token?: string; + application_id?: string; + guild_id?: string; + channel_id?: string; + member?: { user?: { id?: string } } | null; + user?: { id?: string } | null; + data?: { name?: string; options?: Array<{ name?: string; value?: unknown }> }; +} + +export interface DiscordCommandRow { + _id: unknown; + podId?: unknown; + createdBy?: unknown; + status?: string; + isActive?: boolean; + config?: { + serverId?: string; + channelId?: string; + discordUserId?: string; + liveRelay?: boolean; + relayAllAgentMessages?: boolean; + relayMutedUntil?: Date | string; + leadAgentUsername?: string; + adminPause?: unknown; + webhookListenerEnabled?: boolean; + [key: string]: unknown; + }; +} + +export interface InteractionReply { + type: number; + data?: { content?: string; flags?: number; allowed_mentions?: { parse: string[] } }; +} + +export interface DiscordCommandHandled { + /** The HTTP body for Discord's 3-second window. */ + response: InteractionReply; + /** Work that runs after the response is sent (a deferred reply's edit). */ + after?: () => Promise; +} + +const reply = (content: string, opts: { ephemeral?: boolean } = {}): InteractionReply => ({ + type: 4, + data: { + content: fitWithin(content, DISCORD_CONTENT_LIMIT), + flags: opts.ephemeral === false ? 0 : EPHEMERAL, + allowed_mentions: NO_MENTIONS, + }, +}); + +/** The Discord user who typed the command: `member.user` in a guild, `user` in a DM. */ +export const interactionActorId = (interaction: DiscordInteraction): string => String( + interaction.member?.user?.id || interaction.user?.id || '', +); + +/** The row binds exactly the guild and channel the command was typed in. */ +export const isBoundToChannel = (row: DiscordCommandRow, guildId: string, channelId: string): boolean => ( + Boolean(guildId) && Boolean(channelId) + && String(row.config?.serverId || '') === guildId + && String(row.config?.channelId || '') === channelId +); + +/** + * May this Discord user change the row's relay settings? Only the person who + * added the bot, matched by the Discord id the install proved โ€” the same + * person the Connectors toggle and the PATCH admit. A row without a proven id + * admits no one: there is nothing to match a caller against. + */ +export const mayChangeRelaySettings = (row: DiscordCommandRow, actorId: string): boolean => ( + Boolean(actorId) && Boolean(row.config?.discordUserId) && String(row.config?.discordUserId) === actorId +); + +const optionValue = (interaction: DiscordInteraction, name: string): string | undefined => { + const option = (interaction.data?.options || []).find((o) => o?.name === name); + return option?.value === undefined || option.value === null ? undefined : String(option.value); +}; + +const NOT_CONNECTED = "This channel isn't connected to a Commonly pod. Connect it from the pod's Connectors page."; + +const HELP_TEXT = [ + '**Commonly commands for this channel**', + '/mode: show the relay mode, or switch it to mirror or attention', + ' โ€ข mirror: every agent message in the pod is copied here', + ' โ€ข attention: only escalations and the lead agent reach this channel', + '/status: pod, mode, and mute state for this channel', + '/mute [30m | 2h]: pause relay here (default 60 minutes); /unmute to resume', + '/tldr: post the pod\'s latest summary here', + '/summary: summarize recent chat here into the pod', + 'Only the person who connected this channel can change mode or mute.', +].join('\n'); + +const refusalFor = (row: DiscordCommandRow): string => ( + row.config?.discordUserId + ? `Only <@${row.config.discordUserId}>, who connected this channel to Commonly, can change its relay settings.` + : 'Only the person who connected this channel to Commonly can change its relay settings, and this connection ' + + "doesn't record their Discord account. They can reconnect it from Commonly to fix that." +); + +const relayStateOf = (row: DiscordCommandRow): string => { + if (!row.config?.liveRelay) return 'off'; + if (row.status !== 'connected') return `on, but the connection is ${row.status || 'not connected'}`; + return 'on'; +}; + +/** A Discord timestamp tag: each reader sees it in their own time zone. */ +const discordTime = (at: Date): string => ``; + +/** Discord's words for one row's outcome. Plain text; the caller adds the pod tag. */ +const discordCommandReply = (outcome: RelayCommandOutcome, row: DiscordCommandRow, podName: string): string => { + switch (outcome.kind) { + case 'help': + return HELP_TEXT; + case 'not-linked': + return NOT_CONNECTED; + case 'refused': + return refusalFor(row); + case 'mode-show': + return `Relay mode: **${outcome.mode}**. Switch with /mode mirror or /mode attention.`; + case 'mode-usage': + return 'Usage: /mode mirror or /mode attention'; + case 'mode-set': + return outcome.mode === 'mirror' + ? 'Mode: **mirror**. Every agent message in the pod is copied here.' + : 'Mode: **attention**. Only escalations and the lead agent reach this channel.'; + case 'mute': + return `Muted for ${outcome.minutes} min. Nothing relays here until ${discordTime(outcome.until)}. ` + + '/unmute to resume.'; + case 'unmute': + return 'Unmuted. Relay resumes.'; + case 'status': { + const muted = outcome.mutedUntil + ? `muted until ${discordTime(outcome.mutedUntil)}` + : 'not muted'; + return [ + `Pod: **${escapeDiscordMarkdown(podName)}**`, + `Mode: **${outcome.mode}** ยท Relay: ${relayStateOf(row)} ยท ${muted}`, + outcome.lead ? `Lead agent: ${escapeDiscordMarkdown(outcome.lead)}` : null, + ].filter(Boolean).join('\n'); + } + default: + return HELP_TEXT; + } +}; + +const podNameOf = async (row: DiscordCommandRow): Promise => { + try { + const pod = await Pod.findById(row.podId).select('name').lean(); + return String(pod?.name || row.podId || 'unknown'); + } catch { + return String(row.podId || 'unknown'); + } +}; + +// Several pods can share one channel; each answer then says which pod it is. +const joinPerPod = (blocks: Array<{ podName: string; text: string }>): string => ( + blocks.length === 1 + ? blocks[0].text + : blocks + .map(({ podName, text }) => `**${escapeDiscordMarkdown(defuseMassMentions(podName))}**\n${text}`) + .join('\n\n') +); + +/** The rows bound to the channel the command came from. Every row is re-checked, not just queried. */ +export const findBoundRows = async (guildId: string, channelId: string): Promise => { + if (!guildId || !channelId) return []; + const rows = await Integration.find({ + type: 'discord', + isActive: true, + 'config.serverId': guildId, + 'config.channelId': channelId, + 'config.adminPause': { $exists: false }, + }).lean() as DiscordCommandRow[] | null; + return (rows || []).filter((row) => isBoundToChannel(row, guildId, channelId)); +}; + +const runSettingsCommand = async ( + interaction: DiscordInteraction, + rows: DiscordCommandRow[], +): Promise => { + const name = String(interaction.data?.name); + const arg = name === 'mode' ? optionValue(interaction, 'mode') : optionValue(interaction, 'duration'); + const command = parseRelayCommand(name, arg); + if (!command) return reply(HELP_TEXT); + const actorId = interactionActorId(interaction); + const blocks = await Promise.all(rows.map(async (row) => { + const outcome = applyRelayCommand(command, row, new Date(), { + mayChangeSettings: mayChangeRelaySettings(row, actorId), + }); + if ('update' in outcome) await Integration.findByIdAndUpdate(row._id, outcome.update); + const podName = await podNameOf(row); + return { podName, text: discordCommandReply(outcome, row, podName) }; + })); + return reply(joinPerPod(blocks)); +}; + +/** The /tldr text: the summary's title, then its body, the way Telegram sends it. */ +export const renderDiscordTldr = (summary: { title?: string; content?: string }): string => { + const title = defuseMassMentions(escapeDiscordMarkdown(String(summary.title || 'Pod Summary'))); + const body = defuseMassMentions(escapeDiscordMarkdown(String(summary.content || ''))); + return `**${title}**\n\n${body}`; +}; + +const runTldr = async (rows: DiscordCommandRow[]): Promise => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const DiscordCommandService = require('./discordCommandService'); + const results = await Promise.all(rows.map(async (row) => ({ + row, + podName: await podNameOf(row), + found: await DiscordCommandService.latestPodSummaryFor(row) as + { kind: 'refused' } | { kind: 'none' } | { kind: 'summary'; summary: { title?: string; content?: string } }, + }))); + const shared = results.filter((r) => r.found.kind === 'summary'); + if (!shared.length) { + const refused = results.some((r) => r.found.kind === 'refused'); + return reply(refused + ? "Only the pod's creator can share this pod's summary with a channel. Ask them to reconnect it." + : '๐Ÿ“ No recent pod summaries available yet.'); + } + const blocks = shared.map((r) => ({ + podName: r.podName, + text: renderDiscordTldr((r.found as { summary: { title?: string; content?: string } }).summary), + })); + return reply(joinPerPod(blocks), { ephemeral: false }); +}; + +const DISCORD_SNOWFLAKE = /^\d{17,20}$/; +const INTERACTION_TOKEN = /^[A-Za-z0-9_.-]{1,512}$/; + +// Edit the deferred reply. The interaction token is the credential, so the URL +// carries no bot token; the application id comes from config. +const editDeferredReply = async (interaction: DiscordInteraction, content: string): Promise => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const config = require('../config/discord'); + const applicationId = String(interaction.application_id || config.applicationId || ''); + const token = String(interaction.token || ''); + // Both come from a signed payload, but they are still path segments: pin + // them to Discord's shapes so the request can only reach this endpoint. + if (!DISCORD_SNOWFLAKE.test(applicationId) || !INTERACTION_TOKEN.test(token)) return; + await axios.patch( + `https://discord.com/api/v10/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(token)}/messages/@original`, + { content: fitWithin(content, DISCORD_CONTENT_LIMIT), allowed_mentions: NO_MENTIONS }, + { headers: { 'Content-Type': 'application/json' }, timeout: 10_000 }, + ); +}; + +// /summary: the channel's buffered chat โ†’ a summary โ†’ Commonly Bot in the pod, +// the path `/discord-push` has always taken. It calls the summarizer, so it +// cannot promise Discord's 3 seconds and answers by deferring. +const deferSummary = (interaction: DiscordInteraction, rows: DiscordCommandRow[]): DiscordCommandHandled => ({ + response: { type: 5, data: { flags: EPHEMERAL } }, + after: async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { runCommandForIntegration } = require('./discordMultiCommandService'); + const blocks = await Promise.all(rows.map(async (row) => { + const podName = await podNameOf(row); + if (!row.config?.webhookListenerEnabled) { + return { + podName, + text: 'Channel sync is off for this channel, so there is nothing buffered to summarize. ' + + 'Turn it on with /discord-enable.', + }; + } + try { + const { result } = await runCommandForIntegration({ commandName: 'discord-push', integration: row }); + return { podName, text: String(result?.content || 'โŒ The summary did not run.') }; + } catch (error) { + console.warn(`${LOG} /summary failed integration=${String(row._id)}: ${(error as Error).message}`); + return { podName, text: 'โŒ The summary did not run. Try again shortly.' }; + } + })); + await editDeferredReply(interaction, joinPerPod(blocks)); + }, +}); + +/** + * One bridge command โ†’ its interaction response. Only for names + * `isDiscordRelayCommand` accepts; the route keeps every other name. + */ +export const handleDiscordRelayCommand = async (interaction: DiscordInteraction): Promise => { + const name = String(interaction.data?.name || ''); + if (name === 'help') return { response: reply(HELP_TEXT) }; + const guildId = String(interaction.guild_id || ''); + const channelId = String(interaction.channel_id || ''); + const rows = await findBoundRows(guildId, channelId); + if (!rows.length) return { response: reply(NOT_CONNECTED) }; + if (name === 'tldr') return { response: await runTldr(rows) }; + if (name === 'summary') return deferSummary(interaction, rows); + return { response: await runSettingsCommand(interaction, rows) }; +}; + +module.exports = { + DISCORD_RELAY_COMMAND_NAMES, + discordApplicationCommands, + isDiscordRelayCommand, + interactionActorId, + isBoundToChannel, + mayChangeRelaySettings, + findBoundRows, + renderDiscordTldr, + handleDiscordRelayCommand, +}; diff --git a/docs/discord/REGISTER_DISCORD_COMMANDS.md b/docs/discord/REGISTER_DISCORD_COMMANDS.md index 202cd5110..d866380bf 100644 --- a/docs/discord/REGISTER_DISCORD_COMMANDS.md +++ b/docs/discord/REGISTER_DISCORD_COMMANDS.md @@ -2,6 +2,38 @@ This guide shows you how to register Discord slash commands for your Commonly integrations. +## Bridge commands (F133): what is current + +The command set lives in one file, `backend/config/discordApplicationCommands.json`: +the seven bridge commands (`/mode`, `/status`, `/mute`, `/unmute`, `/tldr`, +`/summary`, `/help`, the ones a Telegram chat has) plus the five legacy ones. +`backend/start.sh` registers that whole set as **global** commands on every boot, +with one bulk-overwrite `PUT /applications/{id}/commands` +(`scripts/deploy-discord-commands.js`). It runs only when `DISCORD_CLIENT_ID`, +`DISCORD_BOT_TOKEN` and `DISCORD_PUBLIC_KEY` are all set. To run it by hand: +`npm run discord:deploy`, then `npm run discord:verify`. Global commands can +take up to an hour to show in every guild. + +- **Transport.** Commands arrive at `POST /api/discord/interactions`, which + verifies `X-Signature-Ed25519` / `X-Signature-Timestamp` before anything else + and answers PING. The application's *Interactions Endpoint URL* in the Developer + Portal must point there (`https:///api/discord/interactions`). The + gateway does not handle `INTERACTION_CREATE`: while an endpoint URL is set, + Discord doesn't send interactions over the gateway anyway. +- **Semantics** come from the shared relay core (`services/chatRelay/chatRelayCore.ts`: + `parseRelayCommand`, `applyRelayCommand`). Only the Discord wording, the + channel binding and the authority proof are in `services/discordRelayCommandService.ts`. +- **Authority.** `/mode `, `/mute` and `/unmute` write the row, so they + need the caller's Discord id (`member.user.id`) to equal the row's + `config.discordUserId`, the installer the OAuth callback proved. Everyone else + gets an ephemeral refusal naming that person. Reads are open to anyone in the + bound channel. +- **Binding.** A command acts only on rows whose `serverId` and `channelId` are + the ones it was typed in. Unlike the legacy commands, it has no guild-wide fallback. +- **Scope.** The install link asks for `applications.commands` already. A guild + that added the bot before that scope was on the link must add the bot again + before commands show up there. + ## ๐Ÿš€ **Quick Start (Recommended)** ### **Method 1: Using the Registration Script**