Skip to content

src/wake.ts

247 lines · 7.9 KB · TypeScript
Raw
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<WakeResult> {
	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<WakeResult> {
	return wakeDirect(input)
}