diff --git a/docs/plans/analysis/2026-02-16-codebase-audit-report.md b/docs/plans/analysis/2026-02-16-codebase-audit-report.md index 0f1abcc..c47f2d6 100644 --- a/docs/plans/analysis/2026-02-16-codebase-audit-report.md +++ b/docs/plans/analysis/2026-02-16-codebase-audit-report.md @@ -268,6 +268,7 @@ Remediation update (2026-02-16): Remediation update (2026-02-16): - Added shared `normalizeResetCommandText()` utility and migrated Discord/Slack/WhatsApp adapters to use it, reducing repeated reset-command parsing logic. +- Added shared `buildResetInboundMessage()` utility and migrated Discord/Slack/WhatsApp adapters to use it, reducing repeated reset-metadata construction logic. ### F-014 Low: ModelRouter listener API has destructive setter footgun diff --git a/docs/plans/state.json b/docs/plans/state.json index f4e0c81..f494636 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -2618,7 +2618,7 @@ "status": "in_progress", "date": "2026-02-16", "updated": "2026-02-16", - "summary": "Started reducing channel adapter duplication by extracting shared reset-command normalization and migrating Discord/Slack/WhatsApp adapters to use it.", + "summary": "Started reducing channel adapter duplication by extracting shared reset-command normalization and reset message construction utilities, and migrating Discord/Slack/WhatsApp adapters to use them.", "files_modified": [ "src/channels/utils.ts", "src/channels/utils.test.ts", @@ -2627,7 +2627,7 @@ "src/channels/whatsapp/adapter.ts", "docs/plans/analysis/2026-02-16-codebase-audit-report.md" ], - "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm typecheck passing" + "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm typecheck + pnpm lint passing" }, "audit-followup-lint-error-baseline": { "status": "completed", diff --git a/src/channels/discord/adapter.ts b/src/channels/discord/adapter.ts index d922e94..2e3077d 100644 --- a/src/channels/discord/adapter.ts +++ b/src/channels/discord/adapter.ts @@ -17,7 +17,7 @@ import type { ChannelAdapter, ChannelStatus, } from '../types.js'; -import { normalizeResetCommandText, splitMessage } from '../utils.js'; +import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the Discord channel adapter. */ @@ -242,15 +242,12 @@ export class DiscordAdapter implements ChannelAdapter { // ── Reset command ── if (text === '!reset') { - this.messageHandler({ + this.messageHandler(buildResetInboundMessage({ id: message.id, channel: 'discord', senderId: message.channelId, senderName: message.author.username, - text: '!reset', - timestamp: Date.now(), - metadata: { isCommand: true, command: 'reset' }, - }); + })); return; } diff --git a/src/channels/slack/adapter.ts b/src/channels/slack/adapter.ts index 4ea597a..2641384 100644 --- a/src/channels/slack/adapter.ts +++ b/src/channels/slack/adapter.ts @@ -15,7 +15,7 @@ import type { ChannelAdapter, ChannelStatus, } from '../types.js'; -import { normalizeResetCommandText, splitMessage } from '../utils.js'; +import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the Slack channel adapter. */ @@ -357,16 +357,13 @@ export class SlackAdapter implements ChannelAdapter { // Detect reset command if (text === '!reset') { - this.messageHandler({ + this.messageHandler(buildResetInboundMessage({ id: message.ts ?? '', channel: 'slack', senderId: peerId, senderName, - text: '!reset', - timestamp: Date.now(), - metadata: { isCommand: true, command: 'reset' }, - ...(attachments.length > 0 && { attachments }), - }); + attachments, + })); return; } diff --git a/src/channels/utils.test.ts b/src/channels/utils.test.ts index 81fdbf3..e202b55 100644 --- a/src/channels/utils.test.ts +++ b/src/channels/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { normalizeResetCommandText, splitMessage } from './utils.js'; +import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from './utils.js'; describe('splitMessage', () => { it('returns single chunk for empty string', () => { @@ -98,3 +98,38 @@ describe('normalizeResetCommandText', () => { expect(normalizeResetCommandText('hello')).toBe('hello'); }); }); + +describe('buildResetInboundMessage', () => { + it('builds canonical reset command metadata and text', () => { + const message = buildResetInboundMessage({ + id: 'id-1', + channel: 'slack', + senderId: 'C1:T1', + senderName: 'Alice', + timestamp: 123, + }); + + expect(message).toEqual({ + id: 'id-1', + channel: 'slack', + senderId: 'C1:T1', + senderName: 'Alice', + text: '!reset', + timestamp: 123, + metadata: { isCommand: true, command: 'reset' }, + }); + }); + + it('includes attachments only when provided', () => { + const message = buildResetInboundMessage({ + id: 'id-2', + channel: 'whatsapp', + senderId: '123@c.us', + attachments: [{ mimeType: 'image/png', url: 'https://example.com/a.png' }], + }); + + expect(message.attachments).toHaveLength(1); + expect(message.text).toBe('!reset'); + expect(message.metadata).toEqual({ isCommand: true, command: 'reset' }); + }); +}); diff --git a/src/channels/utils.ts b/src/channels/utils.ts index 44287bb..2979c04 100644 --- a/src/channels/utils.ts +++ b/src/channels/utils.ts @@ -1,6 +1,7 @@ /** * Shared utilities for channel adapters. */ +import type { Attachment, InboundMessage } from './types.js'; /** * Split a long message into chunks that respect a platform's character limit. @@ -42,3 +43,26 @@ export function normalizeResetCommandText(text: string): string { } return text; } + +interface ResetMessageParams { + id: string; + channel: InboundMessage['channel']; + senderId: string; + senderName?: string; + timestamp?: number; + attachments?: Attachment[]; +} + +/** Build a normalized inbound reset command message. */ +export function buildResetInboundMessage(params: ResetMessageParams): InboundMessage { + return { + id: params.id, + channel: params.channel, + senderId: params.senderId, + senderName: params.senderName, + text: '!reset', + timestamp: params.timestamp ?? Date.now(), + metadata: { isCommand: true, command: 'reset' }, + ...(params.attachments && params.attachments.length > 0 ? { attachments: params.attachments } : {}), + }; +} diff --git a/src/channels/whatsapp/adapter.ts b/src/channels/whatsapp/adapter.ts index ee1f6fc..7d91f49 100644 --- a/src/channels/whatsapp/adapter.ts +++ b/src/channels/whatsapp/adapter.ts @@ -17,7 +17,7 @@ import type { ChannelAdapter, ChannelStatus, } from '../types.js'; -import { normalizeResetCommandText, splitMessage } from '../utils.js'; +import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the WhatsApp channel adapter. */ @@ -313,16 +313,13 @@ export class WhatsAppAdapter implements ChannelAdapter { // Detect reset command if (text === '!reset') { - this.messageHandler({ + this.messageHandler(buildResetInboundMessage({ id: message.id.id, channel: 'whatsapp', senderId: from, senderName, - text: '!reset', - timestamp: Date.now(), - metadata: { isCommand: true, command: 'reset' }, - ...(attachments.length > 0 ? { attachments } : {}), - }); + attachments, + })); return; }