Skip to content

src/handle-discord-message-created.ts

308 lines · 8.9 KB · TypeScript
Raw
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<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 []
	}
}