import postMessage from 'kody:@kentcdodds/discord/post-message' import startTyping from 'kody:@kentcdodds/discord/start-typing-indicator' import normalizeMessageCreated from 'kody:@kentcdodds/discord/normalize-message-created' import fetchMessages from 'kody:@kentcdodds/discord/fetch-discord-channel-messages' import { resolveChannelMapping } from './discord-map.ts' import { buildImageWakePrompt, collectDiscordImages, type DiscordImage, } from './discord-attachments.ts' import { botKey, getSecretUseStatus, parseBotRecord, WEBHOOK_HOST, } from './registry.ts' import wake, { isWakeTimedOut } from './wake.ts' import { packageStorage } from 'kody:runtime' import { storageGet } from './plain.ts' export type HandleDiscordMessageCreatedInput = { event?: Record dryRun?: boolean /** Platform synthetic subscription smoke may set this; empty payloads soft-skip. */ synthetic?: boolean [key: string]: unknown } function unwrapPackageEvent(input: Record) { if ( input?.event === '@kentcdodds/discord.message.created' && input?.payload && typeof input.payload === 'object' ) { return input.payload as Record } return input } function pickDryRun(...values: unknown[]): boolean { return values.some((value) => value === true) } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null } function pickNonEmptyString(...values: unknown[]): string | null { for (const value of values) { if (typeof value === 'string' && value.trim()) return value.trim() } return null } /** * Mirror the channelId keys `@kentcdodds/discord/normalize-message-created` * accepts so we can soft-skip before that helper throws on empty synthetic * subscription smoke payloads (`channelId is required.`). */ function pickChannelIdHint(source: unknown): string | null { const outer = asRecord(source) if (!outer) return null const event = asRecord(outer.event) ?? {} const nestedMessage = asRecord(event.message) ?? {} const payload = Object.keys(nestedMessage).length > 0 ? nestedMessage : Object.keys(event).length > 0 ? event : outer return pickNonEmptyString( outer.channelId, outer.channel_id, event.channelId, event.channel_id, payload.channelId, payload.channel_id, ) } /** * Production `@kentcdodds/discord.message.created` subscriber for Grok Bot channels. * * A Kent-authored message in a mapped per-bot channel wakes that bot. Unmapped * channels return `{ handled: false }` so other subscribers can run. Missing * `channelId` (including empty synthetic subscription smoke) returns * `{ handled: false, reason: 'missing-channel-id' }` instead of throwing. * A late webhook ACK returns `{ handled: true, reason: 'wake-timed-out' }` and * replies in-channel instead of throwing. * * @param params.event - Normalized or raw message.created event from Discord dispatch * @param params.dryRun - Inspect routing without posting or waking * @returns Handler result with handled flag and bot/channel metadata * @example * import handleDiscordMessageCreated from 'kody:@kentcdodds/grok-bot/handle-discord-message-created' * const result = await handleDiscordMessageCreated({ * dryRun: true, * event: { channelId: '1542616408020619334', messageId: '1', content: 'hi', author: { id: '105755735731781632' } }, * }) * // => { handled: true, reason: 'bot-not-registered-for-wake', bot: { id, name } } */ export default async function handleDiscordMessageCreated( input: HandleDiscordMessageCreatedInput = {}, ): Promise> { const params = unwrapPackageEvent(input as Record) const dryRun = pickDryRun(input.dryRun, params.dryRun) const normalizeSource = params.event || params // Synthetic subscription smoke (and other empty envelopes) omit channelId. // normalizeMessageCreated throws "channelId is required." - soft-skip so // Activity does not fingerprint a standing subscription failure. if ( !pickChannelIdHint(normalizeSource) && !pickChannelIdHint(params) && !pickChannelIdHint(input) ) { return { subscriber: 'grok-bot', handled: false, reason: 'missing-channel-id', synthetic: input.synthetic === true || params.synthetic === true, } } const event = await normalizeMessageCreated(normalizeSource) const mapping = await resolveChannelMapping(event) if (event.author?.bot === true) { return { subscriber: 'grok-bot', handled: false, reason: 'ignored-bot-author', } } const content = typeof event.content === 'string' ? event.content.trim() : '' let images = collectDiscordImages(input) if (!images.length) images = collectDiscordImages(params.event || params) if (!images.length && event.channelId && event.messageId && mapping) { images = await loadImagesFromDiscord(event.channelId, event.messageId) } if (!content && images.length === 0) { return { subscriber: 'grok-bot', handled: Boolean(mapping), reason: 'ignored-empty-content', channelId: event.channelId, } } if (!mapping) { return { subscriber: 'grok-bot', handled: false, reason: 'unmapped-channel', channelId: event.channelId, } } const record = parseBotRecord( await storageGet((key) => packageStorage().get(key), botKey(mapping.botId)), ) const secret = record ? await getSecretUseStatus(record.secretName) : { exists: false, usableScope: null, needsHostApproval: false, approvalUrl: null, allowedHosts: [], } const pendingSecret = !secret.exists const hasUrl = Boolean(record?.url) if (!hasUrl || pendingSecret) { const notice = `${mapping.name} is not registered for wake yet. Call register with this bot's webhook URL, then save the sender key on kody.codes.` const posted = dryRun === true ? null : await postMessage({ channelId: event.channelId, content: notice, replyToMessageId: event.messageId, }) return { subscriber: 'grok-bot', handled: true, reason: 'bot-not-registered-for-wake', bot: { id: mapping.botId, name: mapping.name }, channelId: mapping.channelId, messageId: event.messageId, pendingSecret, hasUrl, dryRun, posted, } } if (secret.needsHostApproval) { const notice = secret.approvalUrl ? `${mapping.name} needs host approval for ${WEBHOOK_HOST} before it can wake. Open ${secret.approvalUrl} and approve that host.` : `${mapping.name} needs host approval for ${WEBHOOK_HOST} before it can wake.` const posted = dryRun === true ? null : await postMessage({ channelId: event.channelId, content: notice, replyToMessageId: event.messageId, }) return { subscriber: 'grok-bot', handled: true, reason: 'host-approval-required', bot: { id: mapping.botId, name: mapping.name }, channelId: mapping.channelId, messageId: event.messageId, secretName: record?.secretName, approvalUrl: secret.approvalUrl, dryRun, posted, } } let typing: { info?: unknown; stop?: () => Promise } | null = null try { typing = await startTyping({ channelId: event.channelId, dryRun, }) } catch { typing = null } try { const woke = await wake({ bot: mapping.botId, prompt: images.length ? buildImageWakePrompt({ botName: mapping.name, content, images }) : content, discordChannelId: event.channelId, discordMessageId: event.messageId, ...(images.length ? { images } : {}), dryRun, }) if (isWakeTimedOut(woke)) { const notice = `${mapping.name} did not accept the wake in time. I did not retry.` const posted = dryRun === true ? null : await postMessage({ channelId: event.channelId, content: notice, replyToMessageId: event.messageId, }) return { subscriber: 'grok-bot', handled: true, reason: 'wake-timed-out', bot: { id: mapping.botId, name: mapping.name }, channelId: mapping.channelId, messageId: event.messageId, dryRun, wake: woke, posted, typingIndicator: typing?.info ?? null, } } return { subscriber: 'grok-bot', handled: true, reason: 'woke', bot: { id: mapping.botId, name: mapping.name }, channelId: mapping.channelId, messageId: event.messageId, dryRun, wake: woke, typingIndicator: typing?.info ?? null, } } finally { if (typeof typing?.stop === 'function') { await typing.stop() } } } async function loadImagesFromDiscord( channelId: string, messageId: string, ): Promise> { try { const result = await fetchMessages({ channelId, limit: 20 }) const rows = Array.isArray(result) ? result : Array.isArray((result as { messages?: unknown }).messages) ? (result as { messages: unknown[] }).messages : [result] const found = rows.find((row) => { if (!row || typeof row !== 'object') return false const id = (row as { id?: unknown }).id return id === messageId }) return found ? collectDiscordImages(found) : [] } catch { return [] } }