import { ok } from "@atcute/client";
import type { Nsid } from "@atcute/lexicons/syntax";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { spindleXrpc, type SpindleTarget } from "#lib/api/spindlewrite.ts";
import { serviceDidForHost } from "#lib/auth/agent.ts";
import type { Permission } from "#lib/auth/scopes.ts";
import { mainSchema as createWebhookSchema } from "#lib/api/lexicons/types/org/tangled/webhook/createWebhook.ts";
import type {
Delivery as RawDelivery,
Webhook
} from "#lib/api/lexicons/types/org/tangled/webhook/defs.ts";
import { mainSchema as deleteWebhookSchema } from "#lib/api/lexicons/types/org/tangled/webhook/deleteWebhook.ts";
import { mainSchema as listDeliveriesSchema } from "#lib/api/lexicons/types/org/tangled/webhook/listWebhookDeliveries.ts";
import { mainSchema as listWebhooksSchema } from "#lib/api/lexicons/types/org/tangled/webhook/listWebhooks.ts";
import { mainSchema as retryDeliverySchema } from "#lib/api/lexicons/types/org/tangled/webhook/retryWebhookDelivery.ts";
import { mainSchema as updateWebhookSchema } from "#lib/api/lexicons/types/org/tangled/webhook/updateWebhook.ts";
import { didOf } from "#lib/api/syntax.ts";
export type { Webhook };
// request and response bodies arrive as lexicon bytes; the UI shows them as text.
export interface Delivery extends Omit<RawDelivery, "requestBody" | "responseBody"> {
requestBody?: string;
responseBody?: string;
}
export const WEBHOOK_EVENTS = [
{ id: "push", label: "Push events" },
{ id: "repository:renamed", label: "Repository renamed" },
{ id: "pull_request:created", label: "Pull request opened" },
{ id: "pull_request:resubmitted", label: "Pull request resubmitted" },
{ id: "pull_request:merged", label: "Pull request merged" },
{ id: "pull_request:closed", label: "Pull request closed" },
{ id: "pull_request:reopened", label: "Pull request reopened" }
] as const;
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]["id"];
export type WebhookEventSelection = Record<WebhookEvent, boolean>;
export type WebhookActivation = "active" | "inactive";
export const webhookActivation = (active: boolean): WebhookActivation =>
active ? "active" : "inactive";
export const webhookIsActive = (activation: WebhookActivation): boolean => activation === "active";
const WEBHOOK_METHODS = [
createWebhookSchema.nsid,
deleteWebhookSchema.nsid,
listDeliveriesSchema.nsid,
listWebhooksSchema.nsid,
retryDeliverySchema.nsid,
updateWebhookSchema.nsid
] as const;
export const webhookPermissions = (spindle: URL): readonly Permission[] => {
const aud = didOf(serviceDidForHost(spindle.host));
return WEBHOOK_METHODS.map((lxm) => ({ resource: "rpc", lxm: lxm as Nsid, aud }));
};
export const webhookEventSelection = (
events: readonly string[] = ["push"]
): WebhookEventSelection =>
Object.fromEntries(
WEBHOOK_EVENTS.map(({ id }) => [id, events.includes(id)])
) as WebhookEventSelection;
export const selectedWebhookEvents = (selection: WebhookEventSelection): WebhookEvent[] =>
WEBHOOK_EVENTS.flatMap(({ id }) => (selection[id] ? [id] : []));
export const isWebhookUrl = (value: string): boolean => {
try {
return ["http:", "https:"].includes(new URL(value.trim()).protocol);
} catch {
return false;
}
};
type GenericUri = `${string}:${string}`;
const url = (value: string): GenericUri => {
const normalized = value.trim();
if (!isWebhookUrl(normalized)) throw new Error("Webhook URL must use HTTP or HTTPS.");
return normalized as GenericUri;
};
export const listWebhooks = async (
agent: OAuthUserAgent,
spindle: URL,
target: SpindleTarget
): Promise<Webhook[]> => {
const { webhooks } = await ok(
(await spindleXrpc(agent, spindle, target)).call(listWebhooksSchema, {
params: { repo: target.repoDid }
})
);
return webhooks ?? [];
};
export interface CreateWebhook {
url: string;
events: WebhookEvent[];
secret?: string;
active?: boolean;
}
export const createWebhook = async (
agent: OAuthUserAgent,
spindle: URL,
target: SpindleTarget,
webhook: CreateWebhook
): Promise<string> => {
const { id } = await ok(
(await spindleXrpc(agent, spindle, target)).call(createWebhookSchema, {
input: { ...webhook, repo: target.repoDid, url: url(webhook.url) }
})
);
return id;
};
export interface UpdateWebhook {
url?: string;
events?: WebhookEvent[];
secret?: string;
active?: boolean;
}
export const updateWebhook = async (
agent: OAuthUserAgent,
spindle: URL,
target: SpindleTarget,
id: string,
webhook: UpdateWebhook
): Promise<void> => {
const { url: webhookUrl, ...changes } = webhook;
await ok(
(await spindleXrpc(agent, spindle, target)).call(updateWebhookSchema, {
input: {
...changes,
repo: target.repoDid,
webhook: id,
...(webhookUrl === undefined ? {} : { url: url(webhookUrl) })
}
})
);
};
export const deleteWebhook = async (
agent: OAuthUserAgent,
spindle: URL,
target: SpindleTarget,
id: string
): Promise<void> => {
await ok(
(await spindleXrpc(agent, spindle, target)).call(deleteWebhookSchema, {
input: { repo: target.repoDid, webhook: id }
})
);
};
export const listWebhookDeliveries = async (
agent: OAuthUserAgent,
spindle: URL,
target: SpindleTarget,
ids: string[],
limit = 100
): Promise<Delivery[]> => {
const { deliveries } = await ok(
(await spindleXrpc(agent, spindle, target)).call(listDeliveriesSchema, {
params: { repo: target.repoDid, webhooks: ids, limit }
})
);
return (deliveries ?? []).map(({ requestBody, responseBody, ...delivery }) => ({
...delivery,
requestBody: requestBody && text(requestBody),
responseBody: responseBody && text(responseBody)
}));
};
const text = ({ $bytes }: { $bytes: string }): string =>
new TextDecoder().decode(Uint8Array.from(atob($bytes), (c) => c.charCodeAt(0)));
export const retryWebhookDelivery = async (
agent: OAuthUserAgent,
spindle: URL,
target: SpindleTarget,
webhookId: string,
deliveryId: string
): Promise<void> => {
await ok(
(await spindleXrpc(agent, spindle, target)).call(retryDeliverySchema, {
input: { repo: target.repoDid, webhook: webhookId, delivery: deliveryId }
})
);
};