import { buildSaveUrl, getSecretUseStatus, hostApprovalError, missingBotError, missingSecretError, requireBotQuery, requirePrompt, resolveBot, savedPackageId, secretPlaceholder, isForeignPackageIsolate, } 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 /** Kody inbox message id this wake came from. Passed through in the JSON body. */ emailMessageId?: string /** Kody inbox thread id this wake came from. Passed through in the JSON body. */ emailThreadId?: string /** Plus-alias tag (bot name) that routed this mail. Passed through in the JSON body. */ emailAlias?: string /** Full plus-alias address that routed this mail. Passed through in the JSON body. */ emailAddress?: string /** Discord image attachment URLs so the bot can see the actual image. */ images?: Array<{ url: string; filename?: string; contentType?: string }> } /** Client wait for the webhook ACK. One try; a retry would double-wake. */ export const WAKE_TIMEOUT_MS = 8_000 export type WakePostedResult = { ok: boolean status: number bot: { id: string; name: string } woke: boolean timedOut?: boolean timeoutMs?: number } export type WakeResult = | WakePostedResult | { ok: true dryRun: true bot: { id: string; name: string } secretName: string } export function isWakeTimedOut( result: WakeResult, ): result is WakePostedResult & { timedOut: true; timeoutMs: number } { return 'timedOut' in result && result.timedOut === true } function normalizeWakeFields(input: WakeInput = {} as WakeInput) { 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 emailMessageId = typeof input?.emailMessageId === 'string' ? input.emailMessageId.trim() : '' const emailThreadId = typeof input?.emailThreadId === 'string' ? input.emailThreadId.trim() : '' const emailAlias = typeof input?.emailAlias === 'string' ? input.emailAlias.trim() : '' const emailAddress = typeof input?.emailAddress === 'string' ? input.emailAddress.trim() : '' const images = Array.isArray(input?.images) ? input.images .filter((image) => image && typeof image.url === 'string' && image.url.trim()) .slice(0, 4) .map((image) => ({ url: image.url.trim(), ...(typeof image.filename === 'string' && image.filename.trim() ? { filename: image.filename.trim() } : {}), ...(typeof image.contentType === 'string' && image.contentType.trim() ? { contentType: image.contentType.trim() } : {}), })) : [] return { botQuery, prompt, dryRun: input?.dryRun === true, discordChannelId, discordMessageId, emailMessageId, emailThreadId, emailAlias, emailAddress, images, } } /** * Direct POST path using this package's secret context (stamp-aligned for * static `kody:@kentcdodds/grok-bot/wake` imports from dependents). * Used by `wake`, same-package callers, and `handle-wake` (webhook/export). */ export async function wakeDirect( input: WakeInput = {} as WakeInput, ): Promise { const fields = normalizeWakeFields(input) const record = await resolveBot(fields.botQuery) if (!record) throw missingBotError(fields.botQuery) const saveUrl = buildSaveUrl({ secretName: record.secretName, name: record.name, packageId: savedPackageId(), }) const secret = await getSecretUseStatus(record.secretName) const foreign = isForeignPackageIsolate() // Dependent webhook/job isolates often cannot *list* grok-bot package-scoped // sender keys even when stamp-aligned fetch would succeed. Do not false-refuse. if (!secret.exists && !foreign) { throw missingSecretError({ name: record.name, secretName: record.secretName, saveUrl, }) } if (secret.exists && secret.needsHostApproval) { throw hostApprovalError({ name: record.name, secretName: record.secretName, approvalUrl: secret.approvalUrl || buildSaveUrl({ secretName: record.secretName, name: record.name, packageId: savedPackageId(), }), }) } if (fields.dryRun) { return { ok: true, dryRun: true, bot: { id: record.id, name: record.name }, secretName: record.secretName, } } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), WAKE_TIMEOUT_MS) try { const placeholder = secretPlaceholder( record.secretName, secret.usableScope ?? (foreign ? 'package' : null), ) const response = await fetch(record.url, { method: 'POST', headers: { Authorization: `Bearer ${placeholder}`, 'X-Automation-Key': placeholder, 'Content-Type': 'application/json', }, body: JSON.stringify({ id: record.id, name: record.name, prompt: fields.prompt, ...(fields.discordChannelId ? { discordChannelId: fields.discordChannelId } : {}), ...(fields.discordMessageId ? { discordMessageId: fields.discordMessageId } : {}), ...(fields.emailMessageId ? { emailMessageId: fields.emailMessageId } : {}), ...(fields.emailThreadId ? { emailThreadId: fields.emailThreadId } : {}), ...(fields.emailAlias ? { emailAlias: fields.emailAlias } : {}), ...(fields.emailAddress ? { emailAddress: fields.emailAddress } : {}), ...(fields.images.length ? { images: fields.images } : {}), }), 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') { return { ok: false, status: 0, bot: { id: record.id, name: record.name }, woke: false, timedOut: true, timeoutMs: WAKE_TIMEOUT_MS, } } throw error } finally { clearTimeout(timer) } } /** * 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. Optional `emailMessageId`, * `emailThreadId`, `emailAlias`, and `emailAddress` are included when the * wake came from a plus-alias inbox message. * * Refuses to POST when the sender-key secret is missing or the webhook host * is not approved. Use `dryRun: true` to resolve the bot and confirm the * secret can be used without sending. A late webhook ACK returns * `{ woke: false, timedOut: true }` after 8s — one try, no retry — instead of * throwing. Non-200 responses are the same soft `{ woke: false }` shape. * * From another package, prefer static * `import wake from 'kody:@kentcdodds/grok-bot/wake'` (list `@kentcdodds/grok-bot` * in `kody.dependencies`). Stamp-aligned secret authority keeps * `grokBotWake.*` keys locked to grok-bot. Do not use * `packageSubscriptionDispatch`, auto-dispatch detours, or * `grokBotWakeWebhookUrl`. Webhook/export entry remains `./handle-wake`. * * @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 { return wakeDirect(input) }