Skip to content

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

Package listing

@kentcdodds/grok-bot

src/register.ts

156 lines · 4.2 KB · TypeScript
import createGuildChannel from 'kody:@kentcdodds/discord/create-guild-channel'
import getConfig from 'kody:@kentcdodds/discord/get-config'
import {
	channelSlug,
	getMappingByBotId,
	getMappingByName,
	readCategoryId,
	writeChannelMapping,
} from './discord-map.ts'
import {
	buildSaveUrl,
	deleteAlias,
	isSecretPending,
	normalizeName,
	parseBotRecord,
	parseWebhookUrl,
	requireId,
	requireName,
	savedPackageId,
	secretNameFor,
	urlHostOf,
	writeBotRecord,
	type BotRecord,
} from './registry.ts'
import { packageStorage } from 'kody:runtime'

export type RegisterInput = {
	/** Grok Bot UUID. */
	id: string
	/** Human name used as a case-insensitive alias. */
	name: string
	/** HTTPS webhook URL. Host must be exactly api2.cursor.sh. */
	url: string
}

export type RegisterResult = {
	id: string
	name: string
	secretName: string
	urlHost: string
	saveUrl: string
	pendingSecret: boolean
	channelId: string | null
}

async function ensureDiscordChannel(input: {
	id: string
	name: string
}): Promise<string> {
	const slug = channelSlug(input.name)
	const byBot = await getMappingByBotId(input.id)
	if (byBot) return byBot.channelId
	const byName = slug ? await getMappingByName(slug) : null
	if (byName) {
		if (byName.botId !== input.id) {
			await writeChannelMapping({
				channelId: byName.channelId,
				botId: input.id,
				name: slug || byName.name,
			})
		}
		return byName.channelId
	}
	const categoryId = await readCategoryId()
	const config = (await getConfig()) as { serverId?: string }
	const created = (await createGuildChannel({
		guildId: config.serverId,
		name: slug || channelSlug(input.name),
		parentId: categoryId,
		type: 0,
	})) as { channelId?: string; id?: string; name?: string }
	const channelId = String(created.channelId || created.id || '').trim()
	if (!channelId) {
		throw new Error('create-guild-channel did not return a channel id.')
	}
	await writeChannelMapping({
		channelId,
		botId: input.id,
		name: slug || created.name || input.name,
	})
	return channelId
}

/**
 * Register or update a Grok Bot webhook roster row.
 *
 * Stores id, name, aliases, and URL in packageStorage. Never accepts or
 * persists a sender key. After register, open `saveUrl` and paste the key
 * there. Ensures a text channel under the Grok Bot Discord category (reuses
 * the existing slug when present) and stores `channelId` on the roster row.
 *
 * @param input.id - Grok Bot UUID
 * @param input.name - Human name used as an alias
 * @param input.url - HTTPS webhook on api2.cursor.sh
 * @returns Registration result with saveUrl, pendingSecret, and channelId
 * @example
 * import register from 'kody:@kentcdodds/grok-bot/register'
 * const result = await register({
 *   id: '11111111-1111-1111-1111-111111111111',
 *   name: 'ship-pr',
 *   url: 'https://api2.cursor.sh/automations/webhook/example',
 * })
 * // => { id, name, secretName, urlHost: 'api2.cursor.sh', saveUrl, pendingSecret, channelId }
 */
export default async function register(
	input: RegisterInput = {} as RegisterInput,
): Promise<RegisterResult> {
	const id = requireId(input?.id)
	const name = requireName(input?.name)
	const webhook = parseWebhookUrl(input?.url)
	const now = new Date().toISOString()
	const secretName = secretNameFor(id)
	const normalized = normalizeName(name)
	const bucket = packageStorage()
	const existing = parseBotRecord(await bucket.get(`bot:${id}`))

	const aliases = existing
		? Array.from(new Set([...existing.aliases.filter((alias) => alias !== normalizeName(existing.name)), normalized]))
		: [normalized]

	if (existing) {
		const previous = normalizeName(existing.name)
		if (previous && previous !== normalized) {
			await deleteAlias(previous)
		}
	}

	const channelId = await ensureDiscordChannel({ id, name })
	const record: BotRecord = {
		id,
		name,
		aliases,
		url: webhook.toString(),
		secretName,
		channelId,
		createdAt: existing?.createdAt ?? now,
		updatedAt: now,
	}
	await writeBotRecord(record)
	await writeChannelMapping({ channelId, botId: id, name: channelSlug(name) || name })

	const saveUrl = buildSaveUrl({
		secretName,
		name,
		packageId: savedPackageId(),
	})
	return {
		id,
		name,
		secretName,
		urlHost: urlHostOf(record.url),
		saveUrl,
		pendingSecret: await isSecretPending(secretName),
		channelId: record.channelId || channelId,
	}
}