Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/grok-bot

src/wake.ts

130 lines · 3.5 KB · TypeScript
import {
	buildSaveUrl,
	isSecretPending,
	missingBotError,
	missingSecretError,
	requireBotQuery,
	requirePrompt,
	resolveBot,
	savedPackageId,
} from './registry.ts'

export type WakeInput = {
	/** Bot id or name (case-insensitive). */
	bot: string
	/** Required non-empty prompt Kent is sending to the bot. */
	prompt: string
	/** Resolve and validate the secret without POSTing. */
	dryRun?: boolean
	/** Discord channel the wake was requested from. Passed through in the JSON body. */
	discordChannelId?: string
	/** Discord message that should receive the bot reply. Passed through in the JSON body. */
	discordMessageId?: string
}

export type WakeResult =
	| {
			ok: boolean
			status: number
			bot: { id: string; name: string }
			woke: boolean
	  }
	| {
			ok: true
			dryRun: true
			bot: { id: string; name: string }
			secretName: string
	  }

/**
 * Wake a registered Grok Bot by POSTing `{ id, name, prompt }` to its webhook.
 * Optional `discordChannelId` / `discordMessageId` are included in the JSON
 * body so the bot can reply in Discord.
 *
 * Refuses to POST when the sender-key secret is missing. Use `dryRun: true`
 * to resolve the bot and confirm the secret exists without sending.
 *
 * @param input.bot - Bot id or name
 * @param input.prompt - Non-empty prompt
 * @param input.dryRun - Validate only; do not POST
 * @returns Slim wake result; never a webhook response body
 * @example
 * import wake from 'kody:@kentcdodds/grok-bot/wake'
 * const preview = await wake({
 *   bot: 'ship-pr',
 *   prompt: 'Summarize what you shipped today.',
 *   dryRun: true,
 * })
 * // => { ok: true, dryRun: true, bot: { id, name }, secretName }
 */
export default async function wake(
	input: WakeInput = {} as WakeInput,
): Promise<WakeResult> {
	const botQuery = requireBotQuery(input?.bot)
	const prompt = requirePrompt(input?.prompt)
	const discordChannelId =
		typeof input?.discordChannelId === 'string' ? input.discordChannelId.trim() : ''
	const discordMessageId =
		typeof input?.discordMessageId === 'string' ? input.discordMessageId.trim() : ''
	const record = await resolveBot(botQuery)
	if (!record) throw missingBotError(botQuery)

	const saveUrl = buildSaveUrl({
		secretName: record.secretName,
		name: record.name,
		packageId: savedPackageId(),
	})
	if (await isSecretPending(record.secretName)) {
		throw missingSecretError({
			name: record.name,
			secretName: record.secretName,
			saveUrl,
		})
	}

	if (input?.dryRun === true) {
		return {
			ok: true,
			dryRun: true,
			bot: { id: record.id, name: record.name },
			secretName: record.secretName,
		}
	}

	const controller = new AbortController()
	const timer = setTimeout(() => controller.abort(), 8000)
	try {
		const response = await fetch(record.url, {
			method: 'POST',
			headers: {
				Authorization: `Bearer {{secret:${record.secretName}}}`,
				'X-Automation-Key': `{{secret:${record.secretName}}}`,
				'Content-Type': 'application/json',
			},
			body: JSON.stringify({
				id: record.id,
				name: record.name,
				prompt,
				...(discordChannelId ? { discordChannelId } : {}),
				...(discordMessageId ? { discordMessageId } : {}),
			}),
			signal: controller.signal,
		})
		const ok = response.status === 200
		return {
			ok,
			status: response.status,
			bot: { id: record.id, name: record.name },
			woke: ok,
		}
	} catch (error) {
		if (error instanceof Error && error.name === 'AbortError') {
			throw new Error(
				`Wake timed out after 8s for "${record.name}" (${record.id}). One try, no retry.`,
			)
		}
		throw error
	} finally {
		clearTimeout(timer)
	}
}