import type { Env } from "../types"; export type DeliveryStatus = "pending" | "processing" | "delivered" | "retrying" | "failed" | "dead"; export interface DeliveryMessage { deliveryId: string; groupId?: string; provider: string; event: string; payload?: Record; payloadRef?: string; installationId?: number; receivedAt: number; requestId?: string; } export interface DispatchFailure { target: string; error?: string; errorCode?: string; status?: number; } export interface DispatchSummary { attempts: number; failures: DispatchFailure[]; } export const DELIVERY_QUEUE = "webhooker-delivery"; export const DELIVERY_DLQ = "webhooker-delivery-dlq"; const MAX_QUEUE_MESSAGE_BYTES = 100_000; const PAYLOAD_KV_TTL_SECONDS = 60 * 60 * 24; const STATE_KV_TTL_SECONDS = 60 * 60 * 24; const RETRYABLE_ERROR_CODES = new Set(["DISCORD_5XX", "TELEGRAM_5XX", "NETWORK", "RETRIES"]); const RETRY_DELAYS_SECONDS = [5, 30, 120, 600]; export function isRetryableError(code?: string): boolean { return code == null || RETRYABLE_ERROR_CODES.has(code); } export function classifyDelivery(summary: DispatchSummary): { failed: boolean; retryable: boolean; } { if (summary.failures.length === 0) return { failed: false, retryable: false }; const retryable = summary.failures.every((f) => isRetryableError(f.errorCode)); const permanent = summary.failures.some((f) => !isRetryableError(f.errorCode)); return { failed: true, retryable: retryable && !permanent }; } export function retryDelay(attempt: number): number { if (attempt < 1) return RETRY_DELAYS_SECONDS[0]; const idx = Math.min(attempt - 1, RETRY_DELAYS_SECONDS.length - 1); return RETRY_DELAYS_SECONDS[idx]; } function scopeKey(provider: string, groupId: string | undefined, deliveryId: string): string { return `${provider}:${groupId ?? "global"}:${deliveryId}`; } export function deliveryStateKey( provider: string, groupId: string | undefined, deliveryId: string, ): string { return `delivery-state:${scopeKey(provider, groupId, deliveryId)}`; } function payloadKey(provider: string, groupId: string | undefined, deliveryId: string): string { return `queue:payload:${scopeKey(provider, groupId, deliveryId)}`; } export async function setDeliveryState( env: Env, key: string, status: DeliveryStatus, ): Promise { await env.KV.put(key, JSON.stringify({ status, at: Date.now() }), { expirationTtl: STATE_KV_TTL_SECONDS, }); } export async function getDeliveryState(env: Env, key: string): Promise { const raw = await env.KV.get(key); if (!raw) return null; try { return (JSON.parse(raw) as { status?: DeliveryStatus }).status ?? null; } catch { return null; } } export async function enqueueWebhook(env: Env, message: DeliveryMessage): Promise { const queue = env.QUEUE; if (!queue) throw new Error("QUEUE binding is not configured"); const { payload, ...rest } = message; const direct: DeliveryMessage = { ...rest, payload }; if (JSON.stringify(direct).length <= MAX_QUEUE_MESSAGE_BYTES) { await queue.send(direct); return; } const payloadRef = payloadKey(message.provider, message.groupId, message.deliveryId); await env.KV.put(payloadRef, JSON.stringify(payload ?? {}), { expirationTtl: PAYLOAD_KV_TTL_SECONDS, }); await queue.send({ ...rest, payloadRef }); } export async function resolvePayload( env: Env, message: DeliveryMessage, ): Promise> { if (message.payload) return message.payload; if (message.payloadRef) { const raw = await env.KV.get(message.payloadRef); if (raw) { try { return JSON.parse(raw) as Record; } catch { return {}; } } } return {}; } export async function discardPayload(env: Env, message: DeliveryMessage): Promise { if (message.payloadRef) await env.KV.delete(message.payloadRef); }