← Public packages
@kentcdodds/grok-bot
Wake a named Grok Bot from Kody by posting a prompt to that bot's own Cursor webhook.
src/wake.ts
247 lines · 7.9 KB · TypeScriptimport {
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)
}