From a0755ff1e6a92557f2e8133dac45d41c43c4bdb8 Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Wed, 22 Apr 2026 18:04:35 -0400
Subject: [PATCH 1/9] Added base files needed for google docs intigration.
---
README.md | 2 ++
googledocs/index.js | 51 ++++++++++++++++++++++++++++++++++++++++
googledocs/package.json | 15 ++++++++++++
googledocs/wrangler.toml | 15 ++++++++++++
proxy/index.js | 1 +
proxy/wrangler.toml | 4 ++++
6 files changed, 88 insertions(+)
create mode 100644 googledocs/index.js
create mode 100644 googledocs/package.json
create mode 100644 googledocs/wrangler.toml
diff --git a/README.md b/README.md
index b91f2e6..b67f698 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ The `proxy` Worker sits in front of the others: callers authenticate against the
| [`proxy/`](./proxy) | Shared auth gate and router. Forwards requests for known hosts to sibling Workers via service bindings; everything else falls through to a public `fetch()`. |
| [`fivem/`](./fivem) | Looks up a FiveM server by ID and resolves a Discord snowflake to a player on that server. |
| [`bloxlink/`](./bloxlink) | Resolves a Discord user to their linked Roblox account via Bloxlink, with a KV-backed cache. |
+| [`googledocs/`](./googledocs) | Scaffold for writing ticket data to a Google Sheet (placeholder only; API calls not implemented yet). |
(back to top)
@@ -98,6 +99,7 @@ New integrations are picked up automatically — no workflow edits needed.
| `proxy` | `PROXY_AUTH_KEY` | Shared token expected in that header. |
| `fivem` | `FIVEM_AUTH_KEY` | Token expected in the `Authorization` header. |
| `bloxlink` | `BLOXLINK_AUTH_KEY` | Token expected in the `X-Tickets-Auth` header. |
+| `googledocs` | `GOOGLEDOCS_AUTH_KEY` | Token expected in the `X-Tickets-Auth` header. |
`SENTRY_DSN` for each Worker is configured in its `wrangler.toml` under `[vars]`.
diff --git a/googledocs/index.js b/googledocs/index.js
new file mode 100644
index 0000000..f864e3f
--- /dev/null
+++ b/googledocs/index.js
@@ -0,0 +1,51 @@
+import * as Sentry from "@sentry/cloudflare";
+
+function jsonResponse(status, body) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+async function handleRequest(request, env) {
+ if (request.method !== "POST") {
+ return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
+ status: 405,
+ headers: {
+ "content-type": "application/json",
+ allow: "POST",
+ },
+ });
+ }
+
+ if (request.headers.get("X-Tickets-Auth") !== env.GOOGLEDOCS_AUTH_KEY) {
+ return jsonResponse(401, { error: "Invalid auth key" });
+ }
+
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return jsonResponse(400, { error: "Invalid request body" });
+ }
+
+ // Placeholder response so the Worker can be deployed and wired before
+ // Google Sheets writing is implemented.
+ return jsonResponse(501, {
+ error: "Google Docs integration is not implemented yet",
+ received: body,
+ });
+}
+
+export default Sentry.withSentry(
+ (env) => ({
+ dsn: env.SENTRY_DSN,
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+ }),
+ {
+ async fetch(request, env) {
+ return handleRequest(request, env);
+ },
+ },
+);
diff --git a/googledocs/package.json b/googledocs/package.json
new file mode 100644
index 0000000..da4626c
--- /dev/null
+++ b/googledocs/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "googledocs-integration",
+ "private": true,
+ "main": "index.js",
+ "scripts": {
+ "deploy": "wrangler deploy",
+ "dev": "wrangler dev"
+ },
+ "dependencies": {
+ "@sentry/cloudflare": "^10.49.0"
+ },
+ "devDependencies": {
+ "wrangler": "^4.83.0"
+ }
+}
diff --git a/googledocs/wrangler.toml b/googledocs/wrangler.toml
new file mode 100644
index 0000000..db55787
--- /dev/null
+++ b/googledocs/wrangler.toml
@@ -0,0 +1,15 @@
+name = "googledocs"
+main = "index.js"
+compatibility_date = "2025-01-01"
+compatibility_flags = ["nodejs_compat"]
+
+[observability.logs]
+enabled = true
+
+[vars]
+SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13"
+
+# Secrets (set via `wrangler secret put `):
+# GOOGLEDOCS_AUTH_KEY — token expected in the X-Tickets-Auth header
+# GOOGLEDOCS_SPREADSHEET_ID — target Google Sheets spreadsheet ID
+# GOOGLEDOCS_SERVICE_ACCOUNT_JSON — service account JSON for Google APIs
diff --git a/proxy/index.js b/proxy/index.js
index d8ca0d9..60a094d 100644
--- a/proxy/index.js
+++ b/proxy/index.js
@@ -16,6 +16,7 @@ const SERVICE_BINDINGS = {
"fivem.tickets-v2.workers.dev": "FIVEM",
"guild-lookup-worker.tickets-v2.workers.dev": "GUILDLOOKUP",
"bloxlink.tickets-v2.workers.dev": "BLOXLINK",
+ "googledocs.tickets-v2.workers.dev": "GOOGLEDOCS",
};
function errorResponse(status, message) {
diff --git a/proxy/wrangler.toml b/proxy/wrangler.toml
index a34afd4..bd9e5b7 100644
--- a/proxy/wrangler.toml
+++ b/proxy/wrangler.toml
@@ -23,6 +23,10 @@ service = "guild-lookup-worker"
binding = "BLOXLINK"
service = "bloxlink"
+[[services]]
+binding = "GOOGLEDOCS"
+service = "googledocs"
+
# Secrets (set via `wrangler secret put `):
# PROXY_AUTH_HEADER — name of the header callers send the auth token in
# PROXY_AUTH_KEY — shared token expected in that header
From 9da3f834e5761c697d1fdfc817211f02c6d7df99 Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Tue, 28 Apr 2026 10:33:18 -0400
Subject: [PATCH 2/9] First upload of real code. This is a baseline program to
handle tickets bot interaction.
Co-authored-by: Copilot
---
.gitignore | 1 +
googledocs/index.js | 1257 ++++++++++++++++++++++++++-
googledocs/package-lock.json | 1546 ++++++++++++++++++++++++++++++++++
googledocs/wrangler.toml | 7 +-
4 files changed, 2795 insertions(+), 16 deletions(-)
create mode 100644 googledocs/package-lock.json
diff --git a/.gitignore b/.gitignore
index 0dcc8a4..60b6c82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules/
.wrangler/
+RankBloxTicket+/
diff --git a/googledocs/index.js b/googledocs/index.js
index f864e3f..9d37cd7 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -1,5 +1,40 @@
import * as Sentry from "@sentry/cloudflare";
+const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
+const GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
+const DEFAULT_SHEET_TAB = "Tickets";
+const DEFAULT_SPREADSHEET_ID_HEADER = "X-Tickets-Google-Sheets-Id";
+const DEFAULT_SHEET_TAB_HEADER = "X-Tickets-Google-Sheet-Tab";
+const GOOGLE_WRITE_MAX_ATTEMPTS = 2;
+const GOOGLE_WRITE_DEBOUNCE_MS = 750;
+const GOOGLE_WRITE_MIN_INTERVAL_MS = 1500;
+const BOT_API_URL_HEADER = "X-Tickets-Bot-Api-Url";
+const BOT_API_SECRET_HEADER = "X-Tickets-Bot-Api-Secret";
+const TICKET_COLUMNS = 11;
+const TICKET_HEADERS = [
+ "Ticket ID",
+ "Channel ID",
+ "Status",
+ "Guild ID",
+ "User ID",
+ "Claimed By",
+ "Close Requested By",
+ "Opened At",
+ "First Reply At",
+ "Closed At",
+ "Panel Title",
+];
+
+let cachedAccessToken = null;
+let cachedAccessTokenExpiry = 0;
+let sheetsInitialized = false;
+let initializedSheetKey = null;
+const sheetRowByChannelId = new Map();
+const sheetRowByTicketId = new Map();
+const googleSheetsWriteQueueByKey = new Map();
+let googleSheetsGlobalWriteChain = Promise.resolve();
+let googleSheetsLastWriteAt = 0;
+
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
@@ -7,33 +42,1225 @@ function jsonResponse(status, body) {
});
}
-async function handleRequest(request, env) {
- if (request.method !== "POST") {
- return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
- status: 405,
+function errorResponse(status, message) {
+ return jsonResponse(status, { error: message });
+}
+
+function getHeaderValue(request, name) {
+ const value = request.headers.get(name);
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+function normalizeConfiguredTicketValue(value) {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+function getSpreadsheetConfig(request, env) {
+ const spreadsheetId =
+ getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER) ||
+ readRequiredEnv(env, "GOOGLEDOCS_SPREADSHEET_ID");
+ const sheetTab =
+ getHeaderValue(request, DEFAULT_SHEET_TAB_HEADER) ||
+ env.GOOGLEDOCS_SHEET_TAB ||
+ DEFAULT_SHEET_TAB;
+
+ return { spreadsheetId, sheetTab };
+}
+
+function getBotApiConfig(request, env) {
+ const botApiUrl = getHeaderValue(request, BOT_API_URL_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_URL");
+ const botApiSecret = getHeaderValue(request, BOT_API_SECRET_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET");
+
+ return {
+ botApiUrl,
+ botApiSecret,
+ };
+}
+
+function isSameOriginUrl(leftUrl, rightUrl) {
+ try {
+ return new URL(leftUrl).origin === new URL(rightUrl).origin;
+ } catch {
+ return false;
+ }
+}
+
+function getExpectedAuthKey(env) {
+ return (
+ readRequiredEnv(env, "GOOGLEDOCS_AUTH_KEY") ||
+ readRequiredEnv(env, "TICKETS_SHARED_SECRET") ||
+ readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET")
+ );
+}
+
+function isValidAuthRequest(request, env) {
+ const expected = getExpectedAuthKey(env);
+ if (!expected) {
+ return true;
+ }
+
+ const provided =
+ getHeaderValue(request, "X-Tickets-Auth") ||
+ getHeaderValue(request, "X-Tickets-Secret") ||
+ getHeaderValue(request, "Authorization") ||
+ "";
+ const normalized = provided.startsWith("Bearer ") ? provided.slice("Bearer ".length) : provided;
+ return normalized === expected;
+}
+
+async function validateSecrets(env, secrets) {
+ const spreadsheetId = secrets.spreadsheet_id || secrets.GOOGLEDOCS_SPREADSHEET_ID;
+ const sheetTab = secrets.sheet_tab || secrets.GOOGLEDOCS_SHEET_TAB || DEFAULT_SHEET_TAB;
+
+ if (!spreadsheetId) {
+ return errorResponse(400, "Missing spreadsheet_id.");
+ }
+
+ if (!isSpreadsheetId(spreadsheetId)) {
+ return errorResponse(400, "Your spreadsheet_id is invalid.");
+ }
+
+ if (!isValidSheetTabName(sheetTab)) {
+ return errorResponse(400, "Your sheet_tab is invalid.");
+ }
+
+ return verifySpreadsheetAccess(env, spreadsheetId, sheetTab);
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function base64UrlEncodeText(text) {
+ const utf8 = new TextEncoder().encode(text);
+ return base64UrlEncodeBytes(utf8);
+}
+
+function base64UrlEncodeBytes(bytes) {
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+
+ return btoa(binary)
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/g, "");
+}
+
+function pemPrivateKeyToArrayBuffer(pem) {
+ const stripped = pem
+ .replace(/\r/g, "")
+ .replace("-----BEGIN PRIVATE KEY-----", "")
+ .replace("-----END PRIVATE KEY-----", "")
+ .replace(/\s+/g, "");
+
+ const binary = atob(stripped);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+
+async function signServiceAccountJwt(serviceEmail, privateKey) {
+ const header = { alg: "RS256", typ: "JWT" };
+ const now = Math.floor(Date.now() / 1000);
+ const payload = {
+ iss: serviceEmail,
+ scope: GOOGLE_SHEETS_SCOPE,
+ aud: GOOGLE_TOKEN_URL,
+ exp: now + 3600,
+ iat: now,
+ };
+
+ const encodedHeader = base64UrlEncodeText(JSON.stringify(header));
+ const encodedPayload = base64UrlEncodeText(JSON.stringify(payload));
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
+
+ const key = await crypto.subtle.importKey(
+ "pkcs8",
+ pemPrivateKeyToArrayBuffer(privateKey),
+ {
+ name: "RSASSA-PKCS1-v1_5",
+ hash: "SHA-256",
+ },
+ false,
+ ["sign"],
+ );
+
+ const signature = await crypto.subtle.sign(
+ "RSASSA-PKCS1-v1_5",
+ key,
+ new TextEncoder().encode(signingInput),
+ );
+
+ const encodedSignature = base64UrlEncodeBytes(new Uint8Array(signature));
+ return `${signingInput}.${encodedSignature}`;
+}
+
+function readRequiredEnv(env, key) {
+ const value = env[key];
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function isSpreadsheetId(value) {
+ return typeof value === "string" && /^[a-zA-Z0-9-_]{20,200}$/.test(value);
+}
+
+function isValidSheetTabName(value) {
+ return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 100;
+}
+
+function buildGoogleSheetsUrl(spreadsheetId, range) {
+ return `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
+}
+
+function buildHeadersUrl(spreadsheetId, sheetTab) {
+ return `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A1:K1?valueInputOption=USER_ENTERED`;
+}
+
+function buildSheetRange(sheetTab, rowNumber) {
+ return `${sheetTab}!A${rowNumber}:K${rowNumber}`;
+}
+
+function getTicketWriteKey(spreadsheetId, sheetTab, payload) {
+ const event = normaliseEventPayload(payload);
+ const stableId = event.ticketId || event.channelId;
+ if (!spreadsheetId || !sheetTab || !stableId) {
+ return null;
+ }
+
+ return `${spreadsheetId}::${sheetTab}::${stableId}`;
+}
+
+function getTicketWriteSignature(payload) {
+ return JSON.stringify(buildTicketRowFromEvent(payload));
+}
+
+function getTicketWriteQueueState(key) {
+ let state = googleSheetsWriteQueueByKey.get(key);
+ if (!state) {
+ state = {
+ timerId: null,
+ running: false,
+ pendingPayload: null,
+ promise: null,
+ resolve: null,
+ reject: null,
+ lastSignature: null,
+ lastWriteAt: 0,
+ };
+ googleSheetsWriteQueueByKey.set(key, state);
+ }
+
+ return state;
+}
+
+function resetTicketWriteQueueState(key) {
+ const state = googleSheetsWriteQueueByKey.get(key);
+ if (!state) {
+ return;
+ }
+
+ if (state.timerId) {
+ clearTimeout(state.timerId);
+ }
+
+ googleSheetsWriteQueueByKey.delete(key);
+}
+
+async function runGoogleSheetsWrite(task) {
+ const next = googleSheetsGlobalWriteChain.then(async () => {
+ const elapsed = Date.now() - googleSheetsLastWriteAt;
+ if (googleSheetsLastWriteAt > 0 && elapsed < GOOGLE_WRITE_MIN_INTERVAL_MS) {
+ await sleep(GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed);
+ }
+
+ const result = await task();
+ googleSheetsLastWriteAt = Date.now();
+ return result;
+ });
+
+ googleSheetsGlobalWriteChain = next.catch(() => null);
+ return next;
+}
+
+async function flushTicketWriteQueue(key, env, spreadsheetId, sheetTab) {
+ const state = googleSheetsWriteQueueByKey.get(key);
+ if (!state || state.running) {
+ return null;
+ }
+
+ state.running = true;
+ let lastResult = { mode: "skipped", reason: "No pending ticket write" };
+
+ try {
+ while (state.pendingPayload) {
+ const payload = state.pendingPayload;
+ state.pendingPayload = null;
+
+ const signature = getTicketWriteSignature(payload);
+ if (signature === state.lastSignature) {
+ lastResult = { mode: "skipped", reason: "Duplicate ticket state" };
+ continue;
+ }
+
+ lastResult = await runGoogleSheetsWrite(() => upsertTicketToSheet(env, payload, spreadsheetId, sheetTab));
+ state.lastSignature = signature;
+ state.lastWriteAt = Date.now();
+ }
+
+ if (state.resolve) {
+ state.resolve(lastResult);
+ }
+
+ return lastResult;
+ } catch (error) {
+ if (state.reject) {
+ state.reject(error);
+ }
+ throw error;
+ } finally {
+ state.running = false;
+ state.timerId = null;
+ state.promise = null;
+ state.resolve = null;
+ state.reject = null;
+ state.pendingPayload = null;
+
+ if (!state.lastSignature) {
+ resetTicketWriteQueueState(key);
+ }
+ }
+}
+
+function queueTicketWrite(env, spreadsheetId, sheetTab, payload) {
+ const key = getTicketWriteKey(spreadsheetId, sheetTab, payload);
+ if (!key) {
+ return upsertTicketToSheet(env, payload, spreadsheetId, sheetTab);
+ }
+
+ const state = getTicketWriteQueueState(key);
+ state.pendingPayload = payload;
+
+ if (!state.promise) {
+ state.promise = new Promise((resolve, reject) => {
+ state.resolve = resolve;
+ state.reject = reject;
+ });
+ }
+
+ if (state.timerId === null && !state.running) {
+ const elapsed = state.lastWriteAt > 0 ? Date.now() - state.lastWriteAt : null;
+ const delayMs = elapsed === null ? GOOGLE_WRITE_DEBOUNCE_MS : Math.max(GOOGLE_WRITE_DEBOUNCE_MS, GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed);
+
+ state.timerId = setTimeout(() => {
+ state.timerId = null;
+ flushTicketWriteQueue(key, env, spreadsheetId, sheetTab).catch((error) => {
+ console.error("[DEBUG] Google Sheets write queue failed:", error);
+ });
+ }, delayMs);
+ }
+
+ return state.promise;
+}
+
+function getSheetCacheKey(spreadsheetId, sheetTab) {
+ return `${spreadsheetId}::${sheetTab}`;
+}
+
+function resetSheetCache() {
+ sheetsInitialized = false;
+ initializedSheetKey = null;
+ sheetRowByChannelId.clear();
+ sheetRowByTicketId.clear();
+}
+
+function parseRetryAfter(headerValue) {
+ if (!headerValue) {
+ return null;
+ }
+
+ const seconds = Number(headerValue);
+ if (Number.isFinite(seconds)) {
+ return Math.max(0, seconds * 1000);
+ }
+
+ const dateMs = Date.parse(headerValue);
+ if (Number.isFinite(dateMs)) {
+ return Math.max(0, dateMs - Date.now());
+ }
+
+ return null;
+}
+
+function classifySheetsFailure(response, bodyText) {
+ if (response.status === 401) {
+ return {
+ status: 401,
+ message: "Google authentication failed. Check the service account email and private key.",
+ };
+ }
+
+ if (response.status === 403) {
+ return {
+ status: 403,
+ message:
+ "Google Sheets permission denied. Share the spreadsheet with the service account email as Editor.",
+ };
+ }
+
+ if (response.status === 404) {
+ return {
+ status: 404,
+ message:
+ "Google Sheets spreadsheet was not found. Verify the spreadsheet ID and sharing permissions.",
+ };
+ }
+
+ if (response.status === 429) {
+ return {
+ status: 429,
+ message: "Google Sheets rate limit exceeded. The worker will retry automatically.",
+ };
+ }
+
+ if (response.status >= 500) {
+ return {
+ status: response.status,
+ message: `Google Sheets is temporarily unavailable (${response.status}).`,
+ };
+ }
+
+ return {
+ status: response.status,
+ message: `Google Sheets append failed (${response.status}): ${bodyText}`,
+ };
+}
+
+function getEventType(payload) {
+ return String(payload.event_type || payload.type || payload.event || "").trim().toLowerCase();
+}
+
+function normaliseEventPayload(payload) {
+ const openedAt = payload.opened_at || payload.created_at || new Date().toISOString();
+ const eventType = getEventType(payload);
+ const status =
+ payload.status ||
+ (eventType === "closed"
+ ? "closed"
+ : eventType === "close_requested"
+ ? "close_requested"
+ : eventType === "claimed"
+ ? "claimed"
+ : "open");
+
+ return {
+ ticketId: payload.ticket_id || payload.ticketId || "",
+ channelId: payload.ticket_channel_id || payload.channel_id || payload.channelId || "",
+ status,
+ guildId: payload.guild_id || payload.guildId || "",
+ userId: payload.user_id || payload.userId || "",
+ claimedBy: payload.claimed_by || payload.claimedBy || "",
+ closeRequestedBy: payload.close_requested_by || payload.closeRequestedBy || "",
+ openedAt,
+ firstReplyAt: payload.first_reply_at || payload.first_response_at || payload.firstReplyAt || "",
+ closedAt: payload.closed_at || payload.closedAt || "",
+ panelTitle: payload.panel_title || payload.panelTitle || "",
+ };
+}
+
+function buildTicketRowFromEvent(payload) {
+ const event = normaliseEventPayload(payload);
+
+ return [
+ toCellValue(event.ticketId),
+ toCellValue(event.channelId),
+ toCellValue(event.status),
+ toCellValue(event.guildId),
+ toCellValue(event.userId),
+ toCellValue(event.claimedBy),
+ toCellValue(event.closeRequestedBy),
+ toCellValue(event.openedAt),
+ toCellValue(event.firstReplyAt),
+ toCellValue(event.closedAt),
+ toCellValue(event.panelTitle),
+ ];
+}
+
+function buildBotSeedRecord(payload) {
+ const event = normaliseEventPayload(payload);
+
+ return {
+ ticket_id: event.ticketId,
+ ticket_channel_id: event.channelId,
+ status: event.status,
+ guild_id: event.guildId,
+ user_id: event.userId,
+ claimed_by: event.claimedBy,
+ close_requested_by: event.closeRequestedBy,
+ opened_at: event.openedAt,
+ first_reply_at: event.firstReplyAt,
+ closed_at: event.closedAt,
+ panel_title: event.panelTitle,
+ };
+}
+
+async function notifyBotApi(request, env, records, options = {}) {
+ const { botApiUrl, botApiSecret } = getBotApiConfig(request, env);
+ if (!botApiUrl) {
+ console.log("[DEBUG] No bot API URL configured; skipping bot sync.");
+ return null;
+ }
+
+ if (request?.url && isSameOriginUrl(request.url, botApiUrl)) {
+ console.warn(
+ `[DEBUG] RANKBLOX_TICKETS_API_URL points to this worker (${botApiUrl}); skipping bot sync to avoid self-posting.`,
+ );
+ return null;
+ }
+
+ const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env);
+ const payload = Array.isArray(records) ? records : [records];
+ console.log(`[DEBUG] Syncing ${payload.length} ticket record(s) to bot API: ${botApiUrl}`);
+ const response = await fetch(`${botApiUrl.replace(/\/$/, "")}/api/tickets/seed`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...(botApiSecret ? { "X-Tickets-Secret": botApiSecret } : {}),
+ ...(spreadsheetId ? { [DEFAULT_SPREADSHEET_ID_HEADER]: spreadsheetId } : {}),
+ ...(sheetTab ? { [DEFAULT_SHEET_TAB_HEADER]: sheetTab } : {}),
+ },
+ body: JSON.stringify({
+ tickets: payload,
+ replace: options.replace === true,
+ }),
+ });
+
+ if (!response.ok) {
+ const details = await response.text().catch(() => "");
+ throw new Error(`Bot API sync failed (${response.status}): ${details || response.statusText}`);
+ }
+
+ return response.json().catch(() => null);
+}
+
+async function decideWithBotApi(request, env, payload) {
+ const { botApiUrl, botApiSecret } = getBotApiConfig(request, env);
+ if (!botApiUrl) {
+ return {
+ shouldWrite: true,
+ ticket: buildBotSeedRecord(payload),
+ };
+ }
+
+ if (request?.url && isSameOriginUrl(request.url, botApiUrl)) {
+ return {
+ shouldWrite: true,
+ ticket: buildBotSeedRecord(payload),
+ };
+ }
+
+ const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env);
+ const response = await fetch(`${botApiUrl.replace(/\/$/, "")}/api/tickets/decide`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...(botApiSecret ? { "X-Tickets-Secret": botApiSecret } : {}),
+ ...(spreadsheetId ? { [DEFAULT_SPREADSHEET_ID_HEADER]: spreadsheetId } : {}),
+ ...(sheetTab ? { [DEFAULT_SHEET_TAB_HEADER]: sheetTab } : {}),
+ },
+ body: JSON.stringify(buildBotSeedRecord(payload)),
+ });
+
+ if (!response.ok) {
+ const details = await response.text().catch(() => "");
+ throw new Error(`Bot decision request failed (${response.status}): ${details || response.statusText}`);
+ }
+
+ const decision = await response.json().catch(() => null);
+ return {
+ shouldWrite: decision?.should_write !== false,
+ ticket: decision?.ticket || buildBotSeedRecord(payload),
+ };
+}
+
+function normalizeTicketSeedEntry(entry, fallback = {}) {
+ if (typeof entry === "string") {
+ return {
+ ticket_channel_id: entry,
+ guild_id: fallback.guild_id || fallback.guildId || "",
+ user_id: fallback.user_id || fallback.userId || "",
+ ticket_id: fallback.ticket_id || fallback.ticketId || "",
+ status: fallback.status || "open",
+ panel_title: fallback.panel_title || fallback.panelTitle || "",
+ opened_at: fallback.opened_at || fallback.openedAt || new Date().toISOString(),
+ claimed_by: fallback.claimed_by || fallback.claimedBy || "",
+ close_requested_by: fallback.close_requested_by || fallback.closeRequestedBy || "",
+ first_reply_at: fallback.first_reply_at || fallback.firstReplyAt || "",
+ closed_at: fallback.closed_at || fallback.closedAt || "",
+ };
+ }
+
+ if (!entry || typeof entry !== "object") {
+ return null;
+ }
+
+ const ticketChannelId = entry.ticket_channel_id || entry.channel_id || entry.channelId;
+ if (!ticketChannelId) {
+ return null;
+ }
+
+ return {
+ ticket_id: entry.ticket_id || entry.ticketId || fallback.ticket_id || fallback.ticketId || "",
+ ticket_channel_id: ticketChannelId,
+ status: entry.status || fallback.status || "open",
+ guild_id: entry.guild_id || entry.guildId || fallback.guild_id || fallback.guildId || "",
+ user_id: entry.user_id || entry.userId || fallback.user_id || fallback.userId || "",
+ claimed_by: entry.claimed_by || entry.claimedBy || fallback.claimed_by || fallback.claimedBy || "",
+ close_requested_by:
+ entry.close_requested_by ||
+ entry.closeRequestedBy ||
+ fallback.close_requested_by ||
+ fallback.closeRequestedBy ||
+ "",
+ opened_at:
+ entry.opened_at || entry.openedAt || fallback.opened_at || fallback.openedAt || new Date().toISOString(),
+ first_reply_at:
+ entry.first_reply_at || entry.first_response_at || entry.firstReplyAt || fallback.firstReplyAt || "",
+ closed_at: entry.closed_at || entry.closedAt || fallback.closed_at || fallback.closedAt || "",
+ panel_title: entry.panel_title || entry.panelTitle || fallback.panel_title || fallback.panelTitle || "",
+ };
+}
+
+async function writeTicketSeedBatch(request, env, spreadsheetId, sheetTab, records) {
+ if (!Array.isArray(records) || records.length === 0) {
+ return [];
+ }
+
+ await ensureSheetsInitialized(env, spreadsheetId, sheetTab);
+
+ const results = [];
+ for (const entry of records) {
+ const normalized = normalizeTicketSeedEntry(entry);
+ if (!normalized) {
+ continue;
+ }
+
+ const result = await queueTicketWrite(env, spreadsheetId, sheetTab, normalized);
+ results.push({
+ channel_id: normalized.ticket_channel_id,
+ mode: result.mode,
+ row_number: result.rowNumber,
+ });
+ }
+
+ if (results.length > 0) {
+ notifyBotApi(request, env, records.map((entry) => normalizeTicketSeedEntry(entry)).filter(Boolean), {
+ replace: false,
+ }).catch((error) => {
+ console.error("[DEBUG] Background bot notify failed:", error.message);
+ });
+ }
+
+ return results;
+}
+
+async function getGoogleAccessToken(env) {
+ const now = Math.floor(Date.now() / 1000);
+ if (cachedAccessToken && cachedAccessTokenExpiry - 60 > now) {
+ return cachedAccessToken;
+ }
+
+ const serviceEmail = readRequiredEnv(env, "GOOGLE_SERVICE_ACCOUNT_EMAIL");
+ const privateKeyRaw = readRequiredEnv(env, "GOOGLE_PRIVATE_KEY");
+ if (!serviceEmail || !privateKeyRaw) {
+ throw new Error("Missing Google service account credentials");
+ }
+
+ const privateKey = privateKeyRaw.replace(/\\n/g, "\n");
+ const assertion = await signServiceAccountJwt(serviceEmail, privateKey);
+
+ const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ assertion,
+ }),
+ });
+
+ if (!tokenResponse.ok) {
+ const details = await tokenResponse.text();
+ throw new Error(`Token exchange failed (${tokenResponse.status}): ${details}`);
+ }
+
+ const tokenData = await tokenResponse.json();
+ cachedAccessToken = tokenData.access_token;
+ cachedAccessTokenExpiry = now + Number(tokenData.expires_in || 3600);
+ return cachedAccessToken;
+}
+
+async function ensureSheetsInitialized(env, spreadsheetId, sheetTab) {
+ const sheetKey = getSheetCacheKey(spreadsheetId, sheetTab);
+ if (sheetsInitialized && initializedSheetKey === sheetKey) {
+ return;
+ }
+
+ if (sheetsInitialized && initializedSheetKey !== sheetKey) {
+ resetSheetCache();
+ }
+
+ await verifySpreadsheetAccess(env, spreadsheetId, sheetTab);
+ await ensureTicketHeaders(env, spreadsheetId, sheetTab);
+ await loadSheetRowIndexCache(env, spreadsheetId, sheetTab);
+ initializedSheetKey = sheetKey;
+ sheetsInitialized = true;
+}
+
+async function loadSheetRowIndexCache(env, spreadsheetId, sheetTab) {
+ const accessToken = await getGoogleAccessToken(env);
+ const response = await fetch(
+ `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A2:B`,
+ {
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ return;
+ }
+
+ sheetRowByChannelId.clear();
+ sheetRowByTicketId.clear();
+ const data = await response.json();
+ const rows = Array.isArray(data.values) ? data.values : [];
+
+ rows.forEach((row, index) => {
+ const ticketId = row?.[0];
+ const channelId = row?.[1];
+ const rowNumber = index + 2;
+
+ if (ticketId) {
+ sheetRowByTicketId.set(String(ticketId), rowNumber);
+ }
+
+ if (channelId) {
+ sheetRowByChannelId.set(String(channelId), rowNumber);
+ }
+ });
+}
+
+async function resolveExistingTicketRowNumber(env, spreadsheetId, sheetTab, ticketId, channelId) {
+ if (ticketId) {
+ const cachedTicketRowNumber = sheetRowByTicketId.get(String(ticketId));
+ if (cachedTicketRowNumber) {
+ return cachedTicketRowNumber;
+ }
+ }
+
+ if (channelId) {
+ const cachedChannelRowNumber = sheetRowByChannelId.get(String(channelId));
+ if (cachedChannelRowNumber) {
+ return cachedChannelRowNumber;
+ }
+ }
+
+ const accessToken = await getGoogleAccessToken(env);
+ const response = await fetch(
+ `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A2:B`,
+ {
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const data = await response.json().catch(() => null);
+ const rows = Array.isArray(data?.values) ? data.values : [];
+
+ rows.forEach((row, index) => {
+ const rowTicketId = row?.[0];
+ const rowChannelId = row?.[1];
+ const rowNumber = index + 2;
+
+ if (rowTicketId) {
+ sheetRowByTicketId.set(String(rowTicketId), rowNumber);
+ }
+
+ if (rowChannelId) {
+ sheetRowByChannelId.set(String(rowChannelId), rowNumber);
+ }
+ });
+
+ if (ticketId && sheetRowByTicketId.has(String(ticketId))) {
+ return sheetRowByTicketId.get(String(ticketId)) || null;
+ }
+
+ return channelId ? sheetRowByChannelId.get(String(channelId)) || null : null;
+}
+
+async function verifySpreadsheetAccess(env, spreadsheetId, sheetTab) {
+ const accessToken = await getGoogleAccessToken(env);
+ const response = await fetch(
+ `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}?fields=sheets.properties.title`,
+ {
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ if (response.status === 403) {
+ return errorResponse(
+ 400,
+ "Google Sheets permission denied. Share the spreadsheet with the service account email as Editor.",
+ );
+ }
+
+ if (response.status === 404) {
+ return errorResponse(400, "Google Sheets spreadsheet was not found.");
+ }
+
+ if (response.status === 429) {
+ return errorResponse(400, "Google Sheets rate limit exceeded. Try again shortly.");
+ }
+
+ return errorResponse(400, "Unable to verify spreadsheet access.");
+ }
+
+ const data = await response.json();
+ const sheets = Array.isArray(data.sheets) ? data.sheets : [];
+ const tabExists = sheets.some((sheet) => sheet?.properties?.title === sheetTab);
+
+ if (!tabExists) {
+ return errorResponse(400, "Your sheet_tab was not found in the spreadsheet.");
+ }
+
+ return null;
+}
+
+function toCellValue(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ return String(value);
+}
+
+function buildTicketRow(payload) {
+ const openedAt = payload.opened_at || payload.created_at || new Date().toISOString();
+ const firstReplyAt = payload.first_reply_at || payload.first_response_at || "";
+ const closedAt = payload.closed_at || "";
+ const status = payload.status || (payload.is_closed ? "closed" : "open");
+
+ return [
+ toCellValue(payload.ticket_id),
+ toCellValue(payload.ticket_channel_id || payload.channel_id),
+ toCellValue(status),
+ toCellValue(payload.guild_id),
+ toCellValue(payload.user_id),
+ toCellValue(payload.claimed_by),
+ toCellValue(payload.close_requested_by),
+ toCellValue(openedAt),
+ toCellValue(firstReplyAt),
+ toCellValue(closedAt),
+ toCellValue(payload.panel_title),
+ ];
+}
+
+async function ensureTicketHeaders(env, spreadsheetId, sheetTab) {
+ const accessToken = await getGoogleAccessToken(env);
+ const response = await fetch(buildHeadersUrl(spreadsheetId, sheetTab), {
+ method: "PUT",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ values: [TICKET_HEADERS] }),
+ });
+
+ if (!response.ok) {
+ const details = await response.text();
+ throw new Error(`Failed to write sheet headers (${response.status}): ${details}`);
+ }
+}
+
+async function upsertTicketToSheet(env, payload, spreadsheetId, sheetTab) {
+ if (!spreadsheetId) {
+ throw new Error("Missing GOOGLEDOCS_SPREADSHEET_ID");
+ }
+
+ if (!isSpreadsheetId(spreadsheetId)) {
+ throw new Error("Invalid spreadsheet ID");
+ }
+
+ await ensureSheetsInitialized(env, spreadsheetId, sheetTab);
+
+ const rowValues = buildTicketRowFromEvent(payload);
+ const ticketId = rowValues[0];
+ const channelId = rowValues[1];
+ const existingRowNumber = await resolveExistingTicketRowNumber(env, spreadsheetId, sheetTab, ticketId, channelId);
+ const accessToken = await getGoogleAccessToken(env);
+
+ const requestBody = JSON.stringify({
+ majorDimension: "ROWS",
+ values: [rowValues],
+ });
+
+ if (existingRowNumber) {
+ const response = await fetch(
+ `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(buildSheetRange(sheetTab, existingRowNumber))}?valueInputOption=USER_ENTERED`,
+ {
+ method: "PUT",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${accessToken}`,
+ },
+ body: requestBody,
+ },
+ );
+
+ if (!response.ok) {
+ const details = await response.text();
+ throw new Error(`Failed to update ticket row (${response.status}): ${details}`);
+ }
+
+ return { mode: "updated", rowNumber: existingRowNumber };
+ }
+
+ let lastError = null;
+ for (let attempt = 1; attempt <= GOOGLE_WRITE_MAX_ATTEMPTS; attempt += 1) {
+ const response = await fetch(buildGoogleSheetsUrl(spreadsheetId, `${sheetTab}!A:K`), {
+ method: "POST",
headers: {
"content-type": "application/json",
- allow: "POST",
+ authorization: `Bearer ${accessToken}`,
},
+ body: requestBody,
+ });
+
+ if (response.ok) {
+ const data = await response.json().catch(() => null);
+ const updatedRange = data?.updates?.updatedRange || "";
+ const match = updatedRange.match(/!(?:[A-Z]+)(\d+):/);
+ if (match) {
+ const rowNumber = Number(match[1]);
+ if (ticketId) {
+ sheetRowByTicketId.set(String(ticketId), rowNumber);
+ }
+ if (channelId) {
+ sheetRowByChannelId.set(String(channelId), rowNumber);
+ }
+ }
+ return { mode: "inserted", rowNumber: (ticketId && sheetRowByTicketId.get(String(ticketId))) || (channelId && sheetRowByChannelId.get(String(channelId))) || null };
+ }
+
+ const details = await response.text();
+ const classified = classifySheetsFailure(response, details);
+ lastError = new Error(classified.message);
+
+ if (response.status !== 429 && response.status < 500) {
+ throw lastError;
+ }
+
+ if (attempt < GOOGLE_WRITE_MAX_ATTEMPTS) {
+ const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
+ const backoffMs = retryAfterMs ?? Math.min(5000, 500 * 2 ** (attempt - 1));
+ await sleep(backoffMs);
+ }
+ }
+
+ throw lastError || new Error("Failed to write ticket to Google Sheets");
+}
+
+async function processLifecycleEvent(payload, env, spreadsheetId, sheetTab) {
+ if (getEventType(payload) === "first_response" && !payload.first_reply_at && !payload.first_response_at) {
+ payload.first_reply_at = new Date().toISOString();
+ }
+
+ if (getEventType(payload) === "closed" && !payload.closed_at) {
+ payload.closed_at = new Date().toISOString();
+ }
+
+ return queueTicketWrite(env, spreadsheetId, sheetTab, payload);
+}
+
+function queueBotSync(request, env, payload, note) {
+ notifyBotApi(request, env, buildBotSeedRecord(payload))
+ .then(() => {
+ console.log(`[DEBUG] Bot sync queued successfully${note ? ` (${note})` : ""}`);
+ })
+ .catch((error) => {
+ console.error(`[DEBUG] Bot sync failed${note ? ` (${note})` : ""}:`, error);
+ });
+}
+
+async function handleLifecycleEvent(request, env, ctx) {
+ const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env);
+ if (!spreadsheetId) {
+ return errorResponse(400, "Missing spreadsheet ID.");
+ }
+
+ let payload;
+ try {
+ payload = await request.json();
+ } catch {
+ return errorResponse(400, "Invalid request body");
+ }
+
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return errorResponse(400, "Request body must be a JSON object");
+ }
+
+ const channelId = payload.ticket_channel_id || payload.channel_id || payload.channelId;
+ if (!channelId) {
+ return errorResponse(400, "Missing ticket_channel_id");
+ }
+
+ const writeTask = processLifecycleEvent(payload, env, spreadsheetId, sheetTab)
+ .then((result) => {
+ console.log(`[DEBUG] Lifecycle event written for channel ${channelId}: ${result.mode}`);
+ })
+ .catch((error) => {
+ console.error("Lifecycle event write failed", error);
+ });
+
+ queueBotSync(request, env, payload, `channel ${channelId}`);
+
+ if (ctx?.waitUntil) {
+ ctx.waitUntil(writeTask);
+ }
+
+ return jsonResponse(202, {
+ ok: true,
+ queued: true,
+ event_type: getEventType(payload),
+ spreadsheet_id: spreadsheetId,
+ sheet_tab: sheetTab,
+ });
+}
+
+async function handleTicketSeedRequest(request, env) {
+ const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env);
+ if (!spreadsheetId) {
+ return errorResponse(400, "Missing spreadsheet ID.");
+ }
+
+ let payload;
+ try {
+ payload = await request.json();
+ } catch {
+ return errorResponse(400, "Invalid request body");
+ }
+
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return errorResponse(400, "Request body must be a JSON object");
+ }
+
+ const records = Array.isArray(payload.tickets)
+ ? payload.tickets
+ : Array.isArray(payload.ticket_channels)
+ ? payload.ticket_channels
+ : Array.isArray(payload.channel_ids)
+ ? payload.channel_ids
+ : Array.isArray(payload.channelIds)
+ ? payload.channelIds
+ : [];
+
+ if (records.length === 0) {
+ return errorResponse(400, "Missing tickets, ticket_channels, or channel_ids");
+ }
+
+ try {
+ const normalizedFallback = {
+ ticket_id: payload.ticket_id || payload.ticketId || "",
+ guild_id: payload.guild_id || payload.guildId || "",
+ user_id: payload.user_id || payload.userId || "",
+ status: payload.status || "open",
+ panel_title: payload.panel_title || payload.panelTitle || "",
+ opened_at: payload.opened_at || payload.openedAt || new Date().toISOString(),
+ claimed_by: payload.claimed_by || payload.claimedBy || "",
+ close_requested_by: payload.close_requested_by || payload.closeRequestedBy || "",
+ first_reply_at: payload.first_reply_at || payload.first_response_at || payload.firstReplyAt || "",
+ closed_at: payload.closed_at || payload.closedAt || "",
+ replace: payload.replace === true,
+ };
+
+ if (normalizedFallback.replace) {
+ resetSheetCache();
+ }
+
+ const results = await writeTicketSeedBatch(
+ request,
+ env,
+ spreadsheetId,
+ sheetTab,
+ records.map((entry) => normalizeTicketSeedEntry(entry, normalizedFallback)).filter(Boolean),
+ );
+
+ return jsonResponse(200, {
+ ok: true,
+ replaced: normalizedFallback.replace,
+ seeded: results.length,
+ results,
+ spreadsheet_id: spreadsheetId,
+ sheet_tab: sheetTab,
});
+ } catch (error) {
+ console.error("Ticket seed write failed", error);
+ const message = error instanceof Error ? error.message : "Failed to seed tickets";
+ if (message.includes("permission denied")) {
+ return errorResponse(403, message);
+ }
+ if (message.includes("rate limit")) {
+ return errorResponse(429, message);
+ }
+ return errorResponse(502, message);
}
+}
+
+async function handleRequest(request, env, ctx) {
+ const url = new URL(request.url);
+ if (url.pathname === "/api/tickets" && request.method === "GET") {
+ if (!isValidAuthRequest(request, env)) {
+ return errorResponse(401, "Invalid auth key");
+ }
+
+ const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env);
+
+ return jsonResponse(200, {
+ ok: true,
+ endpoints: ["/events", "/api/tickets/bootstrap", "/api/tickets/seed"],
+ hasSpreadsheet: Boolean(spreadsheetId),
+ spreadsheet_id: spreadsheetId,
+ sheet_tab: sheetTab,
+ });
+ }
+
+ if (request.method !== "POST") {
+ return errorResponse(405, "Method Not Allowed");
+ }
+
+ if (url.pathname === "/validate-secrets") {
+ let secrets;
+ try {
+ secrets = await request.json();
+ } catch {
+ return errorResponse(400, "Invalid request body");
+ }
+
+ if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) {
+ return errorResponse(400, "Request body must be a JSON object");
+ }
+
+ const accessCheck = await validateSecrets(env, secrets);
+ if (accessCheck) {
+ return accessCheck;
+ }
+
+ return new Response(null, { status: 204 });
+ }
+
+ if (url.pathname === "/events") {
+ if (!isValidAuthRequest(request, env)) {
+ return errorResponse(401, "Invalid auth key");
+ }
- if (request.headers.get("X-Tickets-Auth") !== env.GOOGLEDOCS_AUTH_KEY) {
- return jsonResponse(401, { error: "Invalid auth key" });
+ return handleLifecycleEvent(request, env, ctx);
+ }
+
+ if (url.pathname === "/api/tickets/bootstrap" || url.pathname === "/api/tickets/seed") {
+ if (!isValidAuthRequest(request, env)) {
+ return errorResponse(401, "Invalid auth key");
+ }
+
+ return handleTicketSeedRequest(request, env);
+ }
+
+ if (url.pathname !== "/" && url.pathname !== "/tickets-webhook") {
+ return errorResponse(404, "Not Found");
+ }
+
+ if (!isValidAuthRequest(request, env)) {
+ return errorResponse(401, "Invalid auth key");
+ }
+
+ const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env);
+ if (!spreadsheetId) {
+ return errorResponse(
+ 400,
+ `Missing spreadsheet ID. Set X-Tickets-Google-Sheets-Id on the integration or GOOGLEDOCS_SPREADSHEET_ID in Cloudflare secrets.`,
+ );
}
let body;
try {
body = await request.json();
} catch {
- return jsonResponse(400, { error: "Invalid request body" });
+ return errorResponse(400, "Invalid request body");
+ }
+
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ return errorResponse(400, "Request body must be a JSON object");
+ }
+
+ // For the public webhook path `/tickets-webhook` we allow missing `user_id`
+ // because some integrations (e.g. channel-delete notifications) may not
+ // include a user identifier. For other endpoints, `user_id` is still
+ // required.
+ if (!body.user_id && url.pathname !== "/tickets-webhook") {
+ return errorResponse(400, "Missing user_id in request body");
+ }
+
+ const decision = await decideWithBotApi(request, env, body)
+ .then((result) => {
+ console.log(
+ `[DEBUG] Bot decision for channel ${body.ticket_channel_id || body.channel_id || body.channelId || "unknown"}: should_write=${result.shouldWrite}`,
+ );
+ return result;
+ })
+ .catch((error) => {
+ console.error("[DEBUG] Pre-write bot decision failed", error);
+ return {
+ shouldWrite: true,
+ ticket: buildBotSeedRecord(body),
+ };
+ });
+
+ if (!decision.shouldWrite) {
+ return jsonResponse(200, {
+ ok: true,
+ skipped: "No meaningful bot-side state change",
+ spreadsheet_id: spreadsheetId,
+ sheet_tab: sheetTab,
+ });
+ }
+
+ const writeTask = processLifecycleEvent(decision.ticket, env, spreadsheetId, sheetTab)
+ .then((result) => {
+ console.log(`[DEBUG] Ticket webhook written for channel ${decision.ticket.ticket_channel_id || decision.ticket.channel_id || decision.ticket.channelId}: ${result.mode}`);
+ })
+ .catch((error) => {
+ console.error("Google Sheets append failed", error);
+ });
+
+ if (ctx?.waitUntil) {
+ ctx.waitUntil(writeTask);
}
- // Placeholder response so the Worker can be deployed and wired before
- // Google Sheets writing is implemented.
- return jsonResponse(501, {
- error: "Google Docs integration is not implemented yet",
- received: body,
+ return jsonResponse(202, {
+ ok: true,
+ queued: true,
+ spreadsheet_id: spreadsheetId,
+ sheet_tab: sheetTab,
});
}
@@ -44,8 +1271,8 @@ export default Sentry.withSentry(
sendDefaultPii: true,
}),
{
- async fetch(request, env) {
- return handleRequest(request, env);
+ async fetch(request, env, ctx) {
+ return handleRequest(request, env, ctx);
},
},
);
diff --git a/googledocs/package-lock.json b/googledocs/package-lock.json
new file mode 100644
index 0000000..0a0c240
--- /dev/null
+++ b/googledocs/package-lock.json
@@ -0,0 +1,1546 @@
+{
+ "name": "googledocs-integration",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "googledocs-integration",
+ "dependencies": {
+ "@sentry/cloudflare": "^10.49.0"
+ },
+ "devDependencies": {
+ "wrangler": "^4.83.0"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz",
+ "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz",
+ "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260421.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260421.1.tgz",
+ "integrity": "sha512-DLU5ZTZ1VHeZZnj0PuVJEMHKGisfLe2XShyImP5P/PPj/m/t7CLEJmPiI7FMxvT7ynArkckJl7m+Z5x7u4Kkdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260421.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260421.1.tgz",
+ "integrity": "sha512-Trotq3xRAkIcpC505WoxM8+kIH4JIvOJCNuRatyHcz9uF5S+ukgiVUFUlM+GIjw1uCM/Bda2St+vSniX1RZdpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260421.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260421.1.tgz",
+ "integrity": "sha512-938QjUv0z+QqK6BAvgwX/lCIZ2b224ZXoXtGTbhyNVMhB+mt4Dj24cj9qca4ekNXjVM7uTKp1yOHZO97fVSacw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260421.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260421.1.tgz",
+ "integrity": "sha512-YI4+mLfwnJcKJ+iPyxzx+tp2Jy4o29BxBPSQGZxl/AZyvZ9eTKsmNZmtjEiT4i3O/M0tdO/B/d9ESDHbRCs2rQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260421.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260421.1.tgz",
+ "integrity": "sha512-q1SFgwlNH9lFmw74vh7EJbJtduo92Nx51mNOfd3/u6pux6AldcwRviYzKEEv3FEbtv6OBB7J8D5f8vtZj7Z6Sg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sentry/cloudflare": {
+ "version": "10.49.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.49.0.tgz",
+ "integrity": "sha512-kHNIwJ6SX39R5TRoW/Bf25rgrBwXBbD44fEK9+hkJ3IdGBLktXG2+T7mNGjpvR98TWxQDhcvs8WLfFw/SsDGrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.1",
+ "@sentry/core": "10.49.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.x"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "10.49.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.49.0.tgz",
+ "integrity": "sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz",
+ "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "4.20260421.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260421.0.tgz",
+ "integrity": "sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "^0.34.5",
+ "undici": "7.24.8",
+ "workerd": "1.20260421.1",
+ "ws": "8.18.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/undici": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz",
+ "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20260421.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260421.1.tgz",
+ "integrity": "sha512-zTYD+xFR4d7TUCxsyl7FTPth9a8CDgk8pM7xUWbJxo0SGUx+2e5C7Q5LrramBZwmuAErtzXmOjlQ15PtkPAhZA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260421.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260421.1",
+ "@cloudflare/workerd-linux-64": "1.20260421.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260421.1",
+ "@cloudflare/workerd-windows-64": "1.20260421.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.84.1",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.84.1.tgz",
+ "integrity": "sha512-Xe1S/Bik7pNdtdJ+asHsEZC2dX9k3WxYn2BbxFtOrrLVxN/LKi750zsrjX41jSAk00M/O1l7jzyQV4sQqw8ftg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.4.2",
+ "@cloudflare/unenv-preset": "2.16.0",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.27.3",
+ "miniflare": "4.20260421.0",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260421.1"
+ },
+ "bin": {
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=20.3.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.20260421.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ }
+ }
+}
diff --git a/googledocs/wrangler.toml b/googledocs/wrangler.toml
index db55787..3e90e6f 100644
--- a/googledocs/wrangler.toml
+++ b/googledocs/wrangler.toml
@@ -12,4 +12,9 @@ SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13"
# Secrets (set via `wrangler secret put `):
# GOOGLEDOCS_AUTH_KEY — token expected in the X-Tickets-Auth header
# GOOGLEDOCS_SPREADSHEET_ID — target Google Sheets spreadsheet ID
-# GOOGLEDOCS_SERVICE_ACCOUNT_JSON — service account JSON for Google APIs
+# GOOGLE_SERVICE_ACCOUNT_EMAIL — Google service account email
+# GOOGLE_PRIVATE_KEY — Google service account private key (\n escaped)
+# RANKBLOX_TICKETS_API_SECRET — token sent to the RankBlox ticket bot API
+# Optional vars/secrets:
+# GOOGLEDOCS_SHEET_TAB — sheet tab to append to (default: Tickets)
+# RANKBLOX_TICKETS_API_URL — base URL for the RankBlox ticket bot API
From 70b71791a90c15478c71df5dc58db726d81bfcbf Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Tue, 28 Apr 2026 11:03:14 -0400
Subject: [PATCH 3/9] add privacy policy
Co-authored-by: Copilot
---
googledocs/index.js | 48 ++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/googledocs/index.js b/googledocs/index.js
index 9d37cd7..702b8aa 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -10,6 +10,7 @@ const GOOGLE_WRITE_DEBOUNCE_MS = 750;
const GOOGLE_WRITE_MIN_INTERVAL_MS = 1500;
const BOT_API_URL_HEADER = "X-Tickets-Bot-Api-Url";
const BOT_API_SECRET_HEADER = "X-Tickets-Bot-Api-Secret";
+const PRIVACY_POLICY_PATHS = new Set(["/privacy", "/privacy-policy"]);
const TICKET_COLUMNS = 11;
const TICKET_HEADERS = [
"Ticket ID",
@@ -46,6 +47,47 @@ function errorResponse(status, message) {
return jsonResponse(status, { error: message });
}
+function privacyPolicyResponse() {
+ const body = [
+ "Privacy Policy",
+ "",
+ "Last updated: 2026-04-28",
+ "",
+ "This worker processes ticket lifecycle events for support tickets.",
+ "",
+ "Information used:",
+ "- Discord ticket and channel IDs",
+ "- Discord user IDs tied to ticket actions",
+ "- Guild IDs and ticket status details",
+ "- Timestamps for opened, claimed, unclaimed, first reply, close requested, and closed events",
+ "- Optional panel titles and spreadsheet routing settings",
+ "",
+ "How it is used:",
+ "- To record ticket activity",
+ "- To sync ticket status to Google Sheets",
+ "- To coordinate with the ticket bot and related worker services",
+ "",
+ "Sharing:",
+ "- Data is sent only to the configured Google Sheets spreadsheet and configured RankBlox ticket bot services needed to process the ticket",
+ "- The worker does not sell personal data",
+ "",
+ "Retention:",
+ "- Ticket records remain in Google Sheets until you delete them",
+ "- Local worker cache is temporary and may reset when the worker restarts",
+ "",
+ "Contact:",
+ "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.com",
+ ].join("\n");
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "content-type": "text/plain; charset=utf-8",
+ "cache-control": "public, max-age=3600",
+ },
+ });
+}
+
function getHeaderValue(request, name) {
const value = request.headers.get(name);
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@@ -1129,6 +1171,10 @@ async function handleTicketSeedRequest(request, env) {
async function handleRequest(request, env, ctx) {
const url = new URL(request.url);
+ if (PRIVACY_POLICY_PATHS.has(url.pathname) && (request.method === "GET" || request.method === "HEAD")) {
+ return privacyPolicyResponse();
+ }
+
if (url.pathname === "/api/tickets" && request.method === "GET") {
if (!isValidAuthRequest(request, env)) {
return errorResponse(401, "Invalid auth key");
@@ -1138,7 +1184,7 @@ async function handleRequest(request, env, ctx) {
return jsonResponse(200, {
ok: true,
- endpoints: ["/events", "/api/tickets/bootstrap", "/api/tickets/seed"],
+ endpoints: ["/events", "/api/tickets/bootstrap", "/api/tickets/seed", "/privacy-policy"],
hasSpreadsheet: Boolean(spreadsheetId),
spreadsheet_id: spreadsheetId,
sheet_tab: sheetTab,
From 36af6e12eeeb7724547f03c057f89a7e426d0f17 Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Fri, 8 May 2026 10:30:47 -0400
Subject: [PATCH 4/9] Update privacy policy
Co-authored-by: Copilot
---
googledocs/index.js | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/googledocs/index.js b/googledocs/index.js
index 702b8aa..2e5f73f 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -51,16 +51,16 @@ function privacyPolicyResponse() {
const body = [
"Privacy Policy",
"",
- "Last updated: 2026-04-28",
+ "Last updated: 2026-05-08",
"",
- "This worker processes ticket lifecycle events for support tickets.",
+ "This api processes ticket lifecycle events for tickets.",
"",
"Information used:",
"- Discord ticket and channel IDs",
"- Discord user IDs tied to ticket actions",
"- Guild IDs and ticket status details",
- "- Timestamps for opened, claimed, unclaimed, first reply, close requested, and closed events",
- "- Optional panel titles and spreadsheet routing settings",
+ "- Timestamps for opened, claimed, unclaimed, first reply, close requested, panel names, and closed events",
+ "- Optional spreadsheet routing settings",
"",
"How it is used:",
"- To record ticket activity",
@@ -73,7 +73,7 @@ function privacyPolicyResponse() {
"",
"Retention:",
"- Ticket records remain in Google Sheets until you delete them",
- "- Local worker cache is temporary and may reset when the worker restarts",
+ "- Local worker cache is temporary and does not persist across deployments or restarts",
"",
"Contact:",
"- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.com",
From b148e8e33470942c728c1fa1a6b692ffc39c8dc8 Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Fri, 8 May 2026 10:48:44 -0400
Subject: [PATCH 5/9] add a docs that explain everything.
Co-authored-by: Copilot
---
googledocs/docs.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 74 insertions(+)
create mode 100644 googledocs/docs.md
diff --git a/googledocs/docs.md b/googledocs/docs.md
new file mode 100644
index 0000000..6474a0f
--- /dev/null
+++ b/googledocs/docs.md
@@ -0,0 +1,74 @@
+# Google Docs (Google Sheets) User Guide
+
+This guide is for users only.
+
+It assumes the integration is already live and connected by your admin/developer. You do not need to deploy anything.
+
+## What this integration does
+
+When ticket activity happens, the system writes or updates one row in your Google Sheet.
+
+Common ticket events that update the sheet:
+
+1. Ticket opened
+2. Ticket claimed/unclaimed
+3. First response sent
+4. Close requested
+5. Ticket closed
+
+## What you need to do as a user
+
+### 1. Setting up the Google Sheet
+
+1. Open or create a sheet, and get the id (the long string in the url)
+2. Create/rename a tab, and keep this in mind (default `Tickets`).
+3. Give the system editor access to the document. Email: rankblox-docs-bot@gmail-to-discord-340802.iam.gserviceaccount.com
+
+### 2. Confirm headers are present
+
+Your tickets tab should have these columns in row 1:
+
+1. Ticket ID
+2. Channel ID
+3. Status
+4. Guild ID
+5. User ID
+6. Claimed By
+7. Close Requested By
+8. Opened At
+9. First Reply At
+10. Closed At
+11. Panel Title
+
+The integration should write them automatically.
+
+### 3. Use your ticket system normally
+
+You do not manually push data to Google Sheets.
+
+Just use the ticket bot/panel as usual:
+
+1. Open a ticket
+2. Claim or unclaim it
+3. Reply to it
+4. Request close or close it
+
+The sheet should update shortly after each action.
+
+### 4. Check if sync is working
+
+After you perform an action in tickets:
+
+1. Refresh the sheet.
+2. Find the ticket by `Ticket ID` or `Channel ID`.
+3. Confirm fields changed as expected:
+ - `Status` reflects the latest state (`open`, `claimed`, `close_requested`, `closed`, etc.)
+ - `First Reply At` fills after first staff reply
+ - `Closed At` fills when closed
+ - `Claimed By` and `Close Requested By` reflect user IDs from ticket actions
+
+### 5. Understand expected delay
+
+Small delay is normal.
+
+Writes are queued and debounced to reduce spam/duplicates, so updates can take a short moment to appear.
\ No newline at end of file
From 2198a208572935aebc7a22b7cf2455ecfeefa89b Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Fri, 8 May 2026 11:10:15 -0400
Subject: [PATCH 6/9] Edited after a full review of code
---
googledocs/index.js | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/googledocs/index.js b/googledocs/index.js
index 2e5f73f..5af4db8 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -5,7 +5,7 @@ const GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
const DEFAULT_SHEET_TAB = "Tickets";
const DEFAULT_SPREADSHEET_ID_HEADER = "X-Tickets-Google-Sheets-Id";
const DEFAULT_SHEET_TAB_HEADER = "X-Tickets-Google-Sheet-Tab";
-const GOOGLE_WRITE_MAX_ATTEMPTS = 2;
+const GOOGLE_WRITE_MAX_ATTEMPTS = 5;
const GOOGLE_WRITE_DEBOUNCE_MS = 750;
const GOOGLE_WRITE_MIN_INTERVAL_MS = 1500;
const BOT_API_URL_HEADER = "X-Tickets-Bot-Api-Url";
@@ -99,8 +99,7 @@ function normalizeConfiguredTicketValue(value) {
function getSpreadsheetConfig(request, env) {
const spreadsheetId =
- getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER) ||
- readRequiredEnv(env, "GOOGLEDOCS_SPREADSHEET_ID");
+ getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER)
const sheetTab =
getHeaderValue(request, DEFAULT_SHEET_TAB_HEADER) ||
env.GOOGLEDOCS_SHEET_TAB ||
@@ -110,8 +109,8 @@ function getSpreadsheetConfig(request, env) {
}
function getBotApiConfig(request, env) {
- const botApiUrl = getHeaderValue(request, BOT_API_URL_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_URL");
- const botApiSecret = getHeaderValue(request, BOT_API_SECRET_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET");
+ const botApiUrl = readRequiredEnv(env, "RANKBLOX_TICKETS_API_URL");
+ const botApiSecret = readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET");
return {
botApiUrl,
@@ -1258,10 +1257,7 @@ async function handleRequest(request, env, ctx) {
return errorResponse(400, "Request body must be a JSON object");
}
- // For the public webhook path `/tickets-webhook` we allow missing `user_id`
- // because some integrations (e.g. channel-delete notifications) may not
- // include a user identifier. For other endpoints, `user_id` is still
- // required.
+
if (!body.user_id && url.pathname !== "/tickets-webhook") {
return errorResponse(400, "Missing user_id in request body");
}
From eebd2cb106799cb3533860b96e8bf51138930b3c Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Fri, 8 May 2026 11:34:55 -0400
Subject: [PATCH 7/9] Fixes to be compliant with guidelines
Co-authored-by: Copilot
---
googledocs/index.js | 57 ++++++++++++----------------------------
googledocs/wrangler.toml | 10 ++++++-
2 files changed, 26 insertions(+), 41 deletions(-)
diff --git a/googledocs/index.js b/googledocs/index.js
index 5af4db8..2d6c160 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -141,28 +141,29 @@ function isValidAuthRequest(request, env) {
}
const provided =
+ getHeaderValue(request, "Authorization") ||
getHeaderValue(request, "X-Tickets-Auth") ||
getHeaderValue(request, "X-Tickets-Secret") ||
- getHeaderValue(request, "Authorization") ||
"";
const normalized = provided.startsWith("Bearer ") ? provided.slice("Bearer ".length) : provided;
return normalized === expected;
}
-async function validateSecrets(env, secrets) {
- const spreadsheetId = secrets.spreadsheet_id || secrets.GOOGLEDOCS_SPREADSHEET_ID;
- const sheetTab = secrets.sheet_tab || secrets.GOOGLEDOCS_SHEET_TAB || DEFAULT_SHEET_TAB;
+async function validateSecrets(request, env) {
+ const spreadsheetId = getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER);
+ const sheetTabHeader = request.headers.get(DEFAULT_SHEET_TAB_HEADER);
+ const sheetTab = getHeaderValue(request, DEFAULT_SHEET_TAB_HEADER) || DEFAULT_SHEET_TAB;
if (!spreadsheetId) {
- return errorResponse(400, "Missing spreadsheet_id.");
+ return errorResponse(400, `Missing ${DEFAULT_SPREADSHEET_ID_HEADER} header.`);
}
if (!isSpreadsheetId(spreadsheetId)) {
- return errorResponse(400, "Your spreadsheet_id is invalid.");
+ return errorResponse(400, "Invalid spreadsheet id format.");
}
- if (!isValidSheetTabName(sheetTab)) {
- return errorResponse(400, "Your sheet_tab is invalid.");
+ if (sheetTabHeader && !isValidSheetTabName(sheetTab)) {
+ return errorResponse(400, "Invalid sheet tab name.");
}
return verifySpreadsheetAccess(env, spreadsheetId, sheetTab);
@@ -872,10 +873,10 @@ async function verifySpreadsheetAccess(env, spreadsheetId, sheetTab) {
}
if (response.status === 429) {
- return errorResponse(400, "Google Sheets rate limit exceeded. Try again shortly.");
+ return errorResponse(500, "Google Sheets rate limit exceeded while validating the spreadsheet. Try again shortly.");
}
- return errorResponse(400, "Unable to verify spreadsheet access.");
+ return errorResponse(500, "Google Sheets is temporarily unavailable while validating the spreadsheet.");
}
const data = await response.json();
@@ -1174,11 +1175,11 @@ async function handleRequest(request, env, ctx) {
return privacyPolicyResponse();
}
- if (url.pathname === "/api/tickets" && request.method === "GET") {
- if (!isValidAuthRequest(request, env)) {
- return errorResponse(401, "Invalid auth key");
- }
+ if (!isValidAuthRequest(request, env)) {
+ return errorResponse(401, "Invalid auth key");
+ }
+ if (url.pathname === "/api/tickets" && request.method === "GET") {
const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env);
return jsonResponse(200, {
@@ -1195,38 +1196,19 @@ async function handleRequest(request, env, ctx) {
}
if (url.pathname === "/validate-secrets") {
- let secrets;
- try {
- secrets = await request.json();
- } catch {
- return errorResponse(400, "Invalid request body");
- }
-
- if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) {
- return errorResponse(400, "Request body must be a JSON object");
- }
-
- const accessCheck = await validateSecrets(env, secrets);
+ const accessCheck = await validateSecrets(request, env);
if (accessCheck) {
return accessCheck;
}
- return new Response(null, { status: 204 });
+ return jsonResponse(200, {});
}
if (url.pathname === "/events") {
- if (!isValidAuthRequest(request, env)) {
- return errorResponse(401, "Invalid auth key");
- }
-
return handleLifecycleEvent(request, env, ctx);
}
if (url.pathname === "/api/tickets/bootstrap" || url.pathname === "/api/tickets/seed") {
- if (!isValidAuthRequest(request, env)) {
- return errorResponse(401, "Invalid auth key");
- }
-
return handleTicketSeedRequest(request, env);
}
@@ -1234,10 +1216,6 @@ async function handleRequest(request, env, ctx) {
return errorResponse(404, "Not Found");
}
- if (!isValidAuthRequest(request, env)) {
- return errorResponse(401, "Invalid auth key");
- }
-
const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env);
if (!spreadsheetId) {
return errorResponse(
@@ -1257,7 +1235,6 @@ async function handleRequest(request, env, ctx) {
return errorResponse(400, "Request body must be a JSON object");
}
-
if (!body.user_id && url.pathname !== "/tickets-webhook") {
return errorResponse(400, "Missing user_id in request body");
}
diff --git a/googledocs/wrangler.toml b/googledocs/wrangler.toml
index 3e90e6f..d773699 100644
--- a/googledocs/wrangler.toml
+++ b/googledocs/wrangler.toml
@@ -8,6 +8,9 @@ enabled = true
[vars]
SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13"
+#[[kv_namespaces]]
+#binding = "INTEGRATION_CACHE"
+#id = "fbdf23642f6a40d0b5876abf3265910d"
# Secrets (set via `wrangler secret put `):
# GOOGLEDOCS_AUTH_KEY — token expected in the X-Tickets-Auth header
@@ -17,4 +20,9 @@ SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13"
# RANKBLOX_TICKETS_API_SECRET — token sent to the RankBlox ticket bot API
# Optional vars/secrets:
# GOOGLEDOCS_SHEET_TAB — sheet tab to append to (default: Tickets)
-# RANKBLOX_TICKETS_API_URL — base URL for the RankBlox ticket bot API
+# Integration request headers:
+# Authorization: Bearer
+# X-Tickets-Google-Sheets-Id:
+# X-Tickets-Google-Sheet-Tab:
+# X-Tickets-Bot-Api-Url:
+# X-Tickets-Bot-Api-Secret:
From 3fd2a62be1f07ec05883397410c9a15bff152a6f Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Fri, 8 May 2026 11:56:18 -0400
Subject: [PATCH 8/9] add comments
Co-authored-by: Copilot
---
googledocs/index.js | 52 +++++++++++++++++++++++++++++++++------------
1 file changed, 39 insertions(+), 13 deletions(-)
diff --git a/googledocs/index.js b/googledocs/index.js
index 2d6c160..f28cba6 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -127,9 +127,11 @@ function isSameOriginUrl(leftUrl, rightUrl) {
}
function getExpectedAuthKey(env) {
+ // Try multiple auth key sources in order of preference:
+ // 1. Integration-specific key (GOOGLEDOCS_AUTH_KEY)
+ // 2. Fallback to main API secret (RANKBLOX_TICKETS_API_SECRET)
return (
readRequiredEnv(env, "GOOGLEDOCS_AUTH_KEY") ||
- readRequiredEnv(env, "TICKETS_SHARED_SECRET") ||
readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET")
);
}
@@ -137,14 +139,16 @@ function getExpectedAuthKey(env) {
function isValidAuthRequest(request, env) {
const expected = getExpectedAuthKey(env);
if (!expected) {
- return true;
+ return false; // No auth configured, deny all requests
}
+ // Check headers in order of precedence: Authorization (standard) > custom headers (legacy)
const provided =
getHeaderValue(request, "Authorization") ||
getHeaderValue(request, "X-Tickets-Auth") ||
getHeaderValue(request, "X-Tickets-Secret") ||
"";
+ // Strip "Bearer " prefix if present
const normalized = provided.startsWith("Bearer ") ? provided.slice("Bearer ".length) : provided;
return normalized === expected;
}
@@ -179,28 +183,32 @@ function base64UrlEncodeText(text) {
}
function base64UrlEncodeBytes(bytes) {
+ // Convert bytes to binary string (required by btoa)
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
+ // Base64-encode and convert to URL-safe format
return btoa(binary)
- .replace(/\+/g, "-")
- .replace(/\//g, "_")
- .replace(/=+$/g, "");
+ .replace(/\+/g, "-") // + -> - for URL safety
+ .replace(/\//g, "_") // / -> _ for URL safety
+ .replace(/=+$/g, ""); // Remove padding
}
function pemPrivateKeyToArrayBuffer(pem) {
+ // Strip PEM formatting: remove line breaks, headers, and whitespace
const stripped = pem
.replace(/\r/g, "")
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\s+/g, "");
+ // Decode base64 to binary string, then convert to ArrayBuffer
const binary = atob(stripped);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
- bytes[i] = binary.charCodeAt(i);
+ bytes[i] = binary.charCodeAt(i); // Convert char codes back to bytes
}
return bytes.buffer;
}
@@ -267,6 +275,8 @@ function buildSheetRange(sheetTab, rowNumber) {
}
function getTicketWriteKey(spreadsheetId, sheetTab, payload) {
+ // Generate unique queue key per ticket: spreadsheetId::sheetTab::ticketId/channelId
+ // Multiple requests for same ticket are coalesced into single queue entry
const event = normaliseEventPayload(payload);
const stableId = event.ticketId || event.channelId;
if (!spreadsheetId || !sheetTab || !stableId) {
@@ -313,10 +323,12 @@ function resetTicketWriteQueueState(key) {
}
async function runGoogleSheetsWrite(task) {
+ // Serialize all Google Sheets writes through a global chain to enforce minimum interval.
+ // Prevents hitting Google's rate limits (1500ms min interval between writes).
const next = googleSheetsGlobalWriteChain.then(async () => {
const elapsed = Date.now() - googleSheetsLastWriteAt;
if (googleSheetsLastWriteAt > 0 && elapsed < GOOGLE_WRITE_MIN_INTERVAL_MS) {
- await sleep(GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed);
+ await sleep(GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed); // Wait for rate limit window
}
const result = await task();
@@ -324,24 +336,29 @@ async function runGoogleSheetsWrite(task) {
return result;
});
+ // Continue chain even if write fails (catch errors to prevent chain breakage)
googleSheetsGlobalWriteChain = next.catch(() => null);
return next;
}
async function flushTicketWriteQueue(key, env, spreadsheetId, sheetTab) {
+ // Process all pending ticket writes for this queue key.
+ // Deduplicates writes by comparing JSON signatures (same data = skip write).
const state = googleSheetsWriteQueueByKey.get(key);
if (!state || state.running) {
- return null;
+ return null; // Already flushing or queue doesn't exist
}
state.running = true;
let lastResult = { mode: "skipped", reason: "No pending ticket write" };
try {
+ // Process all payloads accumulated since last flush (handles rapid updates)
while (state.pendingPayload) {
const payload = state.pendingPayload;
- state.pendingPayload = null;
+ state.pendingPayload = null; // Clear pending so new requests can accumulate
+ // Skip if ticket state hasn't changed (JSON signature is identical)
const signature = getTicketWriteSignature(payload);
if (signature === state.lastSignature) {
lastResult = { mode: "skipped", reason: "Duplicate ticket state" };
@@ -420,18 +437,19 @@ function resetSheetCache() {
}
function parseRetryAfter(headerValue) {
+ // Retry-After header can be either seconds (number) or HTTP date
if (!headerValue) {
return null;
}
const seconds = Number(headerValue);
if (Number.isFinite(seconds)) {
- return Math.max(0, seconds * 1000);
+ return Math.max(0, seconds * 1000); // Convert seconds to milliseconds
}
const dateMs = Date.parse(headerValue);
if (Number.isFinite(dateMs)) {
- return Math.max(0, dateMs - Date.now());
+ return Math.max(0, dateMs - Date.now()); // Calculate ms until the retry-after time
}
return null;
@@ -762,6 +780,8 @@ async function ensureSheetsInitialized(env, spreadsheetId, sheetTab) {
}
async function loadSheetRowIndexCache(env, spreadsheetId, sheetTab) {
+ // Load all existing ticket rows from sheet and build lookup maps.
+ // A2:B means: start at row 2 (skip headers), columns A (ticket ID) and B (channel ID).
const accessToken = await getGoogleAccessToken(env);
const response = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A2:B`,
@@ -781,10 +801,11 @@ async function loadSheetRowIndexCache(env, spreadsheetId, sheetTab) {
const data = await response.json();
const rows = Array.isArray(data.values) ? data.values : [];
+ // Build maps: ticketId -> rowNumber and channelId -> rowNumber
rows.forEach((row, index) => {
const ticketId = row?.[0];
const channelId = row?.[1];
- const rowNumber = index + 2;
+ const rowNumber = index + 2; // +2: skip header row + convert 0-index to 1-index
if (ticketId) {
sheetRowByTicketId.set(String(ticketId), rowNumber);
@@ -992,6 +1013,7 @@ async function upsertTicketToSheet(env, payload, spreadsheetId, sheetTab) {
if (response.ok) {
const data = await response.json().catch(() => null);
const updatedRange = data?.updates?.updatedRange || "";
+ // Extract row number from updatedRange like "Sheet1!A5:K5" -> capture group 1 is "5"
const match = updatedRange.match(/!(?:[A-Z]+)(\d+):/);
if (match) {
const rowNumber = Number(match[1]);
@@ -1015,6 +1037,7 @@ async function upsertTicketToSheet(env, payload, spreadsheetId, sheetTab) {
if (attempt < GOOGLE_WRITE_MAX_ATTEMPTS) {
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
+ // Exponential backoff with 5s cap: 500ms, 1s, 2s, 4s, 5s (respect Retry-After if provided)
const backoffMs = retryAfterMs ?? Math.min(5000, 500 * 2 ** (attempt - 1));
await sleep(backoffMs);
}
@@ -1180,6 +1203,7 @@ async function handleRequest(request, env, ctx) {
}
if (url.pathname === "/api/tickets" && request.method === "GET") {
+ // Note: getSpreadsheetConfig is synchronous but awaited for consistency
const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env);
return jsonResponse(200, {
@@ -1239,6 +1263,8 @@ async function handleRequest(request, env, ctx) {
return errorResponse(400, "Missing user_id in request body");
}
+ // Ask bot API if this ticket change is meaningful (e.g., don't write duplicate states).
+ // If bot API is down, assume write is needed (optimistic fallback).
const decision = await decideWithBotApi(request, env, body)
.then((result) => {
console.log(
@@ -1249,7 +1275,7 @@ async function handleRequest(request, env, ctx) {
.catch((error) => {
console.error("[DEBUG] Pre-write bot decision failed", error);
return {
- shouldWrite: true,
+ shouldWrite: true, // On error, default to writing (better than silently dropping)
ticket: buildBotSeedRecord(body),
};
});
From 80cced85dd9eae5c7708523b8ede934f89455c34 Mon Sep 17 00:00:00 2001
From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com>
Date: Sun, 10 May 2026 23:26:54 -0400
Subject: [PATCH 9/9] Fix privacy contact email
---
googledocs/index.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/googledocs/index.js b/googledocs/index.js
index f28cba6..e726641 100644
--- a/googledocs/index.js
+++ b/googledocs/index.js
@@ -76,7 +76,7 @@ function privacyPolicyResponse() {
"- Local worker cache is temporary and does not persist across deployments or restarts",
"",
"Contact:",
- "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.com",
+ "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.xyz",
].join("\n");
return new Response(body, {