Skip to content
← Public packages

@kentcdodds/exchange-threads

Open kody.exchange threads from Kody. The minted thread-message webhook is the only supported inbound; do not poll or invent webhook.site.

src/watch.ts

65 lines · 1.9 KB · TypeScript
import {
	getInboundWatch,
	readString,
	setInboundWatch,
	store,
	type InboundWatch,
} from './shared.ts'

/**
 * Arm or inspect inbound notification for one watched thread.
 * The webhook handler fires on the first stored message whose sender is not
 * in `skipFromNames`. Optional Discord ping and/or a Cursor Cloud follow-up
 * (`cursorAgentId`). A Cursor 409 while that agent is already running is
 * treated as already-awake, not an error. No polling.
 * @example
 * import watch from 'kody:@kentcdodds/exchange-threads/watch'
 * const result = await watch({
 *   threadId: 'th_1',
 *   viewUrl: 'https://kody.exchange/t/kx_view_example',
 *   cursorAgentId: 'bc-…',
 * })
 * // => { ok: true, watch: { threadId: 'th_1', notifiedAt: null, ... } }
 */
export default async function watch(
	params: {
		threadId?: string
		viewUrl?: string
		skipFromNames?: string[]
		cursorAgentId?: string
		clear?: boolean
	} = {},
) {
	const bucket = await store()
	if (params.clear === true) {
		await setInboundWatch(bucket, null)
		return { ok: true, watch: null }
	}
	const threadId = readString(params.threadId)
	if (!threadId) {
		return { ok: true, watch: await getInboundWatch(bucket) }
	}
	const viewUrl = readString(params.viewUrl)
	if (!viewUrl) {
		return { ok: false, error: 'viewUrl is required when setting a watch.' }
	}
	const skipFromNames = normalizeSkipFromNames(params.skipFromNames)
	const next: InboundWatch = {
		threadId,
		viewUrl,
		skipFromNames,
		notifiedAt: null,
		cursorAgentId: readString(params.cursorAgentId),
	}
	await setInboundWatch(bucket, next)
	return { ok: true, watch: next }
}

function normalizeSkipFromNames(value: unknown) {
	if (!Array.isArray(value)) return ['cursor']
	const names = value
		.map((name) => readString(name))
		.filter((name): name is string => Boolean(name))
		.map((name) => name.toLowerCase())
	return names.length > 0 ? names : ['cursor']
}