← Public packages
@kentcdodds/grok-bot
Wake a named Grok Bot from Kody by posting a prompt to that bot's own Cursor webhook.
src/handle-discord-message-created.ts
308 lines · 8.9 KB · TypeScriptimport 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<string, unknown>
dryRun?: boolean
/** Platform synthetic subscription smoke may set this; empty payloads soft-skip. */
synthetic?: boolean
[key: string]: unknown
}
function unwrapPackageEvent(input: Record<string, unknown>) {
if (
input?.event === '@kentcdodds/discord.message.created' &&
input?.payload &&
typeof input.payload === 'object'
) {
return input.payload as Record<string, unknown>
}
return input
}
function pickDryRun(...values: unknown[]): boolean {
return values.some((value) => value === true)
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<Record<string, unknown>> {
const params = unwrapPackageEvent(input as Record<string, unknown>)
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<void> } | 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<Array<DiscordImage>> {
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 []
}
}