From 92941e01c155e1d4d6a6fa279cf48a68699c55b3 Mon Sep 17 00:00:00 2001 From: Mat Manna Date: Tue, 4 Aug 2026 10:15:37 -0400 Subject: [PATCH] style: prettier --- .prettierignore | 12 + README.md | 117 +- docs.html | 38 +- drizzle.config.ts | 4 +- index.html | 39 +- keys.html | 38 +- package.json | 3 +- src/api/context.ts | 6 +- src/api/modal.ts | 64 +- src/api/routers/apiKeys.ts | 34 +- src/api/routers/channels.ts | 6 +- src/api/routers/messages.ts | 3 +- src/api/routers/subscriptions.ts | 64 +- src/app.ts | 3104 +++++++++++++++++++----------- src/db/migrate.ts | 45 +- src/db/schema.ts | 101 +- src/lib/api-keys.ts | 23 +- src/lib/auth.ts | 16 +- src/store/neon.ts | 193 +- src/store/pg.test.ts | 129 +- src/store/pg.ts | 195 +- src/store/store.ts | 31 +- src/web/app.tsx | 235 ++- src/web/keys.tsx | 75 +- src/web/lockdown.ts | 10 +- src/web/navbar.tsx | 37 +- src/web/style.css | 100 +- src/web/usage.tsx | 16 +- src/worker.ts | 6 +- tsconfig.json | 2 +- usage.html | 38 +- vite.config.ts | 2 +- wrangler.jsonc | 96 +- 33 files changed, 3237 insertions(+), 1645 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dba936c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +.crush/ +.jj/ +.env.example +.env +build/ +dist/ +.wrangler/ +public/ +node_modules/ +bun.lock +.git/ + diff --git a/README.md b/README.md index 021e7ab..8b5dc29 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ 1. The bot is added to any channel 2. A channel manager or bot admin runs `/in pub` 3. Messages get approved manually ('yep'/'nope' buttons) or automatically (auto-approve) -5. Approved messages are stored in PostgreSQL and served via RSS/JSON/Webhooks -4. If configured, extra metadata can be set through a Slack form modal -7. Managers of other channels can run `/in sub` to receive forwarded copies of approved messages +4. Approved messages are stored in PostgreSQL and served via RSS/JSON/Webhooks +5. If configured, extra metadata can be set through a Slack form modal +6. Managers of other channels can run `/in sub` to receive forwarded copies of approved messages ## Map of the network @@ -27,24 +27,24 @@ See a node graph mapping the channels currently using Indigest in Hack Club at h All commands use the prefix `/in` (or `/indigest`). An optional `#channel` prefix on any command targets a different channel. -| Command | Description | -|---------|-------------| -| `/in` | Show help message with all commands | -| `/in pub` | Enable manual mode (buttons on every message) | -| `/in pub auto` | Enable auto-approve for **all users** | -| `/in pub auto @user` | Enable auto-approve for a specific user | -| `/in pub manual` | Explicitly enable manual mode (clears auto-approve) | -| `/in unpub` | Disable indigest for a channel | -| `/in unpub auto [@user]` | Remove auto-approve for a user (or all users) | -| `/in sub #channel` | Subscribe this channel to receive forwarded messages from `#channel` | -| `/in unsub #channel` | Unsubscribe this channel from `#channel` | -| `/in status` | Show pub status, feed URLs, subscriptions, and permissions | -| `/in webhook ` | Set webhook URL for approved messages | -| `/in webhook clear` | Remove webhook | -| `/in auto list` | List auto-approve users | -| `/in schema set ` | Set metadata schema (see below) | -| `/in schema get` | View current metadata schema | -| `/in schema clear` | Remove metadata schema | +| Command | Description | +| ------------------------ | -------------------------------------------------------------------- | +| `/in` | Show help message with all commands | +| `/in pub` | Enable manual mode (buttons on every message) | +| `/in pub auto` | Enable auto-approve for **all users** | +| `/in pub auto @user` | Enable auto-approve for a specific user | +| `/in pub manual` | Explicitly enable manual mode (clears auto-approve) | +| `/in unpub` | Disable indigest for a channel | +| `/in unpub auto [@user]` | Remove auto-approve for a user (or all users) | +| `/in sub #channel` | Subscribe this channel to receive forwarded messages from `#channel` | +| `/in unsub #channel` | Unsubscribe this channel from `#channel` | +| `/in status` | Show pub status, feed URLs, subscriptions, and permissions | +| `/in webhook ` | Set webhook URL for approved messages | +| `/in webhook clear` | Remove webhook | +| `/in auto list` | List auto-approve users | +| `/in schema set ` | Set metadata schema (see below) | +| `/in schema get` | View current metadata schema | +| `/in schema clear` | Remove metadata schema | ## Permissions @@ -59,6 +59,7 @@ All commands use the prefix `/in` (or `/indigest`). An optional `#channel` prefi A **subscription** means channel A subscribes to channel B. When a message is approved in channel B, it is automatically forwarded to channel A as a new Slack message posted as the original author (with their name and profile picture). **Forwarded messages include:** + - The original author's display name and avatar - The message text - A context footer with a permalink to the original message @@ -80,16 +81,16 @@ Channel creators can define a metadata form that opens in a **modal** when someo **Supported field types:** -| Type | Notes | -|------|-------| -| `plain_text_input` | Supports `multiline`, `min_length`, `max_length`, `placeholder`, `initial_value` | -| `url_text_input` | URL validation built in | -| `email_text_input` | Email validation built in | -| `number_input` | Integer only | -| `static_select` | Single select, requires `options` array with `{label, value}` | -| `multi_static_select` | Multi-select, requires `options` array | -| `datepicker` | Date picker | -| `file_input` | File upload (uploaded to Hack Club CDN if key is set) | +| Type | Notes | +| --------------------- | -------------------------------------------------------------------------------- | +| `plain_text_input` | Supports `multiline`, `min_length`, `max_length`, `placeholder`, `initial_value` | +| `url_text_input` | URL validation built in | +| `email_text_input` | Email validation built in | +| `number_input` | Integer only | +| `static_select` | Single select, requires `options` array with `{label, value}` | +| `multi_static_select` | Multi-select, requires `options` array | +| `datepicker` | Date picker | +| `file_input` | File upload (uploaded to Hack Club CDN if key is set) | Submitted metadata is stored as JSON in the `metadata` column, included in webhook payloads, and returned in the JSON API. @@ -107,7 +108,7 @@ When a message is approved, fires `POST` to the webhook URL: "user_name": "alice", "text": "the message text", "timestamp": "2025-01-01T00:00:00.000Z", - "metadata": {"title":"My Title","priority":"high"} + "metadata": { "title": "My Title", "priority": "high" } } } ``` @@ -129,16 +130,17 @@ All endpoints require Basic Auth using `API_USERNAME` / `API_PASSWORD`. If `API_ GET /api/messages?channel=&limit=50&page=1&after=2025-01-01T00:00:00Z&before=2026-01-01T00:00:00Z&userId=U12345 ``` -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `channel` | string | yes | Slack channel ID | -| `limit` | number | no | Results per page (1-10000, default 50) | -| `page` | number | no | Page number (default 1) | -| `after` | string | no | ISO 8601 timestamp — only messages after this time | -| `before` | string | no | ISO 8601 timestamp — only messages before this time | -| `userId` | string | no | Filter by Slack user ID | +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | --------------------------------------------------- | +| `channel` | string | yes | Slack channel ID | +| `limit` | number | no | Results per page (1-10000, default 50) | +| `page` | number | no | Page number (default 1) | +| `after` | string | no | ISO 8601 timestamp — only messages after this time | +| `before` | string | no | ISO 8601 timestamp — only messages before this time | +| `userId` | string | no | Filter by Slack user ID | **Response:** + ```json { "data": [ @@ -168,12 +170,13 @@ GET /api/messages?channel=&limit=50&page=1&after=2025-01-01T00:00:00 GET /api/messages/{slackTs}?channel= ``` -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `slackTs` | string | yes | Slack message timestamp (in path) | -| `channel` | string | yes | Slack channel ID (query param) | +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | --------------------------------- | +| `slackTs` | string | yes | Slack message timestamp (in path) | +| `channel` | string | yes | Slack channel ID (query param) | **Response:** + ```json { "data": { @@ -237,7 +240,6 @@ npx wrangler dev # local dev npx wrangler deploy # deploy to Cloudflare ``` - ## Deploy ### Cloudflare Worker (production) @@ -266,19 +268,18 @@ Runs the app on `localhost:8080` with a local Postgres 17 instance. The store au ### Secrets (set via `wrangler secret put` or `.env`) -| Variable | Description | -|---|---| -| `SLACK_BOT_TOKEN` | Slack bot user OAuth token (`xoxb-...`) | -| `SLACK_SIGNING_SECRET` | Slack request signing secret | -| `DATABASE_URL` | PostgreSQL connection string (Neon for Worker, local for Docker) | -| `API_PASSWORD` | Password for Basic Auth on `/api/*` routes (empty = auth disabled) | +| Variable | Description | +| ---------------------- | ------------------------------------------------------------------ | +| `SLACK_BOT_TOKEN` | Slack bot user OAuth token (`xoxb-...`) | +| `SLACK_SIGNING_SECRET` | Slack request signing secret | +| `DATABASE_URL` | PostgreSQL connection string (Neon for Worker, local for Docker) | +| `API_PASSWORD` | Password for Basic Auth on `/api/*` routes (empty = auth disabled) | ### Vars (set in `wrangler.jsonc` or `.env`) -| Variable | Description | Default | -|---|---|---| -| `BASE_URL` | Public base URL for feed links in Slack responses | `http://localhost:8080` | -| `API_USERNAME` | Username for Basic Auth on `/api/*` routes | `admin` | -| `LOCKDOWN_USERS` | Comma-separated Slack user IDs that bypass permission checks | (empty) | -| `HACK_CLUB_CDN_KEY` | Hack Club CDN API key for `file_input` uploads | (empty) | - +| Variable | Description | Default | +| ------------------- | ------------------------------------------------------------ | ----------------------- | +| `BASE_URL` | Public base URL for feed links in Slack responses | `http://localhost:8080` | +| `API_USERNAME` | Username for Basic Auth on `/api/*` routes | `admin` | +| `LOCKDOWN_USERS` | Comma-separated Slack user IDs that bypass permission checks | (empty) | +| `HACK_CLUB_CDN_KEY` | Hack Club CDN API key for `file_input` uploads | (empty) | diff --git a/docs.html b/docs.html index a8e9d32..8fd0865 100644 --- a/docs.html +++ b/docs.html @@ -1,19 +1,25 @@ - + - - - - + + + - - indigest — api docs - - - - -
- - - + + indigest — api docs + + + + +
+ + + diff --git a/drizzle.config.ts b/drizzle.config.ts index 0429d64..2a8e064 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ schema: "src/db/schema.ts", out: "drizzle", dbCredentials: { - url: process.env.DATABASE_URL || "postgres://indigest:indigest@localhost:5432/indigest", + url: + process.env.DATABASE_URL || + "postgres://indigest:indigest@localhost:5432/indigest", }, }); diff --git a/index.html b/index.html index 7df0f07..b168996 100644 --- a/index.html +++ b/index.html @@ -1,18 +1,25 @@ - + - - - - - - indigest - feed map - - - - -
- - - + + + + + + indigest - feed map + + + + +
+ + + diff --git a/keys.html b/keys.html index 2d041f5..97b4ee2 100644 --- a/keys.html +++ b/keys.html @@ -1,19 +1,25 @@ - + - - - - + + + - - indigest - your api keys - - - - -
- - - + + indigest - your api keys + + + + +
+ + + diff --git a/package.json b/package.json index 9cbad30..3f22c32 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "db:drop": "drizzle-kit drop", "db:check": "drizzle-kit check", "vercel:dev": "vercel dev", - "vercel:deploy": "vercel deploy --prod" + "vercel:deploy": "vercel deploy --prod", + "format": "npx prettier -write ." }, "devDependencies": { "@cloudflare/vite-plugin": "^1.48.0", diff --git a/src/api/context.ts b/src/api/context.ts index 0df44c1..44f7e83 100644 --- a/src/api/context.ts +++ b/src/api/context.ts @@ -62,7 +62,11 @@ export const authOrApiKeyProcedure = base.use(async ({ context, next }) => { return next(); }); -export async function isChannelManager(channelId: string, userId: string, slackToken: string): Promise { +export async function isChannelManager( + channelId: string, + userId: string, + slackToken: string, +): Promise { if (!slackToken) return false; const slack = new WebClient(slackToken); try { diff --git a/src/api/modal.ts b/src/api/modal.ts index fd72a96..554b426 100644 --- a/src/api/modal.ts +++ b/src/api/modal.ts @@ -20,7 +20,10 @@ export interface MetadataSchema { function buildBlocks(schema: MetadataSchema): any[] { const blocks: any[] = [ - { type: "header", text: { type: "plain_text", text: schema.title, emoji: true } }, + { + type: "header", + text: { type: "plain_text", text: schema.title, emoji: true }, + }, { type: "divider" }, ]; @@ -31,7 +34,9 @@ function buildBlocks(schema: MetadataSchema): any[] { case "plain_text_input": case "url_text_input": case "email_text_input": - element.placeholder = field.placeholder ? { type: "plain_text", text: field.placeholder } : undefined; + element.placeholder = field.placeholder + ? { type: "plain_text", text: field.placeholder } + : undefined; element.initial_value = field.initial_value; element.multiline = field.multiline; element.min_length = field.min_length; @@ -39,7 +44,9 @@ function buildBlocks(schema: MetadataSchema): any[] { break; case "number_input": - element.placeholder = field.placeholder ? { type: "plain_text", text: field.placeholder } : undefined; + element.placeholder = field.placeholder + ? { type: "plain_text", text: field.placeholder } + : undefined; element.initial_value = field.initial_value; element.min_length = field.min_length; element.max_length = field.max_length; @@ -48,12 +55,19 @@ function buildBlocks(schema: MetadataSchema): any[] { case "static_select": case "multi_static_select": - element.placeholder = field.placeholder ? { type: "plain_text", text: field.placeholder } : undefined; - element.options = (field.options || []).map((o) => ({ text: { type: "plain_text", text: o.label }, value: o.value })); + element.placeholder = field.placeholder + ? { type: "plain_text", text: field.placeholder } + : undefined; + element.options = (field.options || []).map((o) => ({ + text: { type: "plain_text", text: o.label }, + value: o.value, + })); break; case "datepicker": - element.placeholder = field.placeholder ? { type: "plain_text", text: field.placeholder } : undefined; + element.placeholder = field.placeholder + ? { type: "plain_text", text: field.placeholder } + : undefined; element.initial_date = field.initial_value; break; @@ -73,7 +87,21 @@ function buildBlocks(schema: MetadataSchema): any[] { return blocks; } -function extractMetadata(schema: MetadataSchema, state: Record>): Record { +function extractMetadata( + schema: MetadataSchema, + state: Record< + string, + Record< + string, + { + value?: string; + selected_options?: { value: string }[]; + selected_date?: string; + files?: any[]; + } + > + >, +): Record { const metadata: Record = {}; for (const field of schema.fields) { const values = state?.[`field_${field.action_id}`]?.[field.action_id]; @@ -81,7 +109,8 @@ function extractMetadata(schema: MetadataSchema, state: Record o.value) || []; + metadata[field.action_id] = + values.selected_options?.map((o) => o.value) || []; break; case "datepicker": metadata[field.action_id] = values.selected_date || ""; @@ -112,7 +141,11 @@ export async function openMetadataModal( type: "modal", callback_id: "metadata_modal", title: { type: "plain_text", text: schema.title, emoji: true }, - submit: { type: "plain_text", text: schema.submit_label || "Save to Feed", emoji: true }, + submit: { + type: "plain_text", + text: schema.submit_label || "Save to Feed", + emoji: true, + }, close: { type: "plain_text", text: "Cancel", emoji: true }, private_metadata: JSON.stringify({ channelId, messageTs, botMessageTs }), notify_on_close: true, @@ -123,7 +156,18 @@ export async function openMetadataModal( export function extractMetadataFromView( schema: MetadataSchema, - state: Record>, + state: Record< + string, + Record< + string, + { + value?: string; + selected_options?: { value: string }[]; + selected_date?: string; + files?: any[]; + } + > + >, ): string { const metadata = extractMetadata(schema, state); return JSON.stringify(metadata); diff --git a/src/api/routers/apiKeys.ts b/src/api/routers/apiKeys.ts index e8d8120..dd31609 100644 --- a/src/api/routers/apiKeys.ts +++ b/src/api/routers/apiKeys.ts @@ -31,7 +31,7 @@ export const listApiKeys = authRequiredProcedure .from(apiKeyChannels) .where(eq(apiKeyChannels.keyId, key.id)); return { ...key, channels: channels.map((c) => c.channelId) }; - }) + }), ); return keysWithChannels; @@ -39,21 +39,36 @@ export const listApiKeys = authRequiredProcedure export const createApiKey = authRequiredProcedure .route({ method: "POST", path: "/api-keys" }) - .input(z.object({ name: z.string().min(1), channelIds: z.array(z.string()).optional() })) + .input( + z.object({ + name: z.string().min(1), + channelIds: z.array(z.string()).optional(), + }), + ) .handler(async ({ input, context }) => { const slackId = context.session?.user?.slackId; - if (!slackId) throw new ORPCError("FORBIDDEN", { message: "No Slack ID on session" }); + if (!slackId) + throw new ORPCError("FORBIDDEN", { message: "No Slack ID on session" }); // Validate user has access to each specified channel if (input.channelIds && input.channelIds.length > 0) { const isLockdown = context.lockdownUsers.includes(slackId); for (const channelId of input.channelIds) { const ch = await context.store.getChannel(channelId); - if (!ch) throw new ORPCError("NOT_FOUND", { message: `Channel ${channelId} not found` }); + if (!ch) + throw new ORPCError("NOT_FOUND", { + message: `Channel ${channelId} not found`, + }); if (!isLockdown && !ch.accessPermUsers.includes(slackId)) { - const isManager = await isChannelManager(channelId, slackId, context.slackToken); + const isManager = await isChannelManager( + channelId, + slackId, + context.slackToken, + ); if (!isManager) { - throw new ORPCError("FORBIDDEN", { message: `You don't have access to channel ${ch.name || channelId}` }); + throw new ORPCError("FORBIDDEN", { + message: `You don't have access to channel ${ch.name || channelId}`, + }); } } } @@ -91,8 +106,11 @@ export const revokeApiKey = authRequiredProcedure .handler(async ({ input, context }) => { const db = getDb(context.databaseUrl); await db.delete(apiKeyChannels).where(eq(apiKeyChannels.keyId, input.id)); - await db.update(apiKeys) .set({ revokedBy: context.session?.user?.id || null }).where(eq(apiKeys.id, input.id)); - return {ok: true} + await db + .update(apiKeys) + .set({ revokedBy: context.session?.user?.id || null }) + .where(eq(apiKeys.id, input.id)); + return { ok: true }; }); export const apiKeyRouter = { diff --git a/src/api/routers/channels.ts b/src/api/routers/channels.ts index 2fe0aed..112dd56 100644 --- a/src/api/routers/channels.ts +++ b/src/api/routers/channels.ts @@ -1,6 +1,10 @@ import { ORPCError } from "@orpc/server"; import { z } from "zod"; -import { publicProcedure, authOrApiKeyProcedure, authRequiredProcedure } from "../context"; +import { + publicProcedure, + authOrApiKeyProcedure, + authRequiredProcedure, +} from "../context"; const channelSchema = z.object({ id: z.string(), diff --git a/src/api/routers/messages.ts b/src/api/routers/messages.ts index 41c757d..1948e0c 100644 --- a/src/api/routers/messages.ts +++ b/src/api/routers/messages.ts @@ -18,7 +18,8 @@ export const listMessages = authOrApiKeyProcedure const { channel, after, before, userId, limit, page } = input; const ch = await context.store.getChannel(channel); - if (!ch || !ch.enabled) throw new ORPCError("Channel not found or not enabled"); + if (!ch || !ch.enabled) + throw new ORPCError("Channel not found or not enabled"); const all = await context.store.getMessages(channel, 100000, 0); diff --git a/src/api/routers/subscriptions.ts b/src/api/routers/subscriptions.ts index c3e86a9..f14b655 100644 --- a/src/api/routers/subscriptions.ts +++ b/src/api/routers/subscriptions.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { ORPCError } from "@orpc/server"; -import { publicProcedure, authRequiredProcedure, isChannelManager } from "../context"; +import { + publicProcedure, + authRequiredProcedure, + isChannelManager, +} from "../context"; export const listSubscriptions = publicProcedure .route({ method: "GET", path: "/subscriptions" }) @@ -13,7 +17,9 @@ export const listSubscriptionsBySubscriber = publicProcedure .route({ method: "GET", path: "/subscriptions/by-subscriber" }) .input(z.object({ subscriberChannelId: z.string().min(1) })) .handler(async ({ input, context }) => { - return context.store.getSubscriptionsBySubscriber(input.subscriberChannelId); + return context.store.getSubscriptionsBySubscriber( + input.subscriberChannelId, + ); }); export const addSubscription = authRequiredProcedure @@ -26,17 +32,37 @@ export const addSubscription = authRequiredProcedure ) .handler(async ({ input, context }) => { const source = await context.store.getChannel(input.sourceChannelId); - if (!source) throw new ORPCError("NOT_FOUND", { message: `Source channel ${input.sourceChannelId} not found` }); + if (!source) + throw new ORPCError("NOT_FOUND", { + message: `Source channel ${input.sourceChannelId} not found`, + }); const sub = await context.store.getChannel(input.subscriberChannelId); - if (!sub) throw new ORPCError("NOT_FOUND", { message: `Subscriber channel ${input.subscriberChannelId} not found` }); + if (!sub) + throw new ORPCError("NOT_FOUND", { + message: `Subscriber channel ${input.subscriberChannelId} not found`, + }); const slackId = context.session?.user?.slackId; - if (!slackId) throw new ORPCError("FORBIDDEN", { message: "No Slack ID on session" }); + if (!slackId) + throw new ORPCError("FORBIDDEN", { message: "No Slack ID on session" }); const isLockdown = context.lockdownUsers.includes(slackId); - const isManager = isLockdown || await isChannelManager(input.subscriberChannelId, slackId, context.slackToken); - if (!isManager) throw new ORPCError("FORBIDDEN", { message: "You must be a channel manager or lockdown user to add subscriptions" }); + const isManager = + isLockdown || + (await isChannelManager( + input.subscriberChannelId, + slackId, + context.slackToken, + )); + if (!isManager) + throw new ORPCError("FORBIDDEN", { + message: + "You must be a channel manager or lockdown user to add subscriptions", + }); - await context.store.addSubscription(input.subscriberChannelId, input.sourceChannelId); + await context.store.addSubscription( + input.subscriberChannelId, + input.sourceChannelId, + ); }); export const removeSubscription = authRequiredProcedure @@ -49,12 +75,26 @@ export const removeSubscription = authRequiredProcedure ) .handler(async ({ input, context }) => { const slackId = context.session?.user?.slackId; - if (!slackId) throw new ORPCError("FORBIDDEN", { message: "No Slack ID on session" }); + if (!slackId) + throw new ORPCError("FORBIDDEN", { message: "No Slack ID on session" }); const isLockdown = context.lockdownUsers.includes(slackId); - const isManager = isLockdown || await isChannelManager(input.subscriberChannelId, slackId, context.slackToken); - if (!isManager) throw new ORPCError("FORBIDDEN", { message: "You must be a channel manager or lockdown user to remove subscriptions" }); + const isManager = + isLockdown || + (await isChannelManager( + input.subscriberChannelId, + slackId, + context.slackToken, + )); + if (!isManager) + throw new ORPCError("FORBIDDEN", { + message: + "You must be a channel manager or lockdown user to remove subscriptions", + }); - await context.store.removeSubscription(input.subscriberChannelId, input.sourceChannelId); + await context.store.removeSubscription( + input.subscriberChannelId, + input.sourceChannelId, + ); }); export const subscriptionRouter = { diff --git a/src/app.ts b/src/app.ts index b0f9053..363fe58 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,13 @@ import { Hono } from "hono"; import { WebClient } from "@slack/web-api"; -import { getAuth } from "./lib/auth" +import { getAuth } from "./lib/auth"; import { Feed } from "feed"; -import type { Store, StoreChannel, StoreMessage, StoreSubscription } from "./store/store"; +import type { + Store, + StoreChannel, + StoreMessage, + StoreSubscription, +} from "./store/store"; import { OpenAPIHandler } from "@orpc/openapi/fetch"; import { OpenAPIGenerator } from "@orpc/openapi"; import { RPCHandler } from "@orpc/server/fetch"; @@ -28,7 +33,11 @@ function slackTsToTime(ts: string): Date { return new Date(sec * 1000 + nsec / 1e6); } -async function isChannelManager(channelId: string, userId: string, slack: WebClient): Promise { +async function isChannelManager( + channelId: string, + userId: string, + slack: WebClient, +): Promise { // Check if user is the channel creator try { const conv = await slack.conversations.info({ channel: channelId }); @@ -45,17 +54,38 @@ async function isChannelManager(channelId: string, userId: string, slack: WebCli return false; } -async function verifySignature(request: Request, body: string, signingSecret: string): Promise { +async function verifySignature( + request: Request, + body: string, + signingSecret: string, +): Promise { const ts = request.headers.get("X-Slack-Request-Timestamp") || ""; const sig = request.headers.get("X-Slack-Signature") || ""; const baseString = `v0:${ts}:${body}`; const encoder = new TextEncoder(); try { - const key = await crypto.subtle.importKey("raw", encoder.encode(signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); - const sigBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(baseString)); - const computed = "v0=" + Array.from(new Uint8Array(sigBytes)).map((b) => b.toString(16).padStart(2, "0")).join(""); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(signingSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sigBytes = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(baseString), + ); + const computed = + "v0=" + + Array.from(new Uint8Array(sigBytes)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); if (computed.length !== sig.length) return false; - return crypto.subtle.timingSafeEqual(encoder.encode(computed), encoder.encode(sig)); + return crypto.subtle.timingSafeEqual( + encoder.encode(computed), + encoder.encode(sig), + ); } catch { return false; } @@ -66,14 +96,25 @@ async function slackResponse(responseUrl: string, text: string) { await fetch(responseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text, response_type: "ephemeral", replace_original: false }), + body: JSON.stringify({ + text, + response_type: "ephemeral", + replace_original: false, + }), }); } catch {} } -async function postPermalinkReply(slack: WebClient, channelId: string, messageTs: string): Promise { +async function postPermalinkReply( + slack: WebClient, + channelId: string, + messageTs: string, +): Promise { try { - const permalink = await slack.chat.getPermalink({ channel: channelId, message_ts: messageTs }); + const permalink = await slack.chat.getPermalink({ + channel: channelId, + message_ts: messageTs, + }); const url = (permalink as any)?.permalink || ""; if (!url) return; await slack.chat.postMessage({ @@ -108,7 +149,11 @@ async function fireWebhook(ch: StoreChannel, msg: StoreMessage) { } catch {} } -async function uploadToCDN(slackUrl: string, botToken: string, cdnKey: string): Promise { +async function uploadToCDN( + slackUrl: string, + botToken: string, + cdnKey: string, +): Promise { if (!cdnKey) return slackUrl; const resp = await fetch(slackUrl, { headers: { Authorization: `Bearer ${botToken}` }, @@ -157,20 +202,29 @@ function hasChannelPing(text: string): boolean { return //.test(text); } -async function shouldForward(msg: StoreMessage, store: Store): Promise { +async function shouldForward( + msg: StoreMessage, + store: Store, +): Promise { // Always forward Slack permalink cross-posts if (isSlackPermalink(msg.text)) return true; // Always forward empty messages (Slack forwarded messages often come as empty) if (isMessageEmpty(msg.text)) return true; if (isLinkOnly(msg.text)) { - console.log(`shouldForward: skipping link-only message ${msg.slackTs}: ${msg.text.substring(0, 80)}`); + console.log( + `shouldForward: skipping link-only message ${msg.slackTs}: ${msg.text.substring(0, 80)}`, + ); return false; } if (hasChannelPing(msg.text)) { const since = new Date(Date.now() - 5 * 60 * 1000); const recent = await store.getRecentMessages(msg.channelId, since); const hasSubstantive = recent.some( - (m) => m.slackTs !== msg.slackTs && !isLinkOnly(m.text) && !isMessageEmpty(m.text) && !hasChannelPing(m.text) + (m) => + m.slackTs !== msg.slackTs && + !isLinkOnly(m.text) && + !isMessageEmpty(m.text) && + !hasChannelPing(m.text), ); if (hasSubstantive) return false; } @@ -179,8 +233,14 @@ async function shouldForward(msg: StoreMessage, store: Store): Promise function isSlackPermalink(text: string): boolean { const trimmed = text.trim(); - return /^https:\/\/hackclub\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test(trimmed) || - /^https:\/\/[a-z0-9-]+\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test(trimmed); + return ( + /^https:\/\/hackclub\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test( + trimmed, + ) || + /^https:\/\/[a-z0-9-]+\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test( + trimmed, + ) + ); } // --- Feed access control --- @@ -191,7 +251,10 @@ interface FeedIdentity { channelIds?: string[]; } -async function resolveFeedIdentity(c: any, env: Env): Promise { +async function resolveFeedIdentity( + c: any, + env: Env, +): Promise { const dbUrl = getDbUrl(env); // 1. Try session from cookie @@ -206,15 +269,27 @@ async function resolveFeedIdentity(c: any, env: Env): Promise 0) { + if ( + identity.type === "apikey" && + identity.channelIds && + identity.channelIds.length > 0 + ) { return identity.channelIds.includes(ch.id); } return ch.accessPermUsers.includes(identity.slackId); @@ -241,23 +323,32 @@ async function forwardToSubscribers( ) { // Dedup: skip if we've already forwarded this message recently if (!markForwarded(msg.channelId, msg.slackTs)) { - console.log(`forwardToSubscribers: duplicate ${msg.channelId}:${msg.slackTs}, skipping`); + console.log( + `forwardToSubscribers: duplicate ${msg.channelId}:${msg.slackTs}, skipping`, + ); return; } const subs = await store.getSubscribersBySource(msg.channelId); if (subs.length === 0) { - console.log(`forwardToSubscribers: no subscribers for channel ${msg.channelId}`); + console.log( + `forwardToSubscribers: no subscribers for channel ${msg.channelId}`, + ); return; } if (!(await shouldForward(msg, store))) { - console.log(`forwardToSubscribers: shouldForward returned false for ${msg.slackTs}`); + console.log( + `forwardToSubscribers: shouldForward returned false for ${msg.slackTs}`, + ); return; } let permalink = ""; try { - const r = await slack.chat.getPermalink({ channel: msg.channelId, message_ts: msg.slackTs }); + const r = await slack.chat.getPermalink({ + channel: msg.channelId, + message_ts: msg.slackTs, + }); permalink = (r as any)?.permalink || ""; } catch {} if (!permalink) { @@ -275,8 +366,14 @@ async function forwardToSubscribers( const u = await slack.users.info({ user: msg.userId }); const user = u.user as any; const profile = user?.profile; - displayName = user?.real_name || user?.name || profile?.display_name || msg.userName || "unknown"; - avatarUrl = profile?.image_72 || profile?.image_original || profile?.image_48 || ""; + displayName = + user?.real_name || + user?.name || + profile?.display_name || + msg.userName || + "unknown"; + avatarUrl = + profile?.image_72 || profile?.image_original || profile?.image_48 || ""; } catch (err) { console.error(`Failed to fetch user info for ${msg.userId}:`, err); } @@ -285,7 +382,9 @@ async function forwardToSubscribers( for (const sub of subs) { const subCh = await store.getChannel(sub.subscriberChannelId); if (!subCh) { - console.error(`Subscriber channel ${sub.subscriberChannelId} not found in DB`); + console.error( + `Subscriber channel ${sub.subscriberChannelId} not found in DB`, + ); continue; } @@ -302,7 +401,10 @@ async function forwardToSubscribers( // Check metadata for slack_permalink (set by auto-approve) if (!forwardLink) { try { - const meta = typeof msg.metadata === "string" ? JSON.parse(msg.metadata) : msg.metadata; + const meta = + typeof msg.metadata === "string" + ? JSON.parse(msg.metadata) + : msg.metadata; if (meta?.slack_permalink) forwardLink = meta.slack_permalink; } catch {} } @@ -313,13 +415,25 @@ async function forwardToSubscribers( originalText = ""; } // If we have a Slack permalink and we're not in link mode, try to fetch the original message details - if (!sourceChannel.linkMode && forwardLink && forwardLink.includes("slack.com/archives/")) { - const permalinkMatch = forwardLink.match(/\/archives\/([A-Z0-9]+)\/p(\d+)/); + if ( + !sourceChannel.linkMode && + forwardLink && + forwardLink.includes("slack.com/archives/") + ) { + const permalinkMatch = forwardLink.match( + /\/archives\/([A-Z0-9]+)\/p(\d+)/, + ); if (permalinkMatch) { const origChannel = permalinkMatch[1]; - const origTs = permalinkMatch[2].slice(0, 10) + "." + permalinkMatch[2].slice(10); + const origTs = + permalinkMatch[2].slice(0, 10) + "." + permalinkMatch[2].slice(10); try { - const history = await slack.conversations.history({ channel: origChannel, latest: origTs, limit: 1, inclusive: true }); + const history = await slack.conversations.history({ + channel: origChannel, + latest: origTs, + limit: 1, + inclusive: true, + }); const origMsg = history.messages?.[0] as any; if (origMsg) { originalText = origMsg.text || ""; @@ -327,8 +441,16 @@ async function forwardToSubscribers( const u = await slack.users.info({ user: origMsg.user }); const user = u.user as any; const profile = user?.profile; - originalAuthorName = user?.real_name || user?.name || profile?.display_name || origMsg.user; - originalAuthorAvatar = profile?.image_72 || profile?.image_original || profile?.image_48 || ""; + originalAuthorName = + user?.real_name || + user?.name || + profile?.display_name || + origMsg.user; + originalAuthorAvatar = + profile?.image_72 || + profile?.image_original || + profile?.image_48 || + ""; } } } catch {} @@ -358,16 +480,33 @@ async function forwardToSubscribers( const contextElements = [{ type: "mrkdwn", text: footerText }]; // Suppress unfurl for direct messages and empty forwards (content shown inline), unless link mode - const suppressUnfurl = sourceChannel.linkMode ? false : (!forwardLink || (Boolean(forwardLink) && isMessageEmpty(msg.text))); - - const parsedMetadata = (msg.metadata && msg.metadata != "") ? JSON.parse(msg.metadata) : null; - const parsedSchema = sourceChannel.metadataSchema ? JSON.parse(sourceChannel.metadataSchema) : null; - const metadataSection = (parsedMetadata && parsedSchema) ? Object.keys(parsedMetadata).map((metaProp) => { - const field = parsedSchema.fields?.find((e: any) => e.action_id === metaProp); - return `*${field?.label || metaProp}:* ${parsedMetadata[metaProp]}`; - }).join("\n") + "\n" : ""; - - console.log("metadata:", parsedMetadata, "schema:", sourceChannel.metadataSchema); + const suppressUnfurl = sourceChannel.linkMode + ? false + : !forwardLink || (Boolean(forwardLink) && isMessageEmpty(msg.text)); + + const parsedMetadata = + msg.metadata && msg.metadata != "" ? JSON.parse(msg.metadata) : null; + const parsedSchema = sourceChannel.metadataSchema + ? JSON.parse(sourceChannel.metadataSchema) + : null; + const metadataSection = + parsedMetadata && parsedSchema + ? Object.keys(parsedMetadata) + .map((metaProp) => { + const field = parsedSchema.fields?.find( + (e: any) => e.action_id === metaProp, + ); + return `*${field?.label || metaProp}:* ${parsedMetadata[metaProp]}`; + }) + .join("\n") + "\n" + : ""; + + console.log( + "metadata:", + parsedMetadata, + "schema:", + sourceChannel.metadataSchema, + ); try { if (sourceChannel.linkMode) { @@ -379,55 +518,75 @@ async function forwardToSubscribers( unfurl_media: true, text: metadataSection ? metadataSection.trim() + "\n" + footerText - : (forwardLink || footerText), + : forwardLink || footerText, blocks: metadataSection ? [ - { type: "section", text: { type: "mrkdwn", text: metadataSection.trim() } }, + { + type: "section", + text: { type: "mrkdwn", text: metadataSection.trim() }, + }, { type: "section", text: { type: "mrkdwn", text: footerText } }, ] : undefined, } as any); if (res?.ts) { - await store.addBotAction({ type: "pub", sourceChannelId: msg.channelId, sourceMessageTs: msg.slackTs, botChannelId: sub.subscriberChannelId, botMessageTs: res.ts, createdAt: "" }); + await store.addBotAction({ + type: "pub", + sourceChannelId: msg.channelId, + sourceMessageTs: msg.slackTs, + botChannelId: sub.subscriberChannelId, + botMessageTs: res.ts, + createdAt: "", + }); } continue; } const blocks: any[] = [ - { - type: "section", - text: { - type: "mrkdwn", - text: (originalText - ? originalText - : forwardLink - ? `<${forwardLink}|🔗 View forwarded message>` - : msg.text || "_[no text]_" - ).slice(0, 3000), - }, + { + type: "section", + text: { + type: "mrkdwn", + text: (originalText + ? originalText + : forwardLink + ? `<${forwardLink}|🔗 View forwarded message>` + : msg.text || "_[no text]_" + ).slice(0, 3000), }, - ]; - if (metadataSection) { - blocks.push({ type: "section", text: { type: "mrkdwn", text: metadataSection.trim() } }); - } - blocks.push({ type: "context", elements: contextElements }); - const res = await slack.chat.postMessage({ - channel: sub.subscriberChannelId, - username: postUsername, - icon_url: postIcon, - unfurl_links: suppressUnfurl ? false : undefined, - unfurl_media: suppressUnfurl ? false : undefined, - text: (originalText - ? `📰 *${postUsername}* in #${sourceChannel.name}:\n${originalText}` - : forwardLink - ? `📰 Forwarded from #${sourceChannel.name}\n${forwardLink}` - : `📰 *${displayName}* in #${sourceChannel.name}:\n${msg.text}` - ).slice(0, 3000), - blocks, + }, + ]; + if (metadataSection) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: metadataSection.trim() }, }); - if (res?.ts) { - await store.addBotAction({ type: "pub", sourceChannelId: msg.channelId, sourceMessageTs: msg.slackTs, botChannelId: sub.subscriberChannelId, botMessageTs: res.ts, createdAt: "" }); - } + } + blocks.push({ type: "context", elements: contextElements }); + const res = await slack.chat.postMessage({ + channel: sub.subscriberChannelId, + username: postUsername, + icon_url: postIcon, + unfurl_links: suppressUnfurl ? false : undefined, + unfurl_media: suppressUnfurl ? false : undefined, + text: (originalText + ? `📰 *${postUsername}* in #${sourceChannel.name}:\n${originalText}` + : forwardLink + ? `📰 Forwarded from #${sourceChannel.name}\n${forwardLink}` + : `📰 *${displayName}* in #${sourceChannel.name}:\n${msg.text}` + ).slice(0, 3000), + blocks, + }); + if (res?.ts) { + await store.addBotAction({ + type: "pub", + sourceChannelId: msg.channelId, + sourceMessageTs: msg.slackTs, + botChannelId: sub.subscriberChannelId, + botMessageTs: res.ts, + createdAt: "", + }); + } } catch (err) { console.error(`Failed to forward to ${sub.subscriberChannelId}:`, err); } @@ -475,7 +634,10 @@ app.use("/api/*", async (c, next) => { const authHeader = c.req.header("Authorization") || ""; if (authHeader.startsWith("Bearer ")) { try { - apiKey = await createApiKeyContext({ req: c.req.raw, databaseUrl: dbUrl }); + apiKey = await createApiKeyContext({ + req: c.req.raw, + databaseUrl: dbUrl, + }); } catch {} } @@ -485,19 +647,24 @@ app.use("/api/*", async (c, next) => { apiKey, databaseUrl: dbUrl, slackToken: c.env.SLACK_BOT_TOKEN || "", - lockdownUsers: (c.env.LOCKDOWN_USERS || "").split(",").map((s: string) => s.trim()).filter(Boolean), + lockdownUsers: (c.env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), }; - const { matched: openMatched, response: openResponse } = await openAPIHandler.handle(c.req.raw, { - prefix: "/api", - context: ctx, - }); + const { matched: openMatched, response: openResponse } = + await openAPIHandler.handle(c.req.raw, { + prefix: "/api", + context: ctx, + }); if (openMatched) return c.newResponse(openResponse.body, openResponse); - const { matched: rpcMatched, response: rpcResponse } = await rpcHandler.handle(c.req.raw, { - context: ctx, - prefix: "/api", - }); + const { matched: rpcMatched, response: rpcResponse } = + await rpcHandler.handle(c.req.raw, { + context: ctx, + prefix: "/api", + }); if (rpcMatched) return c.newResponse(rpcResponse.body, rpcResponse); await next(); }); @@ -545,17 +712,20 @@ app.on(["POST", "GET"], "/api/auth/*", async (c) => { } }); - // GitHub stars cache let cachedStars: number | null = null; let starsFetchedAt = 0; const STARS_CACHE_TTL = 10 * 60 * 1000; // 10 minutes async function getGithubStars(): Promise { - if (cachedStars !== null && Date.now() - starsFetchedAt < STARS_CACHE_TTL) return cachedStars; + if (cachedStars !== null && Date.now() - starsFetchedAt < STARS_CACHE_TTL) + return cachedStars; try { const res = await fetch("https://api.github.com/repos/matmanna/indigest", { - headers: { "User-Agent": "indigest-bot", "Accept": "application/vnd.github.v3+json" }, + headers: { + "User-Agent": "indigest-bot", + Accept: "application/vnd.github.v3+json", + }, }); const data = (await res.json()) as any; if (typeof data.stargazers_count === "number") { @@ -571,24 +741,35 @@ async function getGithubStars(): Promise { app.get("/site-config", async (c) => { c.header("Cache-Control", "no-store"); const env = c.env; - const ids = (env.LOCKDOWN_USERS || "").split(",").map((id) => id.trim()).filter(Boolean); + const ids = (env.LOCKDOWN_USERS || "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); const [lockdownUsers, githubStars] = await Promise.all([ - ids.length === 0 ? Promise.resolve([]) : (async () => { - const slack = new WebClient(env.SLACK_BOT_TOKEN); - return Promise.all(ids.map(async (id) => { - let name = id; - try { - const response = await slack.users.info({ user: id }); - const user = response.user as any; - name = user?.profile?.display_name || user?.name || user?.real_name || id; - } catch {} - return { - id, - name, - url: `https://hackclub.enterprise.slack.com/team/${id}`, - }; - })); - })(), + ids.length === 0 + ? Promise.resolve([]) + : (async () => { + const slack = new WebClient(env.SLACK_BOT_TOKEN); + return Promise.all( + ids.map(async (id) => { + let name = id; + try { + const response = await slack.users.info({ user: id }); + const user = response.user as any; + name = + user?.profile?.display_name || + user?.name || + user?.real_name || + id; + } catch {} + return { + id, + name, + url: `https://hackclub.enterprise.slack.com/team/${id}`, + }; + }), + ); + })(), getGithubStars(), ]); @@ -611,7 +792,8 @@ app.get("/spec.json", (c) => { get: { operationId: "getFeed", summary: "Get channel feed", - description: "Returns an RSS feed or JSON feed of messages for a channel. Requires auth if accessPermUsers is not *.", + description: + "Returns an RSS feed or JSON feed of messages for a channel. Requires auth if accessPermUsers is not *.", tags: ["Feed"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ @@ -641,7 +823,8 @@ app.get("/spec.json", (c) => { ], responses: { "200": { - description: "RSS feed (application/rss+xml) or JSON array depending on Accept header or .json suffix", + description: + "RSS feed (application/rss+xml) or JSON array depending on Accept header or .json suffix", }, "403": { description: "Forbidden — no access to this feed", @@ -653,7 +836,8 @@ app.get("/spec.json", (c) => { get: { operationId: "listMessages", summary: "List messages", - description: "List messages for a channel with filtering and pagination. Requires auth if accessPermUsers is not *.", + description: + "List messages for a channel with filtering and pagination. Requires auth if accessPermUsers is not *.", tags: ["Messages"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ @@ -724,7 +908,8 @@ app.get("/spec.json", (c) => { get: { operationId: "getMessage", summary: "Get message", - description: "Get a single message by its Slack timestamp. Requires auth if accessPermUsers is not *.", + description: + "Get a single message by its Slack timestamp. Requires auth if accessPermUsers is not *.", tags: ["Messages"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ @@ -764,7 +949,8 @@ app.get("/spec.json", (c) => { get: { operationId: "listChannels", summary: "List channels", - description: "List all channels known to indigest (enabled and disabled).", + description: + "List all channels known to indigest (enabled and disabled).", tags: ["Channels"], security: [{ basicAuth: [] }], responses: { @@ -827,7 +1013,8 @@ app.get("/spec.json", (c) => { get: { operationId: "listChannelsByUser", summary: "List channels by user", - description: "List channels the user has access to based on accessPermUsers.", + description: + "List channels the user has access to based on accessPermUsers.", tags: ["Channels"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ @@ -858,7 +1045,8 @@ app.get("/spec.json", (c) => { get: { operationId: "listSubscriptions", summary: "List subscriptions", - description: "List all subscriber channels for a given source channel.", + description: + "List all subscriber channels for a given source channel.", tags: ["Subscriptions"], parameters: [ { @@ -897,8 +1085,14 @@ app.get("/spec.json", (c) => { type: "object", required: ["subscriberChannelId", "sourceChannelId"], properties: { - subscriberChannelId: { type: "string", description: "Channel to receive forwards" }, - sourceChannelId: { type: "string", description: "Channel to forward from" }, + subscriberChannelId: { + type: "string", + description: "Channel to receive forwards", + }, + sourceChannelId: { + type: "string", + description: "Channel to forward from", + }, }, }, }, @@ -939,7 +1133,8 @@ app.get("/spec.json", (c) => { get: { operationId: "listSubscriptionsBySubscriber", summary: "List subscriptions by subscriber", - description: "List all source channels a subscriber channel is subscribed to.", + description: + "List all source channels a subscriber channel is subscribed to.", tags: ["Subscriptions"], parameters: [ { @@ -1044,206 +1239,279 @@ app.post("/events", async (c) => { return c.text(payload.challenge, 200, { "Content-Type": "text/plain" }); } - if (!await verifySignature(c.req.raw, body, env.SLACK_SIGNING_SECRET)) { + if (!(await verifySignature(c.req.raw, body, env.SLACK_SIGNING_SECRET))) { return c.json({ error: "invalid signature" }, 401); } // Respond immediately to prevent Slack retries - c.res = new Response("ok", { status: 200, headers: { "Content-Type": "text/plain" } }); + c.res = new Response("ok", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); - c.executionCtx.waitUntil((async () => { - console.log("Received Slack event:", payload.event?.type, "thread_ts:", payload.event?.thread_ts, "channel:", payload.event?.channel); - const ev = payload.event; - const store = await getStore(getDbUrl(env)); - const slack = new WebClient(env.SLACK_BOT_TOKEN); - - if (ev.type === "member_joined_channel") { - const auth = await slack.auth.test(); - if (ev.user !== auth.user_id) return; - const ch: StoreChannel = { - id: ev.channel, - name: "", - teamId: auth.team_id || "", - enabled: false, - linkMode: false, - webhookUrl: "", - autoApproveUsers: [], - approvedPosters: [], - accessPermUsers: ["*"], - trackReplies: false, - metadataSchema: "", - createdAt: "", - }; - await store.upsertChannel(ch); - try { - const conv = await slack.conversations.info({ channel: ev.channel }); - ch.name = (conv.channel as any)?.name || ""; + c.executionCtx.waitUntil( + (async () => { + console.log( + "Received Slack event:", + payload.event?.type, + "thread_ts:", + payload.event?.thread_ts, + "channel:", + payload.event?.channel, + ); + const ev = payload.event; + const store = await getStore(getDbUrl(env)); + const slack = new WebClient(env.SLACK_BOT_TOKEN); + + if (ev.type === "member_joined_channel") { + const auth = await slack.auth.test(); + if (ev.user !== auth.user_id) return; + const ch: StoreChannel = { + id: ev.channel, + name: "", + teamId: auth.team_id || "", + enabled: false, + linkMode: false, + webhookUrl: "", + autoApproveUsers: [], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }; await store.upsertChannel(ch); - } catch {} - } + try { + const conv = await slack.conversations.info({ channel: ev.channel }); + ch.name = (conv.channel as any)?.name || ""; + await store.upsertChannel(ch); + } catch {} + } + + if (ev.type === "message" && !ev.subtype && !ev.thread_ts) { + const ch = await store.getChannel(ev.channel); + if (!ch || !ch.enabled) return; + + // Log full event for debugging empty forward messages + if (!ev.text && !ev.subtype) { + console.log( + "Empty message event:", + JSON.stringify({ + text: ev.text, + subtype: ev.subtype, + attachments: ev.attachments, + files: ev.files, + x_files: ev.x_files, + user: ev.user, + }), + ); + } - if (ev.type === "message" && !ev.subtype && !ev.thread_ts) { - const ch = await store.getChannel(ev.channel); - if (!ch || !ch.enabled) return; + if (ch.autoApproveUsers.length > 0) { + console.log(`Auto-approving message from ${ev.user} in #${ch.name}`); + if ( + !ch.autoApproveUsers.includes("*") && + !ch.autoApproveUsers.includes(ev.user) + ) { + return; + } + let userName = ev.user; + try { + const u = await slack.users.info({ user: ev.user }); + userName = (u.user as any)?.name || ev.user; + } catch {} + // Extract the original message permalink from the event + // Check text first, then attachments (Slack forwards), then fallback + let msgPermalink = ""; + if (isSlackPermalink(ev.text)) { + msgPermalink = ev.text.trim(); + } else if (ev.attachments && ev.attachments.length > 0) { + const att = ev.attachments[0]; + msgPermalink = + att.from_url || att.original_url || att.permalink || ""; + if ( + !msgPermalink && + att.text && + att.text.includes("slack.com/archives/") + ) { + const match = att.text.match( + /https:\/\/\S+?slack\.com\/archives\/[A-Z0-9]+\/p\d+/, + ); + if (match) msgPermalink = match[0]; + } + } + const metadata = msgPermalink + ? { slack_permalink: msgPermalink } + : {}; + + await store.upsertMessage({ + slackTs: ev.ts, + channelId: ev.channel, + userId: ev.user, + userName, + text: ev.text, + timestamp: slackTsToTime(ev.ts).toISOString(), + metadata, + }); + await fireWebhook(ch, { + slackTs: ev.ts, + channelId: ev.channel, + userId: ev.user, + userName, + text: ev.text, + timestamp: slackTsToTime(ev.ts).toISOString(), + metadata, + }); + await forwardToSubscribers( + { + slackTs: ev.ts, + channelId: ev.channel, + userId: ev.user, + userName, + text: ev.text, + timestamp: slackTsToTime(ev.ts).toISOString(), + metadata, + }, + ch, + store, + slack, + ); + // if (ch.linkMode) await postPermalinkReply(slack, ev.channel, ev.ts); - // Log full event for debugging empty forward messages - if (!ev.text && !ev.subtype) { - console.log("Empty message event:", JSON.stringify({ text: ev.text, subtype: ev.subtype, attachments: ev.attachments, files: ev.files, x_files: ev.x_files, user: ev.user })); + // If channel has a metadata schema, prompt the user to add metadata + if (ch.metadataSchema) { + let schema: any = null; + try { + schema = JSON.parse(ch.metadataSchema); + } catch {} + if (schema && schema.fields?.length > 0) { + try { + await slack.chat.postMessage({ + channel: ev.channel, + thread_ts: ev.ts, + text: "Your message has been posted to the feed!", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "📰 Your message has been posted to the feed! Would you like to add some extra data?", + }, + accessory: { + type: "button", + action_id: "indigest_metadata", + text: { type: "plain_text", text: "Add Metadata" }, + value: ev.ts, + style: "primary" as const, + }, + }, + ], + }); + } catch {} + } + } + } else { + // Non-auto-approve: prompt for manual approval + const section = { + type: "section", + text: { + type: "mrkdwn", + text: `Expose this message to indigest via RSS and API?\n>${ev.text}\n`, + }, + accessory: { + type: "button", + action_id: "indigest_yes", + text: { type: "plain_text", text: "Yep!" }, + value: ev.ts, + }, + }; + const actions = { + type: "actions", + elements: [ + { + type: "button", + action_id: "indigest_no", + text: { type: "plain_text", text: "No thanks" }, + style: "danger" as const, + value: ev.ts, + }, + ], + }; + try { + await slack.chat.postMessage({ + channel: ev.channel, + thread_ts: ev.ts, + blocks: [section, actions], + }); + } catch {} + } } - if (ch.autoApproveUsers.length > 0) { - console.log(`Auto-approving message from ${ev.user} in #${ch.name}`); - if (!ch.autoApproveUsers.includes("*") && !ch.autoApproveUsers.includes(ev.user)) { - return; + // Thread reply tracking (separate from the top-level message handler above) + if (ev.type === "message" && !ev.subtype && !ev.bot_id && ev.thread_ts) { + console.log( + `Thread reply: channel=${ev.channel} thread_ts=${ev.thread_ts} ts=${ev.ts}`, + ); + const ch = await store.getChannel(ev.channel); + console.log( + `Channel lookup: ch=${!!ch} enabled=${ch?.enabled} trackReplies=${ch?.trackReplies}`, + ); + if (!ch || !ch.enabled || !ch.trackReplies) return; + + // Store the parent message if not already stored + const existingParent = ( + await store.getMessages(ev.channel, 200, 0) + ).find((m) => m.slackTs === ev.thread_ts); + if (!existingParent) { + try { + const parentHistory = await slack.conversations.history({ + channel: ev.channel, + latest: ev.thread_ts!, + limit: 1, + inclusive: true, + }); + const parentMsg = parentHistory.messages?.[0] as any; + if (parentMsg && !parentMsg.subtype && !parentMsg.bot_id) { + let parentUserName = parentMsg.user || ""; + try { + const u = await slack.users.info({ user: parentMsg.user }); + parentUserName = (u.user as any)?.name || parentMsg.user; + } catch {} + await store.upsertMessage({ + slackTs: ev.thread_ts!, + channelId: ev.channel, + userId: parentMsg.user || "", + userName: parentUserName, + text: parentMsg.text || "", + timestamp: slackTsToTime(ev.thread_ts!).toISOString(), + metadata: {}, + }); + } + } catch {} } + + // Store the reply let userName = ev.user; try { const u = await slack.users.info({ user: ev.user }); userName = (u.user as any)?.name || ev.user; } catch {} - // Extract the original message permalink from the event - // Check text first, then attachments (Slack forwards), then fallback - let msgPermalink = ""; - if (isSlackPermalink(ev.text)) { - msgPermalink = ev.text.trim(); - } else if (ev.attachments && ev.attachments.length > 0) { - const att = ev.attachments[0]; - msgPermalink = att.from_url || att.original_url || att.permalink || ""; - if (!msgPermalink && att.text && att.text.includes("slack.com/archives/")) { - const match = att.text.match(/https:\/\/\S+?slack\.com\/archives\/[A-Z0-9]+\/p\d+/); - if (match) msgPermalink = match[0]; - } - } - const metadata = msgPermalink ? { slack_permalink: msgPermalink } : {}; - await store.upsertMessage({ slackTs: ev.ts, channelId: ev.channel, + threadTs: ev.thread_ts, userId: ev.user, userName, text: ev.text, timestamp: slackTsToTime(ev.ts).toISOString(), - metadata, - }); - await fireWebhook(ch, { - slackTs: ev.ts, - channelId: ev.channel, - userId: ev.user, - userName, - text: ev.text, - timestamp: slackTsToTime(ev.ts).toISOString(), - metadata, + metadata: {}, }); - await forwardToSubscribers({ slackTs: ev.ts, channelId: ev.channel, userId: ev.user, userName, text: ev.text, timestamp: slackTsToTime(ev.ts).toISOString(), metadata }, ch, store, slack); - // if (ch.linkMode) await postPermalinkReply(slack, ev.channel, ev.ts); - - // If channel has a metadata schema, prompt the user to add metadata - if (ch.metadataSchema) { - let schema: any = null; - try { schema = JSON.parse(ch.metadataSchema); } catch {} - if (schema && schema.fields?.length > 0) { - try { - await slack.chat.postMessage({ - channel: ev.channel, - thread_ts: ev.ts, - text: "Your message has been posted to the feed!", - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "📰 Your message has been posted to the feed! Would you like to add some extra data?" }, - accessory: { - type: "button", - action_id: "indigest_metadata", - text: { type: "plain_text", text: "Add Metadata" }, - value: ev.ts, - style: "primary" as const, - }, - }, - ], - }); - } catch {} - } - } - - } else { - // Non-auto-approve: prompt for manual approval - const section = { - type: "section", - text: { type: "mrkdwn", text: `Expose this message to indigest via RSS and API?\n>${ev.text}\n` }, - accessory: { - type: "button", - action_id: "indigest_yes", - text: { type: "plain_text", text: "Yep!" }, - value: ev.ts, - }, - }; - const actions = { - type: "actions", - elements: [ - { - type: "button", - action_id: "indigest_no", - text: { type: "plain_text", text: "No thanks" }, - style: "danger" as const, - value: ev.ts, - }, - ], - }; - try { - await slack.chat.postMessage({ - channel: ev.channel, - thread_ts: ev.ts, - blocks: [section, actions], - }); - } catch {} - } - } - - // Thread reply tracking (separate from the top-level message handler above) - if (ev.type === "message" && !ev.subtype && !ev.bot_id && ev.thread_ts) { - console.log(`Thread reply: channel=${ev.channel} thread_ts=${ev.thread_ts} ts=${ev.ts}`); - const ch = await store.getChannel(ev.channel); - console.log(`Channel lookup: ch=${!!ch} enabled=${ch?.enabled} trackReplies=${ch?.trackReplies}`); - if (!ch || !ch.enabled || !ch.trackReplies) return; - - // Store the parent message if not already stored - const existingParent = (await store.getMessages(ev.channel, 200, 0)).find((m) => m.slackTs === ev.thread_ts); - if (!existingParent) { - try { - const parentHistory = await slack.conversations.history({ channel: ev.channel, latest: ev.thread_ts!, limit: 1, inclusive: true }); - const parentMsg = parentHistory.messages?.[0] as any; - if (parentMsg && !parentMsg.subtype && !parentMsg.bot_id) { - let parentUserName = parentMsg.user || ""; - try { const u = await slack.users.info({ user: parentMsg.user }); parentUserName = (u.user as any)?.name || parentMsg.user; } catch {} - await store.upsertMessage({ - slackTs: ev.thread_ts!, - channelId: ev.channel, - userId: parentMsg.user || "", - userName: parentUserName, - text: parentMsg.text || "", - timestamp: slackTsToTime(ev.thread_ts!).toISOString(), - metadata: {}, - }); - } - } catch {} + console.log( + `Thread reply stored: ts=${ev.ts} threadTs=${ev.thread_ts}`, + ); } - - // Store the reply - let userName = ev.user; - try { const u = await slack.users.info({ user: ev.user }); userName = (u.user as any)?.name || ev.user; } catch {} - await store.upsertMessage({ - slackTs: ev.ts, - channelId: ev.channel, - threadTs: ev.thread_ts, - userId: ev.user, - userName, - text: ev.text, - timestamp: slackTsToTime(ev.ts).toISOString(), - metadata: {}, - }); - console.log(`Thread reply stored: ts=${ev.ts} threadTs=${ev.thread_ts}`); - } - })()); + })(), + ); }); // === Interactive Components === @@ -1269,32 +1537,96 @@ app.post("/interactions", async (c) => { const ch = await store.getChannel(channelId); if (!ch || !ch.enabled || !ch.metadataSchema) { - return c.json({ response_action: "update", view: { type: "modal", title: { type: "plain_text", text: "Error" }, blocks: [{ type: "section", text: { type: "mrkdwn", text: "No metadata schema configured for this channel." } }] } }); + return c.json({ + response_action: "update", + view: { + type: "modal", + title: { type: "plain_text", text: "Error" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "No metadata schema configured for this channel.", + }, + }, + ], + }, + }); } let schema: any = null; - try { schema = JSON.parse(ch.metadataSchema); } catch {} + try { + schema = JSON.parse(ch.metadataSchema); + } catch {} if (!schema || schema.fields?.length === 0) { - return c.json({ response_action: "update", view: { type: "modal", title: { type: "plain_text", text: "Error" }, blocks: [{ type: "section", text: { type: "mrkdwn", text: "No metadata schema configured for this channel." } }] } }); + return c.json({ + response_action: "update", + view: { + type: "modal", + title: { type: "plain_text", text: "Error" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "No metadata schema configured for this channel.", + }, + }, + ], + }, + }); } const { openMetadataModal } = await import("./api/modal"); try { - await openMetadataModal(slack, triggerId, channelId, messageTs, schema, cb.container?.message_ts); + await openMetadataModal( + slack, + triggerId, + channelId, + messageTs, + schema, + cb.container?.message_ts, + ); } catch (err: any) { - return c.json({ response_action: "update", view: { type: "modal", title: { type: "plain_text", text: "Error" }, blocks: [{ type: "section", text: { type: "mrkdwn", text: `Error opening form: ${err.message}` } }] } }); + return c.json({ + response_action: "update", + view: { + type: "modal", + title: { type: "plain_text", text: "Error" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Error opening form: ${err.message}`, + }, + }, + ], + }, + }); } // Permission check after modal opens - c.executionCtx.waitUntil((async () => { - const clickingUser = cb.user?.id || ""; - const lockdownUsers = (env.LOCKDOWN_USERS || "").split(",").map((s: string) => s.trim()).filter(Boolean); - const isManager = lockdownUsers.includes(clickingUser) || await isChannelManager(channelId, clickingUser, slack); - if (isManager) return; - const msgs = await store.getMessages(channelId, 100, 0); - const originalMsg = msgs.find((m) => m.slackTs === messageTs); - if (originalMsg?.userId !== clickingUser) { - await slackResponse(responseUrl, "Only the original author or channel managers can edit metadata."); - } - })()); + c.executionCtx.waitUntil( + (async () => { + const clickingUser = cb.user?.id || ""; + const lockdownUsers = (env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + const isManager = + lockdownUsers.includes(clickingUser) || + (await isChannelManager(channelId, clickingUser, slack)); + if (isManager) return; + const msgs = await store.getMessages(channelId, 100, 0); + const originalMsg = msgs.find((m) => m.slackTs === messageTs); + if (originalMsg?.userId !== clickingUser) { + await slackResponse( + responseUrl, + "Only the original author or channel managers can edit metadata.", + ); + } + })(), + ); return c.json({}); } } @@ -1305,395 +1637,586 @@ app.post("/interactions", async (c) => { headers: { "Content-Type": "application/json" }, }); - c.executionCtx.waitUntil((async () => { - const store = await getStore(getDbUrl(env)); - const slack = new WebClient(env.SLACK_BOT_TOKEN); - - if (cb.type === "view_submission" && cb.view?.callback_id === "metadata_modal") { - const privateMeta = JSON.parse(cb.view.private_metadata || "{}"); - const { channelId, messageTs, botMessageTs } = privateMeta; - const channel = await store.getChannel(channelId); - if (!channel || !channel.enabled) return; - - let schema: any = null; - try { schema = JSON.parse(channel.metadataSchema); } catch {} - - const metadata = schema ? await (async () => { - const m: Record = {}; - for (const field of schema.fields || []) { - const values = cb.view.state?.values?.[`field_${field.action_id}`]?.[field.action_id]; - if (!values) continue; - if (field.type === "multi_static_select") m[field.action_id] = values.selected_options?.map((o: any) => o.value) || []; - else if (field.type === "datepicker") m[field.action_id] = values.selected_date || ""; - else if (field.type === "file_input") { - const files = values.files || []; - m[field.action_id] = await Promise.all( - files.map(async (f: any) => { - if (!f.url_private) return { ...f, cdn_url: null }; - const cdnUrl = await uploadToCDN(f.url_private, env.SLACK_BOT_TOKEN, env.HACK_CLUB_CDN_KEY || ""); - return { ...f, cdn_url: cdnUrl }; - }), - ); - } - else m[field.action_id] = values.value || ""; - } - return JSON.stringify(m); - })() : ""; + c.executionCtx.waitUntil( + (async () => { + const store = await getStore(getDbUrl(env)); + const slack = new WebClient(env.SLACK_BOT_TOKEN); - const client = new WebClient(env.SLACK_BOT_TOKEN); - try { - const history = await client.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true }); - const msg = history.messages?.[0] as any; - if (!msg) return; - - let userName = msg.user || ""; - try { const u = await client.users.info({ user: msg.user }); userName = (u.user as any)?.name || userName; } catch {} - - await store.upsertMessage({ - slackTs: messageTs, - channelId, - userId: msg.user || "", - userName, - text: msg.text || "", - timestamp: slackTsToTime(messageTs).toISOString(), - metadata, - }); + if ( + cb.type === "view_submission" && + cb.view?.callback_id === "metadata_modal" + ) { + const privateMeta = JSON.parse(cb.view.private_metadata || "{}"); + const { channelId, messageTs, botMessageTs } = privateMeta; + const channel = await store.getChannel(channelId); + if (!channel || !channel.enabled) return; - await fireWebhook(channel, { - slackTs: messageTs, - channelId, - userId: msg.user || "", - userName, - text: msg.text || "", - timestamp: slackTsToTime(messageTs).toISOString(), - metadata, - }); + let schema: any = null; + try { + schema = JSON.parse(channel.metadataSchema); + } catch {} + + const metadata = schema + ? await (async () => { + const m: Record = {}; + for (const field of schema.fields || []) { + const values = + cb.view.state?.values?.[`field_${field.action_id}`]?.[ + field.action_id + ]; + if (!values) continue; + if (field.type === "multi_static_select") + m[field.action_id] = + values.selected_options?.map((o: any) => o.value) || []; + else if (field.type === "datepicker") + m[field.action_id] = values.selected_date || ""; + else if (field.type === "file_input") { + const files = values.files || []; + m[field.action_id] = await Promise.all( + files.map(async (f: any) => { + if (!f.url_private) return { ...f, cdn_url: null }; + const cdnUrl = await uploadToCDN( + f.url_private, + env.SLACK_BOT_TOKEN, + env.HACK_CLUB_CDN_KEY || "", + ); + return { ...f, cdn_url: cdnUrl }; + }), + ); + } else m[field.action_id] = values.value || ""; + } + return JSON.stringify(m); + })() + : ""; + + const client = new WebClient(env.SLACK_BOT_TOKEN); + try { + const history = await client.conversations.history({ + channel: channelId, + latest: messageTs, + limit: 1, + inclusive: true, + }); + const msg = history.messages?.[0] as any; + if (!msg) return; - // Build metadata section for editing subscriber messages - const parsedMetadata = metadata ? JSON.parse(metadata) : null; - const parsedSchema = channel.metadataSchema ? JSON.parse(channel.metadataSchema) : null; - const metadataSection = (parsedMetadata && parsedSchema) ? Object.keys(parsedMetadata).map((metaProp) => { - const field = parsedSchema.fields?.find((e: any) => e.action_id === metaProp); - return `*${field?.label || metaProp}:* ${parsedMetadata[metaProp]}`; - }).join("\n") : ""; - - // Find existing bot actions for this source message and edit them with metadata - const botActions = await store.getBotActionsBySource(channelId, messageTs); - for (const action of botActions) { - if (action.type === "pub" && metadataSection) { + let userName = msg.user || ""; try { - // Fetch the current bot message to get its blocks - const botMsg = await client.conversations.history({ channel: action.botChannelId, latest: action.botMessageTs, limit: 1, inclusive: true }); - const existingMsg = botMsg.messages?.[0] as any; - if (existingMsg?.blocks) { - // Insert metadata section before the context block (last block) - const blocks = [...existingMsg.blocks]; - const contextIdx = blocks.findIndex((b: any) => b.type === "context"); - const metaBlock = { type: "section", text: { type: "mrkdwn", text: metadataSection } }; - if (contextIdx >= 0) { - blocks.splice(contextIdx, 0, metaBlock); - } else { - blocks.push(metaBlock); + const u = await client.users.info({ user: msg.user }); + userName = (u.user as any)?.name || userName; + } catch {} + + await store.upsertMessage({ + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata, + }); + + await fireWebhook(channel, { + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata, + }); + + // Build metadata section for editing subscriber messages + const parsedMetadata = metadata ? JSON.parse(metadata) : null; + const parsedSchema = channel.metadataSchema + ? JSON.parse(channel.metadataSchema) + : null; + const metadataSection = + parsedMetadata && parsedSchema + ? Object.keys(parsedMetadata) + .map((metaProp) => { + const field = parsedSchema.fields?.find( + (e: any) => e.action_id === metaProp, + ); + return `*${field?.label || metaProp}:* ${parsedMetadata[metaProp]}`; + }) + .join("\n") + : ""; + + // Find existing bot actions for this source message and edit them with metadata + const botActions = await store.getBotActionsBySource( + channelId, + messageTs, + ); + for (const action of botActions) { + if (action.type === "pub" && metadataSection) { + try { + // Fetch the current bot message to get its blocks + const botMsg = await client.conversations.history({ + channel: action.botChannelId, + latest: action.botMessageTs, + limit: 1, + inclusive: true, + }); + const existingMsg = botMsg.messages?.[0] as any; + if (existingMsg?.blocks) { + // Insert metadata section before the context block (last block) + const blocks = [...existingMsg.blocks]; + const contextIdx = blocks.findIndex( + (b: any) => b.type === "context", + ); + const metaBlock = { + type: "section", + text: { type: "mrkdwn", text: metadataSection }, + }; + if (contextIdx >= 0) { + blocks.splice(contextIdx, 0, metaBlock); + } else { + blocks.push(metaBlock); + } + await client.chat.update({ + channel: action.botChannelId, + ts: action.botMessageTs, + text: existingMsg.text || "", + blocks, + } as any); + } + } catch (err: any) { + console.error( + `Failed to update bot message ${action.botChannelId}:${action.botMessageTs}:`, + err.message, + ); } + } + } + + // Also forward to any new subscribers that weren't captured in the initial forward + await forwardToSubscribers( + { + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata, + }, + channel, + store, + slack, + ); + // if (channel.linkMode) await postPermalinkReply(client, channelId, messageTs); + + // Update the bot's prompt to show approved state + if (botMessageTs) { + try { await client.chat.update({ - channel: action.botChannelId, - ts: action.botMessageTs, - text: existingMsg.text || "", - blocks, + channel: channelId, + ts: botMessageTs, + text: "✅ Message approved and added to the feed!", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "✅ *Message approved and added to the feed!*", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "indigest_no", + text: { type: "plain_text", text: "Undo" }, + style: "danger" as const, + value: messageTs, + }, + ], + }, + ], } as any); + } catch (err: any) { + console.error( + "chat.update (view_submission) failed:", + err.message, + ); } - } catch (err: any) { - console.error(`Failed to update bot message ${action.botChannelId}:${action.botMessageTs}:`, err.message); } + } catch (err: any) { + console.error("view_submission error:", err.message); } + return; } - // Also forward to any new subscribers that weren't captured in the initial forward - await forwardToSubscribers({ slackTs: messageTs, channelId, userId: msg.user || "", userName, text: msg.text || "", timestamp: slackTsToTime(messageTs).toISOString(), metadata }, channel, store, slack); - // if (channel.linkMode) await postPermalinkReply(client, channelId, messageTs); + // Backfill shortcut: manually pub a message + if (cb.type === "message_action") { + const channelId = cb.channel?.id; + const messageTs = cb.message?.ts; + const triggerId = cb.trigger_id; + const clickingUser = cb.user?.id || ""; - // Update the bot's prompt to show approved state - if (botMessageTs) { - try { - await client.chat.update({ - channel: channelId, - ts: botMessageTs, - text: "✅ Message approved and added to the feed!", - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "✅ *Message approved and added to the feed!*" }, - }, - { - type: "actions", - elements: [ - { - type: "button", - action_id: "indigest_no", - text: { type: "plain_text", text: "Undo" }, - style: "danger" as const, - value: messageTs, - }, - ], - }, - ], - } as any); - } catch (err: any) { console.error("chat.update (view_submission) failed:", err.message); } - } - } catch (err: any) { console.error("view_submission error:", err.message); } - return; - } - - // Backfill shortcut: manually pub a message - if (cb.type === "message_action") { - const channelId = cb.channel?.id; - const messageTs = cb.message?.ts; - const triggerId = cb.trigger_id; - const clickingUser = cb.user?.id || ""; - - if (!channelId || !messageTs) { - await slackResponse(cb.response_url || "", "Could not identify the message to backfill."); - return c.json({}); - } - - c.executionCtx.waitUntil((async () => { - const store = await getStore(getDbUrl(env)); - const slack = new WebClient(env.SLACK_BOT_TOKEN); - - const lockdownUsers = (env.LOCKDOWN_USERS || "").split(",").map((s: string) => s.trim()).filter(Boolean); - const isLockdown = lockdownUsers.includes(clickingUser); - const isManager = isLockdown || await isChannelManager(channelId, clickingUser, slack); - - if (!isManager) { - const ch = await store.getChannel(channelId); - if (!ch || !ch.enabled) { - await slack.chat.postMessage({ channel: channelId, text: "❌ This channel isn't enabled for indigest. Run `/in pub` first." }); - return; + if (!channelId || !messageTs) { + await slackResponse( + cb.response_url || "", + "Could not identify the message to backfill.", + ); + return c.json({}); } - } - try { - const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true }); - const msg = history.messages?.[0] as any; - if (!msg || msg.subtype || msg.bot_id) { - await slack.chat.postMessage({ channel: channelId, text: "❌ That message can't be added to the feed (bot message or system message)." }); - return; - } + c.executionCtx.waitUntil( + (async () => { + const store = await getStore(getDbUrl(env)); + const slack = new WebClient(env.SLACK_BOT_TOKEN); + + const lockdownUsers = (env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + const isLockdown = lockdownUsers.includes(clickingUser); + const isManager = + isLockdown || + (await isChannelManager(channelId, clickingUser, slack)); + + if (!isManager) { + const ch = await store.getChannel(channelId); + if (!ch || !ch.enabled) { + await slack.chat.postMessage({ + channel: channelId, + text: "❌ This channel isn't enabled for indigest. Run `/in pub` first.", + }); + return; + } + } - let userName = msg.user || ""; - try { const u = await slack.users.info({ user: msg.user }); userName = (u.user as any)?.name || msg.user; } catch {} - - let msgPermalink = ""; - if (isSlackPermalink(msg.text)) { - msgPermalink = msg.text.trim(); - } else if (msg.attachments && msg.attachments.length > 0) { - const att = msg.attachments[0]; - msgPermalink = att.from_url || att.original_url || att.permalink || ""; - if (!msgPermalink && att.text && att.text.includes("slack.com/archives/")) { - const match = att.text.match(/https:\/\/\S+?slack\.com\/archives\/[A-Z0-9]+\/p\d+/); - if (match) msgPermalink = match[0]; - } - } - const metadata = msgPermalink ? { slack_permalink: msgPermalink } : {}; + try { + const history = await slack.conversations.history({ + channel: channelId, + latest: messageTs, + limit: 1, + inclusive: true, + }); + const msg = history.messages?.[0] as any; + if (!msg || msg.subtype || msg.bot_id) { + await slack.chat.postMessage({ + channel: channelId, + text: "❌ That message can't be added to the feed (bot message or system message).", + }); + return; + } - const ch = await store.getChannel(channelId); - await store.upsertMessage({ - slackTs: messageTs, - channelId, - userId: msg.user || "", - userName, - text: msg.text || "", - timestamp: slackTsToTime(messageTs).toISOString(), - metadata, - }); + let userName = msg.user || ""; + try { + const u = await slack.users.info({ user: msg.user }); + userName = (u.user as any)?.name || msg.user; + } catch {} + + let msgPermalink = ""; + if (isSlackPermalink(msg.text)) { + msgPermalink = msg.text.trim(); + } else if (msg.attachments && msg.attachments.length > 0) { + const att = msg.attachments[0]; + msgPermalink = + att.from_url || att.original_url || att.permalink || ""; + if ( + !msgPermalink && + att.text && + att.text.includes("slack.com/archives/") + ) { + const match = att.text.match( + /https:\/\/\S+?slack\.com\/archives\/[A-Z0-9]+\/p\d+/, + ); + if (match) msgPermalink = match[0]; + } + } + const metadata = msgPermalink + ? { slack_permalink: msgPermalink } + : {}; + + const ch = await store.getChannel(channelId); + await store.upsertMessage({ + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata, + }); - const savedMsg = { slackTs: messageTs, channelId, userId: msg.user || "", userName, text: msg.text || "", timestamp: slackTsToTime(messageTs).toISOString(), metadata }; - if (ch) await fireWebhook(ch, savedMsg); - if (ch) await forwardToSubscribers(savedMsg, ch, store, slack); - } catch (err: any) { - console.error("backfill shortcut error:", err.message); - await slack.chat.postMessage({ channel: channelId, text: `❌ Error backfilling message: ${err.message}` }); + const savedMsg = { + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata, + }; + if (ch) await fireWebhook(ch, savedMsg); + if (ch) await forwardToSubscribers(savedMsg, ch, store, slack); + } catch (err: any) { + console.error("backfill shortcut error:", err.message); + await slack.chat.postMessage({ + channel: channelId, + text: `❌ Error backfilling message: ${err.message}`, + }); + } + })(), + ); + return c.json({}); } - })()); - return c.json({}); - } - if (cb.type !== "block_actions") return; + if (cb.type !== "block_actions") return; - const action = cb.actions?.[0]; - if (!action) return; + const action = cb.actions?.[0]; + if (!action) return; - const channelId = cb.channel?.id; - const messageTs = action.value; - const responseUrl = cb.response_url; - const triggerId = cb.trigger_id; - const botMessageTs = cb.container?.message_ts || ""; - - const ch = await store.getChannel(channelId); - if (!ch || !ch.enabled) { - await slackResponse(responseUrl, "This channel is not enabled."); - return; - } + const channelId = cb.channel?.id; + const messageTs = action.value; + const responseUrl = cb.response_url; + const triggerId = cb.trigger_id; + const botMessageTs = cb.container?.message_ts || ""; - if (action.action_id === "indigest_yes") { - const clickingUser = cb.user?.id || ""; + const ch = await store.getChannel(channelId); + if (!ch || !ch.enabled) { + await slackResponse(responseUrl, "This channel is not enabled."); + return; + } - // Open modal IMMEDIATELY — trigger_id expires in 3s, can't afford slow checks first - if (ch.metadataSchema) { - let schema: any = null; - try { schema = JSON.parse(ch.metadataSchema); } catch {} - if (schema && schema.fields?.length > 0) { - const { openMetadataModal } = await import("./api/modal"); - try { - await openMetadataModal(slack, triggerId, channelId, messageTs, schema, cb.container?.message_ts); - } catch (err: any) { - console.error("openMetadataModal failed:", err.message); - if (err.message?.includes("expired_trigger_id")) { - await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "⏱️ Form expired — please click Approve again." }); - } else { - await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: `❌ Error opening form: ${err.message}` }); - } - return; - } + if (action.action_id === "indigest_yes") { + const clickingUser = cb.user?.id || ""; - // Permission check AFTER modal is open - const lockdownUsers = (env.LOCKDOWN_USERS || "").split(",").map((s: string) => s.trim()).filter(Boolean); - const isLockdown = lockdownUsers.includes(clickingUser); - const isManager = isLockdown || await isChannelManager(channelId, clickingUser, slack); - if (!isManager) { - const msgs = await store.getMessages(channelId, 100, 0); - const originalMsg = msgs.find((m) => m.slackTs === messageTs); - if (originalMsg) { - let allowed = true; - if (ch.approvedPosters.length === 0) { - allowed = originalMsg.userId === clickingUser; - } else if (ch.approvedPosters.includes("poster")) { - allowed = originalMsg.userId === clickingUser || ch.approvedPosters.includes(clickingUser); - } else { - allowed = ch.approvedPosters.includes(clickingUser); - } - if (!allowed) { - await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ You don't have permission to approve this message." }); + // Open modal IMMEDIATELY — trigger_id expires in 3s, can't afford slow checks first + if (ch.metadataSchema) { + let schema: any = null; + try { + schema = JSON.parse(ch.metadataSchema); + } catch {} + if (schema && schema.fields?.length > 0) { + const { openMetadataModal } = await import("./api/modal"); + try { + await openMetadataModal( + slack, + triggerId, + channelId, + messageTs, + schema, + cb.container?.message_ts, + ); + } catch (err: any) { + console.error("openMetadataModal failed:", err.message); + if (err.message?.includes("expired_trigger_id")) { + await slack.chat.postEphemeral({ + channel: channelId, + user: clickingUser, + text: "⏱️ Form expired — please click Approve again.", + }); + } else { + await slack.chat.postEphemeral({ + channel: channelId, + user: clickingUser, + text: `❌ Error opening form: ${err.message}`, + }); + } return; } + + // Permission check AFTER modal is open + const lockdownUsers = (env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + const isLockdown = lockdownUsers.includes(clickingUser); + const isManager = + isLockdown || + (await isChannelManager(channelId, clickingUser, slack)); + if (!isManager) { + const msgs = await store.getMessages(channelId, 100, 0); + const originalMsg = msgs.find((m) => m.slackTs === messageTs); + if (originalMsg) { + let allowed = true; + if (ch.approvedPosters.length === 0) { + allowed = originalMsg.userId === clickingUser; + } else if (ch.approvedPosters.includes("poster")) { + allowed = + originalMsg.userId === clickingUser || + ch.approvedPosters.includes(clickingUser); + } else { + allowed = ch.approvedPosters.includes(clickingUser); + } + if (!allowed) { + await slack.chat.postEphemeral({ + channel: channelId, + user: clickingUser, + text: "❌ You don't have permission to approve this message.", + }); + return; + } + } + } + return; } } - return; - } - } - - // No metadata schema — do permission checks first, then approve directly - const lockdownUsers = (env.LOCKDOWN_USERS || "").split(",").map((s: string) => s.trim()).filter(Boolean); - const isLockdown = lockdownUsers.includes(clickingUser); - const isManager = isLockdown || await isChannelManager(channelId, clickingUser, slack); - try { - const history = await slack.conversations.history({ - channel: channelId, - latest: messageTs, - limit: 1, - inclusive: true, - }); - const msg = history.messages?.[0] as any; - if (!msg) { - await slackResponse(responseUrl, "Couldn't fetch that message."); - return; - } - - let userName = msg.user || ""; - try { const u = await slack.users.info({ user: msg.user }); userName = (u.user as any)?.name || userName; } catch {} - - await store.upsertMessage({ - slackTs: messageTs, - channelId, - userId: msg.user || "", - userName, - text: msg.text || "", - timestamp: slackTsToTime(messageTs).toISOString(), - metadata: {}, - }); - - const savedMsg = { slackTs: messageTs, channelId, userId: msg.user || "", userName, text: msg.text || "", timestamp: slackTsToTime(messageTs).toISOString(), metadata: {} }; - await fireWebhook(ch, savedMsg); - await forwardToSubscribers(savedMsg, ch, store, slack); - // if (ch.linkMode) await postPermalinkReply(slack, channelId, messageTs); + // No metadata schema — do permission checks first, then approve directly + const lockdownUsers = (env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + const isLockdown = lockdownUsers.includes(clickingUser); + const isManager = + isLockdown || + (await isChannelManager(channelId, clickingUser, slack)); - // Update the bot's prompt to show approved state - if (botMessageTs) { try { - await slack.chat.update({ + const history = await slack.conversations.history({ channel: channelId, - ts: botMessageTs, - text: "✅ Message approved and added to the feed!", - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "✅ *Message approved and added to the feed!*" }, - }, - { - type: "actions", - elements: [ + latest: messageTs, + limit: 1, + inclusive: true, + }); + const msg = history.messages?.[0] as any; + if (!msg) { + await slackResponse(responseUrl, "Couldn't fetch that message."); + return; + } + + let userName = msg.user || ""; + try { + const u = await slack.users.info({ user: msg.user }); + userName = (u.user as any)?.name || userName; + } catch {} + + await store.upsertMessage({ + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata: {}, + }); + + const savedMsg = { + slackTs: messageTs, + channelId, + userId: msg.user || "", + userName, + text: msg.text || "", + timestamp: slackTsToTime(messageTs).toISOString(), + metadata: {}, + }; + await fireWebhook(ch, savedMsg); + await forwardToSubscribers(savedMsg, ch, store, slack); + // if (ch.linkMode) await postPermalinkReply(slack, channelId, messageTs); + + // Update the bot's prompt to show approved state + if (botMessageTs) { + try { + await slack.chat.update({ + channel: channelId, + ts: botMessageTs, + text: "✅ Message approved and added to the feed!", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "✅ *Message approved and added to the feed!*", + }, + }, { - type: "button", - action_id: "indigest_no", - text: { type: "plain_text", text: "Undo" }, - style: "danger" as const, - value: messageTs, + type: "actions", + elements: [ + { + type: "button", + action_id: "indigest_no", + text: { type: "plain_text", text: "Undo" }, + style: "danger" as const, + value: messageTs, + }, + ], }, ], - }, - ], - } as any); - } catch (err: any) { console.error("chat.update (yes) failed:", err.message, "botMessageTs:", botMessageTs, "channel:", channelId); } - } else { - console.warn("indigest_yes: botMessageTs is empty, skipping chat.update. container:", JSON.stringify(cb.container)); - } + } as any); + } catch (err: any) { + console.error( + "chat.update (yes) failed:", + err.message, + "botMessageTs:", + botMessageTs, + "channel:", + channelId, + ); + } + } else { + console.warn( + "indigest_yes: botMessageTs is empty, skipping chat.update. container:", + JSON.stringify(cb.container), + ); + } - await slackResponse(responseUrl, ""); - } catch (err: any) { - await slackResponse(responseUrl, `Error: ${err.message}`); - } - } else if (action.action_id === "indigest_no") { - // Remove the message from the DB if it was previously approved - try { - await store.deleteMessage(channelId, messageTs); - } catch (err: any) { console.error("deleteMessage failed:", err.message); } + await slackResponse(responseUrl, ""); + } catch (err: any) { + await slackResponse(responseUrl, `Error: ${err.message}`); + } + } else if (action.action_id === "indigest_no") { + // Remove the message from the DB if it was previously approved + try { + await store.deleteMessage(channelId, messageTs); + } catch (err: any) { + console.error("deleteMessage failed:", err.message); + } - // Update the bot's prompt to show declined state - if (botMessageTs) { - try { - await slack.chat.update({ - channel: channelId, - ts: botMessageTs, - text: "❌ Message declined.", - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "❌ *Message declined.*" }, - }, - { - type: "actions", - elements: [ + // Update the bot's prompt to show declined state + if (botMessageTs) { + try { + await slack.chat.update({ + channel: channelId, + ts: botMessageTs, + text: "❌ Message declined.", + blocks: [ { - type: "button", - action_id: "indigest_yes", - text: { type: "plain_text", text: "Approve instead" }, - value: messageTs, + type: "section", + text: { type: "mrkdwn", text: "❌ *Message declined.*" }, + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "indigest_yes", + text: { type: "plain_text", text: "Approve instead" }, + value: messageTs, + }, + ], }, ], - }, - ], - } as any); - } catch (err: any) { console.error("chat.update (no) failed:", err.message, "botMessageTs:", botMessageTs, "channel:", channelId); } - } else { - console.warn("indigest_no: botMessageTs is empty, skipping chat.update. container:", JSON.stringify(cb.container)); - } + } as any); + } catch (err: any) { + console.error( + "chat.update (no) failed:", + err.message, + "botMessageTs:", + botMessageTs, + "channel:", + channelId, + ); + } + } else { + console.warn( + "indigest_no: botMessageTs is empty, skipping chat.update. container:", + JSON.stringify(cb.container), + ); + } - await slackResponse(responseUrl, ""); - } + await slackResponse(responseUrl, ""); + } - return; - })()); + return; + })(), + ); }); // === Slash Commands === @@ -1715,537 +2238,714 @@ const slackSlashCommand = async (c: any) => { c.header("Content-Type", "application/json"); c.header("X-Slack-Response-Accepted", "1"); - c.res = new Response(JSON.stringify({ response_type: "ephemeral", text: "⏳ Processing..." }), { status: 200, headers: c.res.headers }); - - c.executionCtx.waitUntil((async () => { - const store = await getStore(getDbUrl(env)); - const slack = new WebClient(env.SLACK_BOT_TOKEN); - const baseUrl = env.BASE_URL || "http://localhost:8787"; - const lockdownUsers = (env.LOCKDOWN_USERS || "").split(",").map((s: string) => s.trim()).filter(Boolean); - - const channelRefMatch = text.match(/^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*(.*)$/); - let targetChannelId: string; - let rest: string; - - if (channelRefMatch) { - const idFromRef = channelRefMatch[2]; - const nameFromRef = channelRefMatch[4]; - rest = channelRefMatch[5]!.trim(); - - if (idFromRef) { - targetChannelId = idFromRef; - } else { - try { - const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000 }); - const channels = list.channels as any[] || []; - const found = channels.find((c: any) => c.name === nameFromRef); - if (!found) { - await respond(`Channel #${nameFromRef} not found.`); - return; - } - targetChannelId = found.id; - } catch { - await respond("Couldn't look up channels."); - return; - } - } - } else { - targetChannelId = sourceChannelId; - rest = text; - } - - let ch = await store.getChannel(targetChannelId); - if (!ch) { - let name = targetChannelId; - try { - const conv = await slack.conversations.info({ channel: targetChannelId }); - name = (conv.channel as any)?.name || targetChannelId; - } catch {} - const auth = await slack.auth.test(); - ch = { id: targetChannelId, name, teamId: auth.team_id || "", enabled: false, linkMode: false, webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }; - await store.upsertChannel(ch); - } - - const parts = rest.split(/\s+/); - const cmd = parts[0]; - const subcmd = parts[1]; - const arg = parts.slice(2).join(" "); -console.log(`Slash command: user=${userId} channel=${targetChannelId} cmd=${cmd} subcmd=${subcmd} arg=${arg}`); - const targetManager = () => lockdownUsers.includes(userId) || isChannelManager(targetChannelId, userId, slack); - - if (lockdownUsers.length > 0 && !lockdownUsers.includes(userId) && cmd !== "status" && cmd !== "") { - await respond("🔒 indigest is in lockdown mode. Only authorized users can run commands."); - return; - } - - switch (cmd) { - case "pub": { - if (subcmd === "auto") { - const target = arg.replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim(); - - ch.enabled = true; - - if (!target) { - ch.autoApproveUsers = ["*"]; - await store.upsertChannel(ch); - await respond(`📰 Auto-approve enabled for all users in #${ch.name}.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`); - return; - } + c.res = new Response( + JSON.stringify({ response_type: "ephemeral", text: "⏳ Processing..." }), + { status: 200, headers: c.res.headers }, + ); - if (target !== userId) { + c.executionCtx.waitUntil( + (async () => { + const store = await getStore(getDbUrl(env)); + const slack = new WebClient(env.SLACK_BOT_TOKEN); + const baseUrl = env.BASE_URL || "http://localhost:8787"; + const lockdownUsers = (env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + + const channelRefMatch = text.match( + /^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*(.*)$/, + ); + let targetChannelId: string; + let rest: string; + + if (channelRefMatch) { + const idFromRef = channelRefMatch[2]; + const nameFromRef = channelRefMatch[4]; + rest = channelRefMatch[5]!.trim(); + + if (idFromRef) { + targetChannelId = idFromRef; + } else { try { - const u = await slack.users.info({ user: target }); - const isBot = (u.user as any)?.is_bot; - if (!isBot) { - if (!(await targetManager())) { - await respond("Only the channel creator can enable auto-approve for another non-bot user."); - return; - } + const list = await slack.conversations.list({ + types: "public_channel,private_channel", + limit: 1000, + }); + const channels = (list.channels as any[]) || []; + const found = channels.find((c: any) => c.name === nameFromRef); + if (!found) { + await respond(`Channel #${nameFromRef} not found.`); + return; } + targetChannelId = found.id; } catch { - await respond("Couldn't look up that user."); + await respond("Couldn't look up channels."); return; } } - - if (ch.autoApproveUsers.includes(target)) { - await respond(`<@${target}> already has auto-approve.`); - return; - } - ch.autoApproveUsers.push(target); - await store.upsertChannel(ch); - await respond(`📰 Auto-approve enabled for <@${target}> in #${ch.name}.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`); - return; + } else { + targetChannelId = sourceChannelId; + rest = text; } - if (subcmd === "link") { - if (!(await targetManager())) { - await respond("Only the channel creator can enable link mode."); - return; - } - ch.enabled = true; - const wantsOff = ["off", "disable", "disabled", "0", "false"].includes((arg || "").trim().toLowerCase()); - ch.linkMode = !wantsOff; + let ch = await store.getChannel(targetChannelId); + if (!ch) { + let name = targetChannelId; + try { + const conv = await slack.conversations.info({ + channel: targetChannelId, + }); + name = (conv.channel as any)?.name || targetChannelId; + } catch {} + const auth = await slack.auth.test(); + ch = { + id: targetChannelId, + name, + teamId: auth.team_id || "", + enabled: false, + linkMode: false, + webhookUrl: "", + autoApproveUsers: [], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }; await store.upsertChannel(ch); - const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; - if (ch.linkMode) { - await respond(`🔗 Link mode enabled for ${label}. Approved messages will get an unfurlable permalink reply.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`); - } else { - await respond(`🔗 Link mode disabled for ${label}.`); - } - return; } - if (subcmd === "manual") { - if (!(await targetManager())) { - await respond("Only the channel creator can enable manual mode."); - return; - } - ch.enabled = true; - ch.autoApproveUsers = []; - await store.upsertChannel(ch); - const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; - await respond(`📰 Manual mode enabled for ${label}. Every message will get a Yep!/No prompt.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`); + const parts = rest.split(/\s+/); + const cmd = parts[0]; + const subcmd = parts[1]; + const arg = parts.slice(2).join(" "); + console.log( + `Slash command: user=${userId} channel=${targetChannelId} cmd=${cmd} subcmd=${subcmd} arg=${arg}`, + ); + const targetManager = () => + lockdownUsers.includes(userId) || + isChannelManager(targetChannelId, userId, slack); + + if ( + lockdownUsers.length > 0 && + !lockdownUsers.includes(userId) && + cmd !== "status" && + cmd !== "" + ) { + await respond( + "🔒 indigest is in lockdown mode. Only authorized users can run commands.", + ); return; } - if (subcmd === "replies") { - if (!(await targetManager())) { - await respond("Only the channel creator can toggle reply tracking."); - return; - } - ch.trackReplies = !ch.trackReplies; - ch.enabled = true; - await store.upsertChannel(ch); - const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; - if (ch.trackReplies) { - await respond(`📰 Reply tracking enabled for ${label}. Thread replies will now be backed up with their reply relationship.`); - } else { - await respond(`📰 Reply tracking disabled for ${label}.`); - } - return; - } + switch (cmd) { + case "pub": { + if (subcmd === "auto") { + const target = arg.replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim(); - if (subcmd) { - await respond("Usage: \`/in pub\` | \`/in pub #channel\` | \`/in pub auto @user\` | \`/in pub manual\` | \`/in pub link\` | \`/in pub replies\`"); - return; - } - if (!(await targetManager())) { - await respond("Only the channel creator can pub indigest."); - return; - } - ch.enabled = true; - ch.autoApproveUsers = []; - await store.upsertChannel(ch); - const label = targetChannelId === sourceChannelId ? "" : ` in #${ch.name}`; - await respond(`📰 indigest pub'd${label}. New messages will get a prompt to add to the feed.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`); - return; - } + ch.enabled = true; - case "unpub": { - if (subcmd === "auto") { - if (!ch.enabled) { - await respond(`indigest is not pub'd in that channel.`); - return; - } - const target = arg.replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim(); - if (!target) { - ch.autoApproveUsers = []; - await store.upsertChannel(ch); - await respond(`Auto-approve disabled for all users in #${ch.name}.`); - return; - } - ch.autoApproveUsers = ch.autoApproveUsers.filter((id) => id !== target); - await store.upsertChannel(ch); - await respond(`Auto-approve disabled for <@${target}> in #${ch.name}.`); - return; - } + if (!target) { + ch.autoApproveUsers = ["*"]; + await store.upsertChannel(ch); + await respond( + `📰 Auto-approve enabled for all users in #${ch.name}.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, + ); + return; + } - if (subcmd) { - await respond("Usage: \`/in unpub\` | \`/in unpub #channel\` | \`/in unpub auto [@user]\`"); - return; - } - if (!(await targetManager())) { - await respond("Only the channel creator can unpub indigest."); - return; - } - ch.enabled = false; - await store.upsertChannel(ch); - const label = targetChannelId === sourceChannelId ? "" : ` in #${ch.name}`; - await respond(`indigest unpub'd${label}.`); - return; - } + if (target !== userId) { + try { + const u = await slack.users.info({ user: target }); + const isBot = (u.user as any)?.is_bot; + if (!isBot) { + if (!(await targetManager())) { + await respond( + "Only the channel creator can enable auto-approve for another non-bot user.", + ); + return; + } + } + } catch { + await respond("Couldn't look up that user."); + return; + } + } - case "sub": { - if (!(await targetManager())) { - await respond("Only the channel creator or workspace admin can subscribe channels."); - return; - } + if (ch.autoApproveUsers.includes(target)) { + await respond(`<@${target}> already has auto-approve.`); + return; + } + ch.autoApproveUsers.push(target); + await store.upsertChannel(ch); + await respond( + `📰 Auto-approve enabled for <@${target}> in #${ch.name}.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, + ); + return; + } - const subArg = [subcmd, arg].filter(Boolean).join(" "); - if (!subArg) { - await respond("Usage: \`/indigest sub #channel\` — subscribe this channel to receive messages from #channel."); - return; - } + if (subcmd === "link") { + if (!(await targetManager())) { + await respond("Only the channel creator can enable link mode."); + return; + } + ch.enabled = true; + const wantsOff = [ + "off", + "disable", + "disabled", + "0", + "false", + ].includes((arg || "").trim().toLowerCase()); + ch.linkMode = !wantsOff; + await store.upsertChannel(ch); + const label = + targetChannelId === sourceChannelId + ? "this channel" + : `#${ch.name}`; + if (ch.linkMode) { + await respond( + `🔗 Link mode enabled for ${label}. Approved messages will get an unfurlable permalink reply.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, + ); + } else { + await respond(`🔗 Link mode disabled for ${label}.`); + } + return; + } - const subChannelMatch = subArg.match(/^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*$/); - if (!subChannelMatch) { - await respond("Usage: \`/indigest sub #channel\` — subscribe this channel to receive messages from #channel."); - return; - } + if (subcmd === "manual") { + if (!(await targetManager())) { + await respond("Only the channel creator can enable manual mode."); + return; + } + ch.enabled = true; + ch.autoApproveUsers = []; + await store.upsertChannel(ch); + const label = + targetChannelId === sourceChannelId + ? "this channel" + : `#${ch.name}`; + await respond( + `📰 Manual mode enabled for ${label}. Every message will get a Yep!/No prompt.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, + ); + return; + } - const subIdFromRef = subChannelMatch[2]; - const subNameFromRef = subChannelMatch[4]; - let sourceId: string; - let sourceName: string; + if (subcmd === "replies") { + if (!(await targetManager())) { + await respond( + "Only the channel creator can toggle reply tracking.", + ); + return; + } + ch.trackReplies = !ch.trackReplies; + ch.enabled = true; + await store.upsertChannel(ch); + const label = + targetChannelId === sourceChannelId + ? "this channel" + : `#${ch.name}`; + if (ch.trackReplies) { + await respond( + `📰 Reply tracking enabled for ${label}. Thread replies will now be backed up with their reply relationship.`, + ); + } else { + await respond(`📰 Reply tracking disabled for ${label}.`); + } + return; + } - if (subIdFromRef) { - sourceId = subIdFromRef; - } else { - try { - const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000 }); - const channels = list.channels as any[] || []; - const found = channels.find((c: any) => c.name === subNameFromRef); - if (!found) { - await respond(`Channel #${subNameFromRef} not found.`); + if (subcmd) { + await respond( + "Usage: \`/in pub\` | \`/in pub #channel\` | \`/in pub auto @user\` | \`/in pub manual\` | \`/in pub link\` | \`/in pub replies\`", + ); + return; + } + if (!(await targetManager())) { + await respond("Only the channel creator can pub indigest."); return; } - sourceId = found.id; - } catch { - await respond("Couldn't look up channels."); + ch.enabled = true; + ch.autoApproveUsers = []; + await store.upsertChannel(ch); + const label = + targetChannelId === sourceChannelId ? "" : ` in #${ch.name}`; + await respond( + `📰 indigest pub'd${label}. New messages will get a prompt to add to the feed.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, + ); return; } - } - const sourceCh = await store.getChannel(sourceId); - sourceName = sourceCh?.name || subNameFromRef || sourceId; + case "unpub": { + if (subcmd === "auto") { + if (!ch.enabled) { + await respond(`indigest is not pub'd in that channel.`); + return; + } + const target = arg.replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim(); + if (!target) { + ch.autoApproveUsers = []; + await store.upsertChannel(ch); + await respond( + `Auto-approve disabled for all users in #${ch.name}.`, + ); + return; + } + ch.autoApproveUsers = ch.autoApproveUsers.filter( + (id) => id !== target, + ); + await store.upsertChannel(ch); + await respond( + `Auto-approve disabled for <@${target}> in #${ch.name}.`, + ); + return; + } - if (sourceId === sourceChannelId) { - await respond("Can't subscribe a channel to itself."); - return; - } + if (subcmd) { + await respond( + "Usage: \`/in unpub\` | \`/in unpub #channel\` | \`/in unpub auto [@user]\`", + ); + return; + } + if (!(await targetManager())) { + await respond("Only the channel creator can unpub indigest."); + return; + } + ch.enabled = false; + await store.upsertChannel(ch); + const label = + targetChannelId === sourceChannelId ? "" : ` in #${ch.name}`; + await respond(`indigest unpub'd${label}.`); + return; + } - if (!sourceCh || !sourceCh.enabled) { - await respond(`#${sourceName} is not pub'd yet. It must be pub'd before other channels can subscribe to it.`); - return; - } + case "sub": { + if (!(await targetManager())) { + await respond( + "Only the channel creator or workspace admin can subscribe channels.", + ); + return; + } - const existingSubs = await store.getSubscriptionsBySubscriber(sourceChannelId); - if (existingSubs.some((s) => s.sourceChannelId === sourceId)) { - await respond(`Already subscribed to #${sourceName}.`); - return; - } + const subArg = [subcmd, arg].filter(Boolean).join(" "); + if (!subArg) { + await respond( + "Usage: \`/indigest sub #channel\` — subscribe this channel to receive messages from #channel.", + ); + return; + } - await store.addSubscription(sourceChannelId, sourceId); - await respond(`📰 Subscribed to #${sourceName}. Messages pub'd there will be forwarded here.`); - return; - } + const subChannelMatch = subArg.match( + /^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*$/, + ); + if (!subChannelMatch) { + await respond( + "Usage: \`/indigest sub #channel\` — subscribe this channel to receive messages from #channel.", + ); + return; + } - case "unsub": { - if (!(await targetManager())) { - await respond("Only the channel creator or workspace admin can unsubscribe channels."); - return; - } + const subIdFromRef = subChannelMatch[2]; + const subNameFromRef = subChannelMatch[4]; + let sourceId: string; + let sourceName: string; - const unsubArg = [subcmd, arg].filter(Boolean).join(" "); - if (!unsubArg) { - await respond("Usage: \`/indigest unsub #channel\` — unsubscribe this channel from #channel."); - return; - } + if (subIdFromRef) { + sourceId = subIdFromRef; + } else { + try { + const list = await slack.conversations.list({ + types: "public_channel,private_channel", + limit: 1000, + }); + const channels = (list.channels as any[]) || []; + const found = channels.find( + (c: any) => c.name === subNameFromRef, + ); + if (!found) { + await respond(`Channel #${subNameFromRef} not found.`); + return; + } + sourceId = found.id; + } catch { + await respond("Couldn't look up channels."); + return; + } + } - const unsubChannelMatch = unsubArg.match(/^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*$/); - if (!unsubChannelMatch) { - await respond("Usage: \`/indigest unsub #channel\` — unsubscribe this channel from #channel."); - return; - } + const sourceCh = await store.getChannel(sourceId); + sourceName = sourceCh?.name || subNameFromRef || sourceId; - const unsubIdFromRef = unsubChannelMatch[2]; - const unsubNameFromRef = unsubChannelMatch[4]; - let sourceId: string; - let sourceName: string; + if (sourceId === sourceChannelId) { + await respond("Can't subscribe a channel to itself."); + return; + } - if (unsubIdFromRef) { - sourceId = unsubIdFromRef; - } else { - try { - const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000 }); - const channels = list.channels as any[] || []; - const found = channels.find((c: any) => c.name === unsubNameFromRef); - if (!found) { - await respond(`Channel #${unsubNameFromRef} not found.`); + if (!sourceCh || !sourceCh.enabled) { + await respond( + `#${sourceName} is not pub'd yet. It must be pub'd before other channels can subscribe to it.`, + ); return; } - sourceId = found.id; - } catch { - await respond("Couldn't look up channels."); + + const existingSubs = + await store.getSubscriptionsBySubscriber(sourceChannelId); + if (existingSubs.some((s) => s.sourceChannelId === sourceId)) { + await respond(`Already subscribed to #${sourceName}.`); + return; + } + + await store.addSubscription(sourceChannelId, sourceId); + await respond( + `📰 Subscribed to #${sourceName}. Messages pub'd there will be forwarded here.`, + ); return; } - } - const sourceCh = await store.getChannel(sourceId); - sourceName = sourceCh?.name || unsubNameFromRef || sourceId; - - const existingSubs = await store.getSubscriptionsBySubscriber(sourceChannelId); - if (!existingSubs.some((s) => s.sourceChannelId === sourceId)) { - await respond(`Not subscribed to #${sourceName}.`); - return; - } + case "unsub": { + if (!(await targetManager())) { + await respond( + "Only the channel creator or workspace admin can unsubscribe channels.", + ); + return; + } - await store.removeSubscription(sourceChannelId, sourceId); - await respond(`📰 Unsubscribed from #${sourceName}.`); - return; - } + const unsubArg = [subcmd, arg].filter(Boolean).join(" "); + if (!unsubArg) { + await respond( + "Usage: \`/indigest unsub #channel\` — unsubscribe this channel from #channel.", + ); + return; + } - case "status": { - const chName = targetChannelId === sourceChannelId ? "" : `#${ch.name} `; + const unsubChannelMatch = unsubArg.match( + /^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*$/, + ); + if (!unsubChannelMatch) { + await respond( + "Usage: \`/indigest unsub #channel\` — unsubscribe this channel from #channel.", + ); + return; + } - const perms: string[] = []; - try { - const conv = await slack.conversations.info({ channel: targetChannelId }); - const creator = (conv.channel as any)?.creator; - if (creator === userId) perms.push("channel creator"); - } catch {} - if (lockdownUsers.includes(userId)) perms.push("lockdown override"); - const permStr = perms.length > 0 ? `\nYour perms: ${perms.join(", ")}` : "\nYour perms: none (can view feeds only)"; + const unsubIdFromRef = unsubChannelMatch[2]; + const unsubNameFromRef = unsubChannelMatch[4]; + let sourceId: string; + let sourceName: string; - if (ch.enabled) { - let msg = `📰 indigest pub'd for ${chName}\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`; - if (ch.linkMode) msg += `\nMode: link`; - if (ch.webhookUrl) msg += `\nWebhook: ${ch.webhookUrl}`; - if (ch.autoApproveUsers.length > 0) { - const autoLabel = ch.autoApproveUsers.includes("*") ? "all users" : ch.autoApproveUsers.map((id) => `<@${id}>`).join(", "); - msg += `\nAuto-approve: ${autoLabel}`; - } - if (ch.metadataSchema) { - try { - const s = JSON.parse(ch.metadataSchema); - msg += `\nMetadata schema: ${s.fields?.length || 0} field(s)`; - } catch (err: any) { console.error("view_submission error:", err.message); } - } - if (ch.approvedPosters.length > 0) { - if (ch.approvedPosters.includes("poster")) { - const extraUsers = ch.approvedPosters.filter((id) => id !== "poster"); - const extraLabel = extraUsers.length > 0 ? ` + ${extraUsers.map((id) => `<@${id}>`).join(", ")}` : ""; - msg += `\nApprovals: poster mode${extraLabel}`; + if (unsubIdFromRef) { + sourceId = unsubIdFromRef; } else { - msg += `\nApprovals: ${ch.approvedPosters.map((id) => `<@${id}>`).join(", ")}`; + try { + const list = await slack.conversations.list({ + types: "public_channel,private_channel", + limit: 1000, + }); + const channels = (list.channels as any[]) || []; + const found = channels.find( + (c: any) => c.name === unsubNameFromRef, + ); + if (!found) { + await respond(`Channel #${unsubNameFromRef} not found.`); + return; + } + sourceId = found.id; + } catch { + await respond("Couldn't look up channels."); + return; + } } - } else { - msg += `\nApprovals: poster + managers (default)`; - } - msg += `\nThread replies: ${ch.trackReplies ? "tracked" : "off"}`; - const subs = await store.getSubscriptionsBySubscriber(targetChannelId); - if (subs.length > 0) { - const subNames = await Promise.all(subs.map(async (s) => { - const sch = await store.getChannel(s.sourceChannelId); - return sch ? `#${sch.name}` : s.sourceChannelId; - })); - msg += `\nSubscribed to: ${subNames.join(", ")}`; - } - const subscribers = await store.getSubscribersBySource(targetChannelId); - if (subscribers.length > 0) { - const subNames = await Promise.all(subscribers.map(async (s) => { - const sch = await store.getChannel(s.subscriberChannelId); - return sch ? `#${sch.name}` : s.subscriberChannelId; - })); - msg += `\nSubscribers: ${subNames.join(", ")}`; - } - msg += permStr; - await respond(msg); - return; - } - await respond(`indigest is not pub'd for ${chName}Run \`/in pub\` to start.${permStr}`); - return; - } - default: { - if (!cmd) { - await respond("Commands:\n• \`pub [#channel]\` — enable indigest\n• \`unpub [#channel]\` — disable indigest\n• \`pub auto [@user]\` — auto-approve mode\n• \`pub manual\` — manual approve mode\n• \`pub replies\` — track thread replies\n• \`sub #channel\` — subscribe to another channel's feed\n• \`unsub #channel\` — unsubscribe from a channel\n• \`perms @user\` — set who can approve messages\n• \`status [#channel]\` — show status\n• \`webhook \` — set webhook\n• \`schema set \` | \`schema get\` | \`schema clear\`"); - return; - } + const sourceCh = await store.getChannel(sourceId); + sourceName = sourceCh?.name || unsubNameFromRef || sourceId; - if (cmd === "webhook") { - if (targetChannelId !== sourceChannelId) { - await respond("Webhook commands must be run from the target channel."); - return; - } - if (!(await targetManager())) { - await respond("Only the channel creator can configure webhooks."); - return; - } - if (subcmd === "clear") { - ch.webhookUrl = ""; - await store.upsertChannel(ch); - await respond("Webhook cleared."); - return; - } - const url = parts.slice(1).join(" "); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - await respond("Invalid URL. Must start with http:// or https://"); - return; - } - ch.webhookUrl = url; - await store.upsertChannel(ch); - await respond(`📰 Webhook set to ${url}`); - return; - } + const existingSubs = + await store.getSubscriptionsBySubscriber(sourceChannelId); + if (!existingSubs.some((s) => s.sourceChannelId === sourceId)) { + await respond(`Not subscribed to #${sourceName}.`); + return; + } - if (cmd === "auto" && subcmd === "list") { - if (ch.autoApproveUsers.length === 0) { - await respond(`No users have auto-approve in #${ch.name}.`); - return; - } - if (ch.autoApproveUsers.includes("*")) { - await respond(`Auto-approve in #${ch.name}: all users`); + await store.removeSubscription(sourceChannelId, sourceId); + await respond(`📰 Unsubscribed from #${sourceName}.`); return; } - const users = ch.autoApproveUsers.map((id) => `<@${id}>`).join(", "); - await respond(`Auto-approve in #${ch.name}: ${users}`); - return; - } - if (cmd === "schema") { - if (subcmd === "get") { - if (!ch.metadataSchema) { - await respond("No metadata schema configured for this channel."); - return; - } + case "status": { + const chName = + targetChannelId === sourceChannelId ? "" : `#${ch.name} `; + + const perms: string[] = []; try { - const pretty = JSON.stringify(JSON.parse(ch.metadataSchema), null, 2); - await respond(`\`\`\`${pretty}\`\`\``); - } catch { - await respond(`Raw schema:\n${ch.metadataSchema}`); + const conv = await slack.conversations.info({ + channel: targetChannelId, + }); + const creator = (conv.channel as any)?.creator; + if (creator === userId) perms.push("channel creator"); + } catch {} + if (lockdownUsers.includes(userId)) perms.push("lockdown override"); + const permStr = + perms.length > 0 + ? `\nYour perms: ${perms.join(", ")}` + : "\nYour perms: none (can view feeds only)"; + + if (ch.enabled) { + let msg = `📰 indigest pub'd for ${chName}\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`; + if (ch.linkMode) msg += `\nMode: link`; + if (ch.webhookUrl) msg += `\nWebhook: ${ch.webhookUrl}`; + if (ch.autoApproveUsers.length > 0) { + const autoLabel = ch.autoApproveUsers.includes("*") + ? "all users" + : ch.autoApproveUsers.map((id) => `<@${id}>`).join(", "); + msg += `\nAuto-approve: ${autoLabel}`; + } + if (ch.metadataSchema) { + try { + const s = JSON.parse(ch.metadataSchema); + msg += `\nMetadata schema: ${s.fields?.length || 0} field(s)`; + } catch (err: any) { + console.error("view_submission error:", err.message); + } + } + if (ch.approvedPosters.length > 0) { + if (ch.approvedPosters.includes("poster")) { + const extraUsers = ch.approvedPosters.filter( + (id) => id !== "poster", + ); + const extraLabel = + extraUsers.length > 0 + ? ` + ${extraUsers.map((id) => `<@${id}>`).join(", ")}` + : ""; + msg += `\nApprovals: poster mode${extraLabel}`; + } else { + msg += `\nApprovals: ${ch.approvedPosters.map((id) => `<@${id}>`).join(", ")}`; + } + } else { + msg += `\nApprovals: poster + managers (default)`; + } + msg += `\nThread replies: ${ch.trackReplies ? "tracked" : "off"}`; + const subs = + await store.getSubscriptionsBySubscriber(targetChannelId); + if (subs.length > 0) { + const subNames = await Promise.all( + subs.map(async (s) => { + const sch = await store.getChannel(s.sourceChannelId); + return sch ? `#${sch.name}` : s.sourceChannelId; + }), + ); + msg += `\nSubscribed to: ${subNames.join(", ")}`; + } + const subscribers = + await store.getSubscribersBySource(targetChannelId); + if (subscribers.length > 0) { + const subNames = await Promise.all( + subscribers.map(async (s) => { + const sch = await store.getChannel(s.subscriberChannelId); + return sch ? `#${sch.name}` : s.subscriberChannelId; + }), + ); + msg += `\nSubscribers: ${subNames.join(", ")}`; + } + msg += permStr; + await respond(msg); + return; } + await respond( + `indigest is not pub'd for ${chName}Run \`/in pub\` to start.${permStr}`, + ); return; } - if (subcmd === "clear") { - ch.metadataSchema = ""; - await store.upsertChannel(ch); - await respond("Metadata schema cleared."); - return; - } - - if (subcmd === "set") { - if (!arg) { - await respond("Usage: \`/in schema set \` — provide a valid metadata schema JSON."); + default: { + if (!cmd) { + await respond( + "Commands:\n• \`pub [#channel]\` — enable indigest\n• \`unpub [#channel]\` — disable indigest\n• \`pub auto [@user]\` — auto-approve mode\n• \`pub manual\` — manual approve mode\n• \`pub replies\` — track thread replies\n• \`sub #channel\` — subscribe to another channel's feed\n• \`unsub #channel\` — unsubscribe from a channel\n• \`perms @user\` — set who can approve messages\n• \`status [#channel]\` — show status\n• \`webhook \` — set webhook\n• \`schema set \` | \`schema get\` | \`schema clear\`", + ); return; } - try { - const parsed = JSON.parse(arg); - if (!parsed.fields || !Array.isArray(parsed.fields)) { - await respond("Schema must have a \`fields\` array. Example: \`{\"title\": \"Metadata\", \"fields\": [{\"action_id\": \"title\", \"label\": \"Title\", \"type\": \"plain_text_input\"}]}\`"); + + if (cmd === "webhook") { + if (targetChannelId !== sourceChannelId) { + await respond( + "Webhook commands must be run from the target channel.", + ); + return; + } + if (!(await targetManager())) { + await respond("Only the channel creator can configure webhooks."); return; } - ch.metadataSchema = JSON.stringify(parsed); + if (subcmd === "clear") { + ch.webhookUrl = ""; + await store.upsertChannel(ch); + await respond("Webhook cleared."); + return; + } + const url = parts.slice(1).join(" "); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + await respond("Invalid URL. Must start with http:// or https://"); + return; + } + ch.webhookUrl = url; await store.upsertChannel(ch); - await respond(`📰 Metadata schema set with ${parsed.fields.length} field(s).`); - } catch (e: any) { - await respond(`Invalid JSON: ${e.message}`); + await respond(`📰 Webhook set to ${url}`); + return; } - return; - } - - await respond("Usage: \`/in schema set \` | \`/in schema get\` | \`/in schema clear\`"); - return; - } - if (cmd === "perms") { - if (subcmd === "get" || (!subcmd && !arg)) { - if (ch.approvedPosters.length === 0) { - await respond("Default mode: only the original poster and channel managers can approve messages."); - } else if (ch.approvedPosters.includes("poster")) { - await respond(`Poster mode: the original poster of each message can approve it, plus channel managers.`); - } else { - const users = ch.approvedPosters.map((id) => `<@${id}>`).join(", "); - await respond(`Approved posters for #${ch.name}: ${users}\nPlus channel managers.`); + if (cmd === "auto" && subcmd === "list") { + if (ch.autoApproveUsers.length === 0) { + await respond(`No users have auto-approve in #${ch.name}.`); + return; + } + if (ch.autoApproveUsers.includes("*")) { + await respond(`Auto-approve in #${ch.name}: all users`); + return; + } + const users = ch.autoApproveUsers + .map((id) => `<@${id}>`) + .join(", "); + await respond(`Auto-approve in #${ch.name}: ${users}`); + return; } - return; - } - if (subcmd === "clear") { - ch.approvedPosters = []; - await store.upsertChannel(ch); - await respond("Poster permissions cleared. Back to default: only the original poster and channel managers can approve."); - return; - } + if (cmd === "schema") { + if (subcmd === "get") { + if (!ch.metadataSchema) { + await respond( + "No metadata schema configured for this channel.", + ); + return; + } + try { + const pretty = JSON.stringify( + JSON.parse(ch.metadataSchema), + null, + 2, + ); + await respond(`\`\`\`${pretty}\`\`\``); + } catch { + await respond(`Raw schema:\n${ch.metadataSchema}`); + } + return; + } + + if (subcmd === "clear") { + ch.metadataSchema = ""; + await store.upsertChannel(ch); + await respond("Metadata schema cleared."); + return; + } + + if (subcmd === "set") { + if (!arg) { + await respond( + "Usage: \`/in schema set \` — provide a valid metadata schema JSON.", + ); + return; + } + try { + const parsed = JSON.parse(arg); + if (!parsed.fields || !Array.isArray(parsed.fields)) { + await respond( + 'Schema must have a \`fields\` array. Example: \`{"title": "Metadata", "fields": [{"action_id": "title", "label": "Title", "type": "plain_text_input"}]}\`', + ); + return; + } + ch.metadataSchema = JSON.stringify(parsed); + await store.upsertChannel(ch); + await respond( + `📰 Metadata schema set with ${parsed.fields.length} field(s).`, + ); + } catch (e: any) { + await respond(`Invalid JSON: ${e.message}`); + } + return; + } - // Parse user mentions and "poster" keyword from the full text - const allParts = rest.split(/\s+/); - const userIds: string[] = []; - let posterMode = false; - for (const part of allParts) { - if (part.toLowerCase() === "poster") { - posterMode = true; - continue; + await respond( + "Usage: \`/in schema set \` | \`/in schema get\` | \`/in schema clear\`", + ); + return; } - const mentionMatch = part.match(/^<@(\w+)(?:\|[^>]*)?>$/); - if (mentionMatch) userIds.push(mentionMatch[1]); - } - if (userIds.length === 0 && !posterMode) { - await respond("Usage: \`/in perms @user1 @user2\` — restrict to specific users\n\`/in perms poster\` — let each message's author approve their own\n\`/in perms get\` — view current permissions\n\`/in perms clear\` — restore defaults"); - return; - } + if (cmd === "perms") { + if (subcmd === "get" || (!subcmd && !arg)) { + if (ch.approvedPosters.length === 0) { + await respond( + "Default mode: only the original poster and channel managers can approve messages.", + ); + } else if (ch.approvedPosters.includes("poster")) { + await respond( + `Poster mode: the original poster of each message can approve it, plus channel managers.`, + ); + } else { + const users = ch.approvedPosters + .map((id) => `<@${id}>`) + .join(", "); + await respond( + `Approved posters for #${ch.name}: ${users}\nPlus channel managers.`, + ); + } + return; + } - ch.approvedPosters = posterMode ? ["poster", ...userIds] : userIds; - await store.upsertChannel(ch); - if (posterMode && userIds.length > 0) { - const users = userIds.map((id) => `<@${id}>`).join(", "); - await respond(`📰 Poster permissions set for #${ch.name}: poster mode + ${users}`); - } else if (posterMode) { - await respond(`📰 Poster mode enabled for #${ch.name}. Each message's author can approve their own message.`); - } else { - const users = userIds.map((id) => `<@${id}>`).join(", "); - await respond(`📰 Poster permissions set for #${ch.name}: ${users}`); + if (subcmd === "clear") { + ch.approvedPosters = []; + await store.upsertChannel(ch); + await respond( + "Poster permissions cleared. Back to default: only the original poster and channel managers can approve.", + ); + return; + } + + // Parse user mentions and "poster" keyword from the full text + const allParts = rest.split(/\s+/); + const userIds: string[] = []; + let posterMode = false; + for (const part of allParts) { + if (part.toLowerCase() === "poster") { + posterMode = true; + continue; + } + const mentionMatch = part.match(/^<@(\w+)(?:\|[^>]*)?>$/); + if (mentionMatch) userIds.push(mentionMatch[1]); + } + + if (userIds.length === 0 && !posterMode) { + await respond( + "Usage: \`/in perms @user1 @user2\` — restrict to specific users\n\`/in perms poster\` — let each message's author approve their own\n\`/in perms get\` — view current permissions\n\`/in perms clear\` — restore defaults", + ); + return; + } + + ch.approvedPosters = posterMode ? ["poster", ...userIds] : userIds; + await store.upsertChannel(ch); + if (posterMode && userIds.length > 0) { + const users = userIds.map((id) => `<@${id}>`).join(", "); + await respond( + `📰 Poster permissions set for #${ch.name}: poster mode + ${users}`, + ); + } else if (posterMode) { + await respond( + `📰 Poster mode enabled for #${ch.name}. Each message's author can approve their own message.`, + ); + } else { + const users = userIds.map((id) => `<@${id}>`).join(", "); + await respond( + `📰 Poster permissions set for #${ch.name}: ${users}`, + ); + } + return; + } + + await respond( + "Commands:\n• \`pub [#channel]\` — enable indigest\n• \`unpub [#channel]\` — disable indigest\n• \`pub auto [@user]\` — auto-approve mode\n• \`pub manual\` — manual approve mode\n• \`pub replies\` — track thread replies\n• \`sub #channel\` — subscribe to another channel's feed\n• \`unsub #channel\` — unsubscribe from a channel\n• \`perms @user\` — set who can approve messages\n• \`status [#channel]\` — show status\n• \`webhook \` — set webhook\n• \`schema set \` | \`schema get\` | \`schema clear\`", + ); } - return; } - - await respond("Commands:\n• \`pub [#channel]\` — enable indigest\n• \`unpub [#channel]\` — disable indigest\n• \`pub auto [@user]\` — auto-approve mode\n• \`pub manual\` — manual approve mode\n• \`pub replies\` — track thread replies\n• \`sub #channel\` — subscribe to another channel's feed\n• \`unsub #channel\` — unsubscribe from a channel\n• \`perms @user\` — set who can approve messages\n• \`status [#channel]\` — show status\n• \`webhook \` — set webhook\n• \`schema set \` | \`schema get\` | \`schema clear\`"); - } - } - })()); + })(), + ); }; app.post("/slack", slackSlashCommand); @@ -2256,7 +2956,9 @@ app.get("/feed/:channelId", async (c) => { try { const env = c.env; const rawId = c.req.param("channelId")!; - const wantsJson = rawId.endsWith(".json") || c.req.header("accept")?.includes("application/json"); + const wantsJson = + rawId.endsWith(".json") || + c.req.header("accept")?.includes("application/json"); const channelId = wantsJson ? rawId.replace(/\.json$/, "") : rawId; const store = await getStore(getDbUrl(env)); const ch = await store.getChannel(channelId); @@ -2266,7 +2968,10 @@ app.get("/feed/:channelId", async (c) => { const identity = await resolveFeedIdentity(c, env); if (!canAccessFeed(ch, identity)) { - return c.json({ error: "forbidden", message: "You don't have access to this feed" }, 403); + return c.json( + { error: "forbidden", message: "You don't have access to this feed" }, + 403, + ); } const limit = Math.min(parseInt(c.req.query("limit") || "50") || 50, 200); @@ -2294,7 +2999,9 @@ app.get("/feed/:channelId", async (c) => { }); } - return c.text(feed.rss2(), 200, { "Content-Type": "application/rss+xml; charset=utf-8" }); + return c.text(feed.rss2(), 200, { + "Content-Type": "application/rss+xml; charset=utf-8", + }); } catch (err: any) { console.error("feed error:", err?.message || err); c.header("Retry-After", "2"); @@ -2318,7 +3025,10 @@ app.get("/api/messages", async (c) => { const identity = await resolveFeedIdentity(c, env); if (!canAccessFeed(ch, identity)) { - return c.json({ error: "forbidden", message: "You don't have access to this channel" }, 403); + return c.json( + { error: "forbidden", message: "You don't have access to this channel" }, + 403, + ); } const after = c.req.query("after"); @@ -2363,7 +3073,10 @@ app.get("/api/messages/:slackTs", async (c) => { const identity = await resolveFeedIdentity(c, env); if (!canAccessFeed(ch, identity)) { - return c.json({ error: "forbidden", message: "You don't have access to this channel" }, 403); + return c.json( + { error: "forbidden", message: "You don't have access to this channel" }, + 403, + ); } const all = await store.getMessages(channel, 100000, 0); @@ -2404,7 +3117,8 @@ app.get("/api/graph", async (c) => { const store = await getStore(getDbUrl(env)); - const { channels, subscriptions, subscriberChannels } = await store.getGraphData(); + const { channels, subscriptions, subscriberChannels } = + await store.getGraphData(); // Index channels by ID for fast lookup const channelMap = new Map(); @@ -2419,28 +3133,109 @@ app.get("/api/graph", async (c) => { subsBySource.set(sub.sourceChannelId, list); } - const nodes: Array<{ id: string; type: string; label: string; data: Record }> = []; - const edges: Array<{ id: string; source: string; target: string; label?: string; animated: boolean }> = []; + const nodes: Array<{ + id: string; + type: string; + label: string; + data: Record; + }> = []; + const edges: Array<{ + id: string; + source: string; + target: string; + label?: string; + animated: boolean; + }> = []; for (const ch of channels) { - nodes.push({ id: `ch:${ch.id}`, type: "channel", label: `#${ch.name || ch.id}`, data: { channelId: ch.id, name: ch.name, webhookUrl: ch.webhookUrl, metadataSchema: ch.metadataSchema, trackReplies: ch.trackReplies, approvedPosters: ch.approvedPosters, autoApproveUsers: ch.autoApproveUsers?.join(", ") || "", enabled: ch.enabled, linkMode: ch.linkMode, createdAt: ch.createdAt } }); - nodes.push({ id: `rss:${ch.id}`, type: "feed", label: "RSS Feed", data: { feedType: "rss", channelId: ch.id } }); - edges.push({ id: `e-rss-${ch.id}`, source: `ch:${ch.id}`, target: `rss:${ch.id}`, animated: true }); - nodes.push({ id: `api:${ch.id}`, type: "feed", label: "API Feed", data: { feedType: "api", channelId: ch.id } }); - edges.push({ id: `e-api-${ch.id}`, source: `ch:${ch.id}`, target: `api:${ch.id}`, animated: true }); + nodes.push({ + id: `ch:${ch.id}`, + type: "channel", + label: `#${ch.name || ch.id}`, + data: { + channelId: ch.id, + name: ch.name, + webhookUrl: ch.webhookUrl, + metadataSchema: ch.metadataSchema, + trackReplies: ch.trackReplies, + approvedPosters: ch.approvedPosters, + autoApproveUsers: ch.autoApproveUsers?.join(", ") || "", + enabled: ch.enabled, + linkMode: ch.linkMode, + createdAt: ch.createdAt, + }, + }); + nodes.push({ + id: `rss:${ch.id}`, + type: "feed", + label: "RSS Feed", + data: { feedType: "rss", channelId: ch.id }, + }); + edges.push({ + id: `e-rss-${ch.id}`, + source: `ch:${ch.id}`, + target: `rss:${ch.id}`, + animated: true, + }); + nodes.push({ + id: `api:${ch.id}`, + type: "feed", + label: "API Feed", + data: { feedType: "api", channelId: ch.id }, + }); + edges.push({ + id: `e-api-${ch.id}`, + source: `ch:${ch.id}`, + target: `api:${ch.id}`, + animated: true, + }); if (ch.webhookUrl) { - nodes.push({ id: `wh:${ch.id}`, type: "feed", label: "Webhook", data: { feedType: "webhook", channelId: ch.id, url: ch.webhookUrl } }); - edges.push({ id: `e-wh-${ch.id}`, source: `ch:${ch.id}`, target: `wh:${ch.id}`, animated: true }); + nodes.push({ + id: `wh:${ch.id}`, + type: "feed", + label: "Webhook", + data: { feedType: "webhook", channelId: ch.id, url: ch.webhookUrl }, + }); + edges.push({ + id: `e-wh-${ch.id}`, + source: `ch:${ch.id}`, + target: `wh:${ch.id}`, + animated: true, + }); } const subs = subsBySource.get(ch.id) || []; for (const sub of subs) { const subCh = channelMap.get(sub.subscriberChannelId); - const subLabel = subCh ? `#${subCh.name || subCh.id}` : sub.subscriberChannelId; + const subLabel = subCh + ? `#${subCh.name || subCh.id}` + : sub.subscriberChannelId; if (!nodes.find((n) => n.id === `sub:${sub.subscriberChannelId}`)) { - nodes.push({ id: `sub:${sub.subscriberChannelId}`, type: "subscriber", label: subLabel, data: { channelId: sub.subscriberChannelId, name: subCh?.name, webhookUrl: subCh?.webhookUrl, metadataSchema: subCh?.metadataSchema, trackReplies: subCh?.trackReplies, approvedPosters: subCh?.approvedPosters, autoApproveUsers: subCh?.autoApproveUsers?.join(", ") || "", enabled: subCh?.enabled, linkMode: subCh?.linkMode, createdAt: subCh?.createdAt } }); + nodes.push({ + id: `sub:${sub.subscriberChannelId}`, + type: "subscriber", + label: subLabel, + data: { + channelId: sub.subscriberChannelId, + name: subCh?.name, + webhookUrl: subCh?.webhookUrl, + metadataSchema: subCh?.metadataSchema, + trackReplies: subCh?.trackReplies, + approvedPosters: subCh?.approvedPosters, + autoApproveUsers: subCh?.autoApproveUsers?.join(", ") || "", + enabled: subCh?.enabled, + linkMode: subCh?.linkMode, + createdAt: subCh?.createdAt, + }, + }); } - edges.push({ id: `e-sub-${ch.id}-${sub.subscriberChannelId}`, source: `ch:${ch.id}`, target: `sub:${sub.subscriberChannelId}`, label: "[sub]", animated: true }); + edges.push({ + id: `e-sub-${ch.id}-${sub.subscriberChannelId}`, + source: `ch:${ch.id}`, + target: `sub:${sub.subscriberChannelId}`, + label: "[sub]", + animated: true, + }); } } @@ -2495,15 +3290,29 @@ function retryingStore(store: Store): Store { listChannels: () => withRetry(() => store.listChannels()), listEnabledChannels: () => withRetry(() => store.listEnabledChannels()), upsertMessage: (msg) => withRetry(() => store.upsertMessage(msg)), - deleteMessage: (channelId, slackTs) => withRetry(() => store.deleteMessage(channelId, slackTs)), - getMessages: (channelId, limit, offset) => withRetry(() => store.getMessages(channelId, limit, offset)), - addSubscription: (subscriberChannelId, sourceChannelId) => withRetry(() => store.addSubscription(subscriberChannelId, sourceChannelId)), - removeSubscription: (subscriberChannelId, sourceChannelId) => withRetry(() => store.removeSubscription(subscriberChannelId, sourceChannelId)), - getSubscribersBySource: (sourceChannelId) => withRetry(() => store.getSubscribersBySource(sourceChannelId)), - getSubscriptionsBySubscriber: (subscriberChannelId) => withRetry(() => store.getSubscriptionsBySubscriber(subscriberChannelId)), - getRecentMessages: (channelId, since) => withRetry(() => store.getRecentMessages(channelId, since)), + deleteMessage: (channelId, slackTs) => + withRetry(() => store.deleteMessage(channelId, slackTs)), + getMessages: (channelId, limit, offset) => + withRetry(() => store.getMessages(channelId, limit, offset)), + addSubscription: (subscriberChannelId, sourceChannelId) => + withRetry(() => + store.addSubscription(subscriberChannelId, sourceChannelId), + ), + removeSubscription: (subscriberChannelId, sourceChannelId) => + withRetry(() => + store.removeSubscription(subscriberChannelId, sourceChannelId), + ), + getSubscribersBySource: (sourceChannelId) => + withRetry(() => store.getSubscribersBySource(sourceChannelId)), + getSubscriptionsBySubscriber: (subscriberChannelId) => + withRetry(() => store.getSubscriptionsBySubscriber(subscriberChannelId)), + getRecentMessages: (channelId, since) => + withRetry(() => store.getRecentMessages(channelId, since)), addBotAction: (action) => withRetry(() => store.addBotAction(action)), - getBotActionsBySource: (sourceChannelId, sourceMessageTs) => withRetry(() => store.getBotActionsBySource(sourceChannelId, sourceMessageTs)), + getBotActionsBySource: (sourceChannelId, sourceMessageTs) => + withRetry(() => + store.getBotActionsBySource(sourceChannelId, sourceMessageTs), + ), getGraphData: () => withRetry(() => store.getGraphData()), close: () => store.close(), }; @@ -2529,14 +3338,19 @@ async function runDdlOnce(databaseUrl: string) { sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS metadata_schema TEXT NOT NULL DEFAULT ''`, sql`ALTER TABLE messages ADD COLUMN IF NOT EXISTS thread_ts TEXT`, ]; - for (const stmt of stmts) { try { await stmt; } catch {} } + for (const stmt of stmts) { + try { + await stmt; + } catch {} + } } async function getStore(databaseUrl: string): Promise { const cached = storeCache.get(databaseUrl); if (cached) return cached; - const isNeon = databaseUrl.includes("neon.tech") || databaseUrl.includes("neondb"); + const isNeon = + databaseUrl.includes("neon.tech") || databaseUrl.includes("neondb"); if (!isNeon) { const { pushSchema } = await import("./db/migrate"); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 51a3b76..d496922 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -3,7 +3,11 @@ import postgres from "postgres"; import { sql } from "drizzle-orm"; export async function pushSchema(connectionString: string) { - const client = postgres(connectionString, { max: 1, debug: false, onnotice: () => {} }); + const client = postgres(connectionString, { + max: 1, + debug: false, + onnotice: () => {}, + }); const db = drizzle(client); await db.execute(sql` @@ -46,13 +50,34 @@ export async function pushSchema(connectionString: string) { // Add columns if they don't exist (for existing DBs) — each in try/catch so one failure doesn't block others const alters: Array<[string, any]> = [ - ["link_mode", sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS link_mode INTEGER NOT NULL DEFAULT 0`], - ["auto_approve_users", sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_approve_users TEXT NOT NULL DEFAULT ''`], - ["approved_posters", sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS approved_posters TEXT NOT NULL DEFAULT ''`], - ["track_replies", sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS track_replies INTEGER NOT NULL DEFAULT 0`], - ["metadata_schema", sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS metadata_schema TEXT NOT NULL DEFAULT ''`], - ["metadata", sql`ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'`], - ["thread_ts", sql`ALTER TABLE messages ADD COLUMN IF NOT EXISTS thread_ts TEXT`], + [ + "link_mode", + sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS link_mode INTEGER NOT NULL DEFAULT 0`, + ], + [ + "auto_approve_users", + sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_approve_users TEXT NOT NULL DEFAULT ''`, + ], + [ + "approved_posters", + sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS approved_posters TEXT NOT NULL DEFAULT ''`, + ], + [ + "track_replies", + sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS track_replies INTEGER NOT NULL DEFAULT 0`, + ], + [ + "metadata_schema", + sql`ALTER TABLE channels ADD COLUMN IF NOT EXISTS metadata_schema TEXT NOT NULL DEFAULT ''`, + ], + [ + "metadata", + sql`ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'`, + ], + [ + "thread_ts", + sql`ALTER TABLE messages ADD COLUMN IF NOT EXISTS thread_ts TEXT`, + ], ]; for (const [col, query] of alters) { try { @@ -164,7 +189,9 @@ export async function pushSchema(connectionString: string) { `); try { - await db.execute(sql`ALTER TABLE auth_user ADD COLUMN IF NOT EXISTS slack_id TEXT`); + await db.execute( + sql`ALTER TABLE auth_user ADD COLUMN IF NOT EXISTS slack_id TEXT`, + ); } catch {} // --- API Key tables --- diff --git a/src/db/schema.ts b/src/db/schema.ts index d579767..b8c939e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,13 @@ -import { pgTable, text, integer, jsonb, boolean, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { + pgTable, + text, + integer, + jsonb, + boolean, + timestamp, + uniqueIndex, + index, +} from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; export const channels = pgTable("channels", { @@ -31,7 +40,10 @@ export const messages = pgTable( }, (t) => ({ uniqueMsg: uniqueIndex("uq_messages_channel_ts").on(t.channelId, t.slackTs), - channelTsIdx: index("idx_messages_channel_ts").on(t.channelId, t.timestamp.desc()), + channelTsIdx: index("idx_messages_channel_ts").on( + t.channelId, + t.timestamp.desc(), + ), }), ); @@ -44,7 +56,10 @@ export const subscriptions = pgTable( createdAt: text("created_at").notNull().default("now()"), }, (t) => ({ - uniqueSub: uniqueIndex("uq_subscriptions").on(t.subscriberChannelId, t.sourceChannelId), + uniqueSub: uniqueIndex("uq_subscriptions").on( + t.subscriberChannelId, + t.sourceChannelId, + ), sourceIdx: index("idx_subscriptions_source").on(t.sourceChannelId), }), ); @@ -52,7 +67,6 @@ export const subscriptions = pgTable( export const botActions = pgTable( "bot_actions", { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), type: text("type").notNull(), sourceChannelId: text("source_channel_id").notNull(), @@ -62,33 +76,30 @@ export const botActions = pgTable( createdAt: text("created_at").notNull().default("now()"), }, (t) => ({ - sourceIdx: index("idx_bot_actions_source").on(t.sourceChannelId, t.sourceMessageTs), + sourceIdx: index("idx_bot_actions_source").on( + t.sourceChannelId, + t.sourceMessageTs, + ), }), ); -export const apiKeys = pgTable( - "api_keys", - { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - name: text("name").notNull(), - owner: text("created_by_slack_id").references(() => authUser.id), - createdAt: timestamp("created_at").defaultNow().notNull(), - revokedBy: text("revoked_by_slack_id").references(() => authUser.id), - keyPrefix: text("key_prefix").notNull().unique(), - secretHash: text("secret_hash").notNull(), - lastUsedAt: timestamp("last_used_at"), - // expiresAt: timestamp("expires_at", { mode: "date" }), - } -) +export const apiKeys = pgTable("api_keys", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + name: text("name").notNull(), + owner: text("created_by_slack_id").references(() => authUser.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + revokedBy: text("revoked_by_slack_id").references(() => authUser.id), + keyPrefix: text("key_prefix").notNull().unique(), + secretHash: text("secret_hash").notNull(), + lastUsedAt: timestamp("last_used_at"), + // expiresAt: timestamp("expires_at", { mode: "date" }), +}); -export const apiKeyChannels = pgTable( - "api_key_channels", - { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - keyId: integer("key_id").references(() => apiKeys.id), - channelId: text("channel_id").references(() => channels.id) - } -) +export const apiKeyChannels = pgTable("api_key_channels", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + keyId: integer("key_id").references(() => apiKeys.id), + channelId: text("channel_id").references(() => channels.id), +}); // --- Better Auth tables --- @@ -100,7 +111,10 @@ export const authUser = pgTable("auth_user", { image: text("image"), slackId: text("slack_id"), createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), }); export const authSession = pgTable( @@ -110,10 +124,14 @@ export const authSession = pgTable( expiresAt: timestamp("expires_at").notNull(), token: text("token").notNull().unique(), createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").$onUpdate(() => new Date()).notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), - userId: text("user_id").notNull().references(() => authUser.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { onDelete: "cascade" }), }, (t) => [index("auth_session_userId_idx").on(t.userId)], ); @@ -124,7 +142,9 @@ export const authAccount = pgTable( id: text("id").primaryKey(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), - userId: text("user_id").notNull().references(() => authUser.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { onDelete: "cascade" }), accessToken: text("access_token"), refreshToken: text("refresh_token"), idToken: text("id_token"), @@ -133,7 +153,9 @@ export const authAccount = pgTable( scope: text("scope"), password: text("password"), createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").$onUpdate(() => new Date()).notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), }, (t) => [index("auth_account_userId_idx").on(t.userId)], ); @@ -146,7 +168,10 @@ export const authVerification = pgTable( value: text("value").notNull(), expiresAt: timestamp("expires_at").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), }, (t) => [index("auth_verification_identifier_idx").on(t.identifier)], ); @@ -157,9 +182,15 @@ export const authUserRelations = relations(authUser, ({ many }) => ({ })); export const authSessionRelations = relations(authSession, ({ one }) => ({ - user: one(authUser, { fields: [authSession.userId], references: [authUser.id] }), + user: one(authUser, { + fields: [authSession.userId], + references: [authUser.id], + }), })); export const authAccountRelations = relations(authAccount, ({ one }) => ({ - user: one(authUser, { fields: [authAccount.userId], references: [authUser.id] }), + user: one(authUser, { + fields: [authAccount.userId], + references: [authUser.id], + }), })); diff --git a/src/lib/api-keys.ts b/src/lib/api-keys.ts index 1d1725a..c73da5f 100644 --- a/src/lib/api-keys.ts +++ b/src/lib/api-keys.ts @@ -7,10 +7,16 @@ import { ORPCError } from "@orpc/server"; const KEY_PREFIX = "ind_"; const KEY_PREFIX_LENGTH = 12; -export function generateApiKey(): { fullKey: string; keyPrefix: string; secretHash: Promise } { +export function generateApiKey(): { + fullKey: string; + keyPrefix: string; + secretHash: Promise; +} { const array = new Uint8Array(24); crypto.getRandomValues(array); - const secret = Array.from(array, (b) => b.toString(36).padStart(2, "0")).join(""); + const secret = Array.from(array, (b) => b.toString(36).padStart(2, "0")).join( + "", + ); const fullKey = KEY_PREFIX + secret; const keyPrefix = secret.slice(0, KEY_PREFIX_LENGTH); const secretHash = hashSecret(fullKey); @@ -21,7 +27,9 @@ export async function hashSecret(secret: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(secret); const hash = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join(""); + return Array.from(new Uint8Array(hash), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); } export async function verifySecret( @@ -86,7 +94,10 @@ export async function createApiKeyContext({ channelId: schema.apiKeyChannels.channelId, }) .from(schema.apiKeys) - .leftJoin(schema.apiKeyChannels, eq(schema.apiKeys.id, schema.apiKeyChannels.keyId)) + .leftJoin( + schema.apiKeyChannels, + eq(schema.apiKeys.id, schema.apiKeyChannels.keyId), + ) .where(eq(schema.apiKeys.keyPrefix, keyPrefix)); if (rows.length === 0) { @@ -104,9 +115,7 @@ export async function createApiKeyContext({ }); } - const channelIds = rows - .filter((r) => r.channelId) - .map((r) => r.channelId!); + const channelIds = rows.filter((r) => r.channelId).map((r) => r.channelId!); await db .update(schema.apiKeys) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4404211..a311228 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -30,7 +30,8 @@ export function getAuth(databaseUrl: string, env?: Record) { config: [ { providerId: "hackclub", - discoveryUrl: "https://auth.hackclub.com/.well-known/openid-configuration", + discoveryUrl: + "https://auth.hackclub.com/.well-known/openid-configuration", clientId: env?.HACKCLUB_CLIENT_ID || "", clientSecret: env?.HACKCLUB_CLIENT_SECRET || "", scopes: ["openid", "profile", "email", "slack_id"], @@ -65,7 +66,10 @@ export function getAuth(databaseUrl: string, env?: Record) { * After user create/update, read the id_token from auth_account, decode it, * and set slack_id on auth_user if present. */ -async function setSlackIdFromAccount(databaseUrl: string, userId: string): Promise { +async function setSlackIdFromAccount( + databaseUrl: string, + userId: string, +): Promise { try { const db = getDb(databaseUrl); const accounts = await db @@ -90,7 +94,9 @@ async function setSlackIdFromAccount(databaseUrl: string, userId: string): Promi .update(schema.authUser) .set({ slackId: payload.slack_id }) .where(eq(schema.authUser.id, userId)); - console.log(`setSlackId: set slack_id=${payload.slack_id} for user ${userId}`); + console.log( + `setSlackId: set slack_id=${payload.slack_id} for user ${userId}`, + ); } catch (err: any) { console.error("setSlackIdFromAccount failed:", err.message); } @@ -98,5 +104,7 @@ async function setSlackIdFromAccount(databaseUrl: string, userId: string): Promi // Default export for `npx auth generate` — uses DATABASE_URL env var const databaseUrl = process.env.DATABASE_URL || ""; -export const auth = databaseUrl ? getAuth(databaseUrl, process.env as Record) : (null as any); +export const auth = databaseUrl + ? getAuth(databaseUrl, process.env as Record) + : (null as any); export default auth; diff --git a/src/store/neon.ts b/src/store/neon.ts index f62022a..efcca0a 100644 --- a/src/store/neon.ts +++ b/src/store/neon.ts @@ -1,5 +1,11 @@ import { neon, type NeonQueryFunction } from "@neondatabase/serverless"; -import type { Store, StoreChannel, StoreMessage, StoreSubscription, StoreBotAction } from "./store"; +import type { + Store, + StoreChannel, + StoreMessage, + StoreSubscription, + StoreBotAction, +} from "./store"; function rows(result: any): any[] { return Array.isArray(result) ? result : result?.rows || []; @@ -13,7 +19,9 @@ export class NeonStore implements Store { } async getChannel(id: string): Promise { - const r = rows(await this.sql`SELECT * FROM channels WHERE id = ${id} LIMIT 1`)[0]; + const r = rows( + await this.sql`SELECT * FROM channels WHERE id = ${id} LIMIT 1`, + )[0]; if (!r) return null; return { id: r.id, @@ -22,9 +30,15 @@ export class NeonStore implements Store { enabled: Boolean(r.enabled), linkMode: Boolean(r.link_mode), webhookUrl: r.webhook_url, - autoApproveUsers: r.auto_approve_users ? r.auto_approve_users.split(",").filter(Boolean) : [], - approvedPosters: r.approved_posters ? r.approved_posters.split(",").filter(Boolean) : [], - accessPermUsers: r.access_perm_users ? r.access_perm_users.split(",").filter(Boolean) : ["*"], + autoApproveUsers: r.auto_approve_users + ? r.auto_approve_users.split(",").filter(Boolean) + : [], + approvedPosters: r.approved_posters + ? r.approved_posters.split(",").filter(Boolean) + : [], + accessPermUsers: r.access_perm_users + ? r.access_perm_users.split(",").filter(Boolean) + : ["*"], trackReplies: Boolean(r.track_replies), metadataSchema: r.metadata_schema, createdAt: r.created_at, @@ -32,7 +46,8 @@ export class NeonStore implements Store { } async upsertChannel(ch: StoreChannel): Promise { - await this.sql`INSERT INTO channels (id, name, team_id, enabled, link_mode, webhook_url, auto_approve_users, approved_posters, access_perm_users, track_replies, metadata_schema, created_at) + await this + .sql`INSERT INTO channels (id, name, team_id, enabled, link_mode, webhook_url, auto_approve_users, approved_posters, access_perm_users, track_replies, metadata_schema, created_at) VALUES (${ch.id}, ${ch.name}, ${ch.teamId}, ${ch.enabled ? 1 : 0}, ${ch.linkMode ? 1 : 0}, ${ch.webhookUrl}, ${ch.autoApproveUsers.join(",")}, ${ch.approvedPosters.join(",")}, ${ch.accessPermUsers.join(",")}, ${ch.trackReplies ? 1 : 0}, ${ch.metadataSchema}, ${new Date().toISOString()}) ON CONFLICT (id) DO UPDATE SET name = ${ch.name}, team_id = ${ch.teamId}, enabled = ${ch.enabled ? 1 : 0}, link_mode = ${ch.linkMode ? 1 : 0}, webhook_url = ${ch.webhookUrl}, auto_approve_users = ${ch.autoApproveUsers.join(",")}, approved_posters = ${ch.approvedPosters.join(",")}, access_perm_users = ${ch.accessPermUsers.join(",")}, track_replies = ${ch.trackReplies ? 1 : 0}, metadata_schema = ${ch.metadataSchema}`; } @@ -45,9 +60,15 @@ export class NeonStore implements Store { enabled: Boolean(r.enabled), linkMode: Boolean(r.link_mode), webhookUrl: r.webhook_url, - autoApproveUsers: r.auto_approve_users ? r.auto_approve_users.split(",").filter(Boolean) : [], - approvedPosters: r.approved_posters ? r.approved_posters.split(",").filter(Boolean) : [], - accessPermUsers: r.access_perm_users ? r.access_perm_users.split(",").filter(Boolean) : ["*"], + autoApproveUsers: r.auto_approve_users + ? r.auto_approve_users.split(",").filter(Boolean) + : [], + approvedPosters: r.approved_posters + ? r.approved_posters.split(",").filter(Boolean) + : [], + accessPermUsers: r.access_perm_users + ? r.access_perm_users.split(",").filter(Boolean) + : ["*"], trackReplies: Boolean(r.track_replies), metadataSchema: r.metadata_schema, createdAt: r.created_at, @@ -55,36 +76,56 @@ export class NeonStore implements Store { } async listEnabledChannels(): Promise { - return rows(await this.sql`SELECT * FROM channels WHERE enabled = 1`).map((r) => ({ - id: r.id, - name: r.name, - teamId: r.team_id, - enabled: true, - linkMode: Boolean(r.link_mode), - webhookUrl: r.webhook_url, - autoApproveUsers: r.auto_approve_users ? r.auto_approve_users.split(",").filter(Boolean) : [], - approvedPosters: r.approved_posters ? r.approved_posters.split(",").filter(Boolean) : [], - accessPermUsers: r.access_perm_users ? r.access_perm_users.split(",").filter(Boolean) : ["*"], - trackReplies: Boolean(r.track_replies), - metadataSchema: r.metadata_schema, - createdAt: r.created_at, - })); + return rows(await this.sql`SELECT * FROM channels WHERE enabled = 1`).map( + (r) => ({ + id: r.id, + name: r.name, + teamId: r.team_id, + enabled: true, + linkMode: Boolean(r.link_mode), + webhookUrl: r.webhook_url, + autoApproveUsers: r.auto_approve_users + ? r.auto_approve_users.split(",").filter(Boolean) + : [], + approvedPosters: r.approved_posters + ? r.approved_posters.split(",").filter(Boolean) + : [], + accessPermUsers: r.access_perm_users + ? r.access_perm_users.split(",").filter(Boolean) + : ["*"], + trackReplies: Boolean(r.track_replies), + metadataSchema: r.metadata_schema, + createdAt: r.created_at, + }), + ); } async upsertMessage(msg: StoreMessage): Promise { - const metadataStr = typeof msg.metadata === "string" ? msg.metadata : JSON.stringify(msg.metadata || {}); - await this.sql`INSERT INTO messages (slack_ts, channel_id, user_id, user_name, text, thread_ts, timestamp, metadata) + const metadataStr = + typeof msg.metadata === "string" + ? msg.metadata + : JSON.stringify(msg.metadata || {}); + await this + .sql`INSERT INTO messages (slack_ts, channel_id, user_id, user_name, text, thread_ts, timestamp, metadata) VALUES (${msg.slackTs}, ${msg.channelId}, ${msg.userId}, ${msg.userName}, ${msg.text}, ${msg.threadTs || null}, ${msg.timestamp}, ${metadataStr}::jsonb) ON CONFLICT (channel_id, slack_ts) DO UPDATE SET user_id = ${msg.userId}, user_name = ${msg.userName}, text = ${msg.text}, thread_ts = ${msg.threadTs || null}, timestamp = ${msg.timestamp}, metadata = ${metadataStr}::jsonb`; } async deleteMessage(channelId: string, slackTs: string): Promise { - await this.sql`DELETE FROM messages WHERE channel_id = ${channelId} AND slack_ts = ${slackTs}`; + await this + .sql`DELETE FROM messages WHERE channel_id = ${channelId} AND slack_ts = ${slackTs}`; } - async getMessages(channelId: string, limit = 50, offset = 0): Promise { + async getMessages( + channelId: string, + limit = 50, + offset = 0, + ): Promise { if (limit > 200) limit = 200; - return rows(await this.sql`SELECT * FROM messages WHERE channel_id = ${channelId} ORDER BY timestamp DESC LIMIT ${limit} OFFSET ${offset}`).map((r) => ({ + return rows( + await this + .sql`SELECT * FROM messages WHERE channel_id = ${channelId} ORDER BY timestamp DESC LIMIT ${limit} OFFSET ${offset}`, + ).map((r) => ({ id: r.id, slackTs: r.slack_ts, channelId: r.channel_id, @@ -93,24 +134,40 @@ export class NeonStore implements Store { userName: r.user_name, text: r.text, timestamp: r.timestamp, - metadata: typeof r.metadata === "string" ? r.metadata : JSON.stringify(r.metadata || {}), + metadata: + typeof r.metadata === "string" + ? r.metadata + : JSON.stringify(r.metadata || {}), })); } close(): void {} - async addSubscription(subscriberChannelId: string, sourceChannelId: string): Promise { - await this.sql`INSERT INTO subscriptions (subscriber_channel_id, source_channel_id, created_at) + async addSubscription( + subscriberChannelId: string, + sourceChannelId: string, + ): Promise { + await this + .sql`INSERT INTO subscriptions (subscriber_channel_id, source_channel_id, created_at) VALUES (${subscriberChannelId}, ${sourceChannelId}, ${new Date().toISOString()}) ON CONFLICT (subscriber_channel_id, source_channel_id) DO NOTHING`; } - async removeSubscription(subscriberChannelId: string, sourceChannelId: string): Promise { - await this.sql`DELETE FROM subscriptions WHERE subscriber_channel_id = ${subscriberChannelId} AND source_channel_id = ${sourceChannelId}`; + async removeSubscription( + subscriberChannelId: string, + sourceChannelId: string, + ): Promise { + await this + .sql`DELETE FROM subscriptions WHERE subscriber_channel_id = ${subscriberChannelId} AND source_channel_id = ${sourceChannelId}`; } - async getSubscribersBySource(sourceChannelId: string): Promise { - return rows(await this.sql`SELECT * FROM subscriptions WHERE source_channel_id = ${sourceChannelId}`).map((r) => ({ + async getSubscribersBySource( + sourceChannelId: string, + ): Promise { + return rows( + await this + .sql`SELECT * FROM subscriptions WHERE source_channel_id = ${sourceChannelId}`, + ).map((r) => ({ id: r.id, subscriberChannelId: r.subscriber_channel_id, sourceChannelId: r.source_channel_id, @@ -118,8 +175,13 @@ export class NeonStore implements Store { })); } - async getSubscriptionsBySubscriber(subscriberChannelId: string): Promise { - return rows(await this.sql`SELECT * FROM subscriptions WHERE subscriber_channel_id = ${subscriberChannelId}`).map((r) => ({ + async getSubscriptionsBySubscriber( + subscriberChannelId: string, + ): Promise { + return rows( + await this + .sql`SELECT * FROM subscriptions WHERE subscriber_channel_id = ${subscriberChannelId}`, + ).map((r) => ({ id: r.id, subscriberChannelId: r.subscriber_channel_id, sourceChannelId: r.source_channel_id, @@ -127,9 +189,15 @@ export class NeonStore implements Store { })); } - async getRecentMessages(channelId: string, since: Date): Promise { + async getRecentMessages( + channelId: string, + since: Date, + ): Promise { const sinceStr = since.toISOString(); - return rows(await this.sql`SELECT * FROM messages WHERE channel_id = ${channelId} AND timestamp >= ${sinceStr} ORDER BY timestamp DESC`).map((r) => ({ + return rows( + await this + .sql`SELECT * FROM messages WHERE channel_id = ${channelId} AND timestamp >= ${sinceStr} ORDER BY timestamp DESC`, + ).map((r) => ({ id: r.id, slackTs: r.slack_ts, channelId: r.channel_id, @@ -138,17 +206,27 @@ export class NeonStore implements Store { userName: r.user_name, text: r.text, timestamp: r.timestamp, - metadata: typeof r.metadata === "string" ? r.metadata : JSON.stringify(r.metadata || {}), + metadata: + typeof r.metadata === "string" + ? r.metadata + : JSON.stringify(r.metadata || {}), })); } async addBotAction(action: StoreBotAction): Promise { - await this.sql`INSERT INTO bot_actions (type, source_channel_id, source_message_ts, bot_channel_id, bot_message_ts, created_at) + await this + .sql`INSERT INTO bot_actions (type, source_channel_id, source_message_ts, bot_channel_id, bot_message_ts, created_at) VALUES (${action.type}, ${action.sourceChannelId}, ${action.sourceMessageTs}, ${action.botChannelId}, ${action.botMessageTs}, ${new Date().toISOString()})`; } - async getBotActionsBySource(sourceChannelId: string, sourceMessageTs: string): Promise { - return rows(await this.sql`SELECT * FROM bot_actions WHERE source_channel_id = ${sourceChannelId} AND source_message_ts = ${sourceMessageTs}`).map((r) => ({ + async getBotActionsBySource( + sourceChannelId: string, + sourceMessageTs: string, + ): Promise { + return rows( + await this + .sql`SELECT * FROM bot_actions WHERE source_channel_id = ${sourceChannelId} AND source_message_ts = ${sourceMessageTs}`, + ).map((r) => ({ id: r.id, type: r.type, sourceChannelId: r.source_channel_id, @@ -167,16 +245,26 @@ export class NeonStore implements Store { enabled: Boolean(r.enabled), linkMode: Boolean(r.link_mode), webhookUrl: r.webhook_url, - autoApproveUsers: r.auto_approve_users ? r.auto_approve_users.split(",").filter(Boolean) : [], - approvedPosters: r.approved_posters ? r.approved_posters.split(",").filter(Boolean) : [], - accessPermUsers: r.access_perm_users ? r.access_perm_users.split(",").filter(Boolean) : ["*"], + autoApproveUsers: r.auto_approve_users + ? r.auto_approve_users.split(",").filter(Boolean) + : [], + approvedPosters: r.approved_posters + ? r.approved_posters.split(",").filter(Boolean) + : [], + accessPermUsers: r.access_perm_users + ? r.access_perm_users.split(",").filter(Boolean) + : ["*"], trackReplies: Boolean(r.track_replies), metadataSchema: r.metadata_schema, createdAt: r.created_at, }; } - async getGraphData(): Promise<{ channels: StoreChannel[]; subscriptions: StoreSubscription[]; subscriberChannels: StoreChannel[] }> { + async getGraphData(): Promise<{ + channels: StoreChannel[]; + subscriptions: StoreSubscription[]; + subscriberChannels: StoreChannel[]; + }> { const [channelRows, subscriptionRows] = await Promise.all([ this.sql`SELECT * FROM channels`, this.sql`SELECT * FROM subscriptions`, @@ -185,11 +273,18 @@ export class NeonStore implements Store { const channels = rows(channelRows).map((r) => this.rowToChannel(r)); const channelIds = new Set(channels.map((c) => c.id)); - const subscriberChannelIds = [...new Set(rows(subscriptionRows).map((r) => r.subscriber_channel_id).filter((id: string) => !channelIds.has(id)))]; + const subscriberChannelIds = [ + ...new Set( + rows(subscriptionRows) + .map((r) => r.subscriber_channel_id) + .filter((id: string) => !channelIds.has(id)), + ), + ]; let subscriberChannels: StoreChannel[] = []; if (subscriberChannelIds.length > 0) { - const subRows = await this.sql`SELECT * FROM channels WHERE id = ANY(${subscriberChannelIds})`; + const subRows = await this + .sql`SELECT * FROM channels WHERE id = ANY(${subscriberChannelIds})`; subscriberChannels = rows(subRows).map((r) => this.rowToChannel(r)); } diff --git a/src/store/pg.test.ts b/src/store/pg.test.ts index e622eab..26397e7 100644 --- a/src/store/pg.test.ts +++ b/src/store/pg.test.ts @@ -30,7 +30,20 @@ afterAll(() => { describe("PostgresStore", () => { it("should insert and retrieve a channel", async () => { - await store.upsertChannel({ id: "C001", name: "general", teamId: "T001", enabled: false, linkMode: false, webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }); + await store.upsertChannel({ + id: "C001", + name: "general", + teamId: "T001", + enabled: false, + linkMode: false, + webhookUrl: "", + autoApproveUsers: [], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }); const ch = await store.getChannel("C001"); expect(ch).not.toBeNull(); expect(ch!.name).toBe("general"); @@ -38,15 +51,38 @@ describe("PostgresStore", () => { }); it("should enable a channel via upsert", async () => { - await store.upsertChannel({ id: "C001", name: "general", teamId: "T001", enabled: true, linkMode: false, webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }); + await store.upsertChannel({ + id: "C001", + name: "general", + teamId: "T001", + enabled: true, + linkMode: false, + webhookUrl: "", + autoApproveUsers: [], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }); const ch = await store.getChannel("C001"); expect(ch!.enabled).toBe(true); }); it("should update channel fields on upsert", async () => { await store.upsertChannel({ - id: "C001", name: "updated-name", teamId: "T001", enabled: true, linkMode: false, - webhookUrl: "https://hook.example.com", autoApproveUsers: ["U1", "U2"], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "", + id: "C001", + name: "updated-name", + teamId: "T001", + enabled: true, + linkMode: false, + webhookUrl: "https://hook.example.com", + autoApproveUsers: ["U1", "U2"], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", }); const ch = await store.getChannel("C001"); expect(ch!.name).toBe("updated-name"); @@ -55,8 +91,34 @@ describe("PostgresStore", () => { }); it("should list only enabled channels", async () => { - await store.upsertChannel({ id: "C002", name: "random", teamId: "T001", enabled: false, linkMode: false, webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }); - await store.upsertChannel({ id: "C003", name: "dev", teamId: "T001", enabled: true, linkMode: false, webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }); + await store.upsertChannel({ + id: "C002", + name: "random", + teamId: "T001", + enabled: false, + linkMode: false, + webhookUrl: "", + autoApproveUsers: [], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }); + await store.upsertChannel({ + id: "C003", + name: "dev", + teamId: "T001", + enabled: true, + linkMode: false, + webhookUrl: "", + autoApproveUsers: [], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }); const enabled = await store.listEnabledChannels(); expect(enabled.length).toBe(2); expect(enabled.map((c) => c.id).sort()).toEqual(["C001", "C003"]); @@ -69,9 +131,33 @@ describe("PostgresStore", () => { it("should insert and retrieve messages in descending order", async () => { const now = new Date(); const msgs = [ - { slackTs: "1.000", channelId: "C001", userId: "U1", userName: "alice", text: "first", timestamp: new Date(now.getTime() - 3000).toISOString(), metadata: {} as Record }, - { slackTs: "2.000", channelId: "C001", userId: "U2", userName: "bob", text: "second", timestamp: new Date(now.getTime() - 2000).toISOString(), metadata: {} as Record }, - { slackTs: "3.000", channelId: "C001", userId: "U1", userName: "alice", text: "third", timestamp: new Date(now.getTime() - 1000).toISOString(), metadata: {} as Record }, + { + slackTs: "1.000", + channelId: "C001", + userId: "U1", + userName: "alice", + text: "first", + timestamp: new Date(now.getTime() - 3000).toISOString(), + metadata: {} as Record, + }, + { + slackTs: "2.000", + channelId: "C001", + userId: "U2", + userName: "bob", + text: "second", + timestamp: new Date(now.getTime() - 2000).toISOString(), + metadata: {} as Record, + }, + { + slackTs: "3.000", + channelId: "C001", + userId: "U1", + userName: "alice", + text: "third", + timestamp: new Date(now.getTime() - 1000).toISOString(), + metadata: {} as Record, + }, ]; for (const m of msgs) await store.upsertMessage(m); @@ -83,7 +169,15 @@ describe("PostgresStore", () => { }); it("should deduplicate and update messages on conflict", async () => { - await store.upsertMessage({ slackTs: "1.000", channelId: "C001", userId: "U1", userName: "alice", text: "edited text", timestamp: new Date().toISOString(), metadata: {} as Record }); + await store.upsertMessage({ + slackTs: "1.000", + channelId: "C001", + userId: "U1", + userName: "alice", + text: "edited text", + timestamp: new Date().toISOString(), + metadata: {} as Record, + }); const got = await store.getMessages("C001", 10, 0); const updated = got.find((m) => m.slackTs === "1.000"); expect(updated).not.toBeUndefined(); @@ -103,7 +197,20 @@ describe("PostgresStore", () => { }); it("should persist webhookUrl and autoApproveUsers on channel", async () => { - await store.upsertChannel({ id: "C010", name: "webhook-test", teamId: "T001", enabled: true, linkMode: false, webhookUrl: "https://hooks.example.com/feed", autoApproveUsers: ["U100", "U200"], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }); + await store.upsertChannel({ + id: "C010", + name: "webhook-test", + teamId: "T001", + enabled: true, + linkMode: false, + webhookUrl: "https://hooks.example.com/feed", + autoApproveUsers: ["U100", "U200"], + approvedPosters: [], + accessPermUsers: ["*"], + trackReplies: false, + metadataSchema: "", + createdAt: "", + }); const ch = await store.getChannel("C010"); expect(ch!.webhookUrl).toBe("https://hooks.example.com/feed"); expect(ch!.autoApproveUsers).toEqual(["U100", "U200"]); diff --git a/src/store/pg.ts b/src/store/pg.ts index 7728725..e0d3bd7 100644 --- a/src/store/pg.ts +++ b/src/store/pg.ts @@ -2,22 +2,55 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { eq, sql, and, inArray } from "drizzle-orm"; import * as schema from "../db/schema"; -import type { Store, StoreChannel, StoreMessage, StoreSubscription, StoreBotAction } from "./store"; +import type { + Store, + StoreChannel, + StoreMessage, + StoreSubscription, + StoreBotAction, +} from "./store"; export class PostgresStore implements Store { private db: ReturnType; private client: ReturnType; constructor(connectionString: string) { - this.client = postgres(connectionString, { prepare: false, debug: false, onnotice: () => {} }); + this.client = postgres(connectionString, { + prepare: false, + debug: false, + onnotice: () => {}, + }); this.db = drizzle(this.client, { schema }); } async getChannel(id: string): Promise { - const rows = await this.db.select().from(schema.channels).where(eq(schema.channels.id, id)).limit(1); + const rows = await this.db + .select() + .from(schema.channels) + .where(eq(schema.channels.id, id)) + .limit(1); if (rows.length === 0) return null; const r = rows[0]!; - return { accessPermUsers: r.accessPermUsers ? r.accessPermUsers.split(",").filter(Boolean) : ["*"], id: r.id, name: r.name, teamId: r.teamId, enabled: Boolean(r.enabled), linkMode: Boolean(r.linkMode), webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], approvedPosters: r.approvedPosters ? r.approvedPosters.split(",").filter(Boolean) : [], trackReplies: Boolean(r.trackReplies), metadataSchema: r.metadataSchema, createdAt: r.createdAt }; + return { + accessPermUsers: r.accessPermUsers + ? r.accessPermUsers.split(",").filter(Boolean) + : ["*"], + id: r.id, + name: r.name, + teamId: r.teamId, + enabled: Boolean(r.enabled), + linkMode: Boolean(r.linkMode), + webhookUrl: r.webhookUrl, + autoApproveUsers: r.autoApproveUsers + ? r.autoApproveUsers.split(",").filter(Boolean) + : [], + approvedPosters: r.approvedPosters + ? r.approvedPosters.split(",").filter(Boolean) + : [], + trackReplies: Boolean(r.trackReplies), + metadataSchema: r.metadataSchema, + createdAt: r.createdAt, + }; } async upsertChannel(ch: StoreChannel): Promise { @@ -63,9 +96,15 @@ export class PostgresStore implements Store { enabled: Boolean(r.enabled), linkMode: Boolean(r.linkMode), webhookUrl: r.webhookUrl, - autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], - accessPermUsers: r.accessPermUsers ? r.accessPermUsers.split(",").filter(Boolean) : [], - approvedPosters: r.approvedPosters ? r.approvedPosters.split(",").filter(Boolean) : [], + autoApproveUsers: r.autoApproveUsers + ? r.autoApproveUsers.split(",").filter(Boolean) + : [], + accessPermUsers: r.accessPermUsers + ? r.accessPermUsers.split(",").filter(Boolean) + : [], + approvedPosters: r.approvedPosters + ? r.approvedPosters.split(",").filter(Boolean) + : [], trackReplies: Boolean(r.trackReplies), metadataSchema: r.metadataSchema, createdAt: r.createdAt, @@ -73,8 +112,30 @@ export class PostgresStore implements Store { } async listEnabledChannels(): Promise { - const rows = await this.db.select().from(schema.channels).where(eq(schema.channels.enabled, 1)); - return rows.map((r) => ({ id: r.id, name: r.name, teamId: r.teamId, enabled: true, linkMode: Boolean(r.linkMode), webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], approvedPosters: r.approvedPosters ? r.approvedPosters.split(",").filter(Boolean) : [], accessPermUsers: r.accessPermUsers ? r.accessPermUsers.split(",").filter(Boolean) : [], trackReplies: Boolean(r.trackReplies), metadataSchema: r.metadataSchema, createdAt: r.createdAt })); + const rows = await this.db + .select() + .from(schema.channels) + .where(eq(schema.channels.enabled, 1)); + return rows.map((r) => ({ + id: r.id, + name: r.name, + teamId: r.teamId, + enabled: true, + linkMode: Boolean(r.linkMode), + webhookUrl: r.webhookUrl, + autoApproveUsers: r.autoApproveUsers + ? r.autoApproveUsers.split(",").filter(Boolean) + : [], + approvedPosters: r.approvedPosters + ? r.approvedPosters.split(",").filter(Boolean) + : [], + accessPermUsers: r.accessPermUsers + ? r.accessPermUsers.split(",").filter(Boolean) + : [], + trackReplies: Boolean(r.trackReplies), + metadataSchema: r.metadataSchema, + createdAt: r.createdAt, + })); } async upsertMessage(msg: StoreMessage): Promise { @@ -88,7 +149,10 @@ export class PostgresStore implements Store { text: msg.text, threadTs: msg.threadTs || null, timestamp: msg.timestamp, - metadata: typeof msg.metadata === "string" ? msg.metadata : JSON.stringify(msg.metadata || {}), + metadata: + typeof msg.metadata === "string" + ? msg.metadata + : JSON.stringify(msg.metadata || {}), }) .onConflictDoUpdate({ target: [schema.messages.channelId, schema.messages.slackTs], @@ -98,16 +162,30 @@ export class PostgresStore implements Store { text: msg.text, threadTs: msg.threadTs || null, timestamp: msg.timestamp, - metadata: typeof msg.metadata === "string" ? msg.metadata : JSON.stringify(msg.metadata || {}), + metadata: + typeof msg.metadata === "string" + ? msg.metadata + : JSON.stringify(msg.metadata || {}), }, }); } async deleteMessage(channelId: string, slackTs: string): Promise { - await this.db.delete(schema.messages).where(and(eq(schema.messages.channelId, channelId), eq(schema.messages.slackTs, slackTs))); + await this.db + .delete(schema.messages) + .where( + and( + eq(schema.messages.channelId, channelId), + eq(schema.messages.slackTs, slackTs), + ), + ); } - async getMessages(channelId: string, limit = 50, offset = 0): Promise { + async getMessages( + channelId: string, + limit = 50, + offset = 0, + ): Promise { if (limit > 200) limit = 200; const rows = await this.db .select() @@ -125,7 +203,10 @@ export class PostgresStore implements Store { userName: r.userName, text: r.text, timestamp: r.timestamp, - metadata: typeof r.metadata === "string" ? r.metadata : JSON.stringify(r.metadata || {}), + metadata: + typeof r.metadata === "string" + ? r.metadata + : JSON.stringify(r.metadata || {}), })); } @@ -133,25 +214,37 @@ export class PostgresStore implements Store { this.client.end(); } - async addSubscription(subscriberChannelId: string, sourceChannelId: string): Promise { + async addSubscription( + subscriberChannelId: string, + sourceChannelId: string, + ): Promise { await this.db .insert(schema.subscriptions) - .values({ subscriberChannelId, sourceChannelId, createdAt: new Date().toISOString() }) + .values({ + subscriberChannelId, + sourceChannelId, + createdAt: new Date().toISOString(), + }) .onConflictDoNothing(); } - async removeSubscription(subscriberChannelId: string, sourceChannelId: string): Promise { + async removeSubscription( + subscriberChannelId: string, + sourceChannelId: string, + ): Promise { await this.db .delete(schema.subscriptions) .where( and( eq(schema.subscriptions.subscriberChannelId, subscriberChannelId), - eq(schema.subscriptions.sourceChannelId, sourceChannelId) - ) + eq(schema.subscriptions.sourceChannelId, sourceChannelId), + ), ); } - async getSubscribersBySource(sourceChannelId: string): Promise { + async getSubscribersBySource( + sourceChannelId: string, + ): Promise { const rows = await this.db .select() .from(schema.subscriptions) @@ -164,7 +257,9 @@ export class PostgresStore implements Store { })); } - async getSubscriptionsBySubscriber(subscriberChannelId: string): Promise { + async getSubscriptionsBySubscriber( + subscriberChannelId: string, + ): Promise { const rows = await this.db .select() .from(schema.subscriptions) @@ -177,12 +272,18 @@ export class PostgresStore implements Store { })); } - async getRecentMessages(channelId: string, since: Date): Promise { + async getRecentMessages( + channelId: string, + since: Date, + ): Promise { const sinceStr = since.toISOString(); const rows = await this.db .select() .from(schema.messages) - .where(eq(schema.messages.channelId, channelId) && sql`${schema.messages.timestamp} >= ${sinceStr}`) + .where( + eq(schema.messages.channelId, channelId) && + sql`${schema.messages.timestamp} >= ${sinceStr}`, + ) .orderBy(sql`${schema.messages.timestamp} DESC`); return rows.map((r) => ({ id: r.id, @@ -193,7 +294,10 @@ export class PostgresStore implements Store { userName: r.userName, text: r.text, timestamp: r.timestamp, - metadata: typeof r.metadata === "string" ? r.metadata : JSON.stringify(r.metadata || {}), + metadata: + typeof r.metadata === "string" + ? r.metadata + : JSON.stringify(r.metadata || {}), })); } @@ -208,11 +312,19 @@ export class PostgresStore implements Store { }); } - async getBotActionsBySource(sourceChannelId: string, sourceMessageTs: string): Promise { + async getBotActionsBySource( + sourceChannelId: string, + sourceMessageTs: string, + ): Promise { const rows = await this.db .select() .from(schema.botActions) - .where(and(eq(schema.botActions.sourceChannelId, sourceChannelId), eq(schema.botActions.sourceMessageTs, sourceMessageTs))); + .where( + and( + eq(schema.botActions.sourceChannelId, sourceChannelId), + eq(schema.botActions.sourceMessageTs, sourceMessageTs), + ), + ); return rows.map((r) => ({ id: r.id, type: r.type, @@ -232,16 +344,26 @@ export class PostgresStore implements Store { enabled: Boolean(r.enabled), linkMode: Boolean(r.linkMode), webhookUrl: r.webhookUrl, - autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], - approvedPosters: r.approvedPosters ? r.approvedPosters.split(",").filter(Boolean) : [], - accessPermUsers: r.accessPermUsers ? r.accessPermUsers.split(",").filter(Boolean) : [], + autoApproveUsers: r.autoApproveUsers + ? r.autoApproveUsers.split(",").filter(Boolean) + : [], + approvedPosters: r.approvedPosters + ? r.approvedPosters.split(",").filter(Boolean) + : [], + accessPermUsers: r.accessPermUsers + ? r.accessPermUsers.split(",").filter(Boolean) + : [], trackReplies: Boolean(r.trackReplies), metadataSchema: r.metadataSchema, createdAt: r.createdAt, }; } - async getGraphData(): Promise<{ channels: StoreChannel[]; subscriptions: StoreSubscription[]; subscriberChannels: StoreChannel[] }> { + async getGraphData(): Promise<{ + channels: StoreChannel[]; + subscriptions: StoreSubscription[]; + subscriberChannels: StoreChannel[]; + }> { const [channelRows, subscriptionRows] = await Promise.all([ this.db.select().from(schema.channels), this.db.select().from(schema.subscriptions), @@ -251,11 +373,20 @@ export class PostgresStore implements Store { // Find subscriber channels that aren't already in the channels list const channelIds = new Set(channels.map((c) => c.id)); - const subscriberChannelIds = [...new Set(subscriptionRows.map((r) => r.subscriberChannelId).filter((id) => !channelIds.has(id)))]; + const subscriberChannelIds = [ + ...new Set( + subscriptionRows + .map((r) => r.subscriberChannelId) + .filter((id) => !channelIds.has(id)), + ), + ]; let subscriberChannels: StoreChannel[] = []; if (subscriberChannelIds.length > 0) { - const subRows = await this.db.select().from(schema.channels).where(inArray(schema.channels.id, subscriberChannelIds)); + const subRows = await this.db + .select() + .from(schema.channels) + .where(inArray(schema.channels.id, subscriberChannelIds)); subscriberChannels = subRows.map((r) => this.rowToChannel(r)); } diff --git a/src/store/store.ts b/src/store/store.ts index 6720c13..2ed9c97 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -49,14 +49,33 @@ export interface Store { listEnabledChannels(): Promise; upsertMessage(msg: StoreMessage): Promise; deleteMessage(channelId: string, slackTs: string): Promise; - getMessages(channelId: string, limit?: number, offset?: number): Promise; - addSubscription(subscriberChannelId: string, sourceChannelId: string): Promise; - removeSubscription(subscriberChannelId: string, sourceChannelId: string): Promise; + getMessages( + channelId: string, + limit?: number, + offset?: number, + ): Promise; + addSubscription( + subscriberChannelId: string, + sourceChannelId: string, + ): Promise; + removeSubscription( + subscriberChannelId: string, + sourceChannelId: string, + ): Promise; getSubscribersBySource(sourceChannelId: string): Promise; - getSubscriptionsBySubscriber(subscriberChannelId: string): Promise; + getSubscriptionsBySubscriber( + subscriberChannelId: string, + ): Promise; getRecentMessages(channelId: string, since: Date): Promise; addBotAction(action: StoreBotAction): Promise; - getBotActionsBySource(sourceChannelId: string, sourceMessageTs: string): Promise; - getGraphData(): Promise<{ channels: StoreChannel[]; subscriptions: StoreSubscription[]; subscriberChannels: StoreChannel[] }>; + getBotActionsBySource( + sourceChannelId: string, + sourceMessageTs: string, + ): Promise; + getGraphData(): Promise<{ + channels: StoreChannel[]; + subscriptions: StoreSubscription[]; + subscriberChannels: StoreChannel[]; + }>; close(): void; } diff --git a/src/web/app.tsx b/src/web/app.tsx index 76228e9..e609393 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -13,14 +13,18 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import "./lockdown"; -import { renderNavbar } from "./navbar" +import { renderNavbar } from "./navbar"; const TEAM_ID = "T024F7C8B"; const slackLink = (channelId: string) => `https://hackclub.enterprise.slack.com/archives/${channelId}`; const parseApprovedPosters = (raw: string): string[] => { if (!raw) return []; - try { return JSON.parse(raw); } catch { return []; } + try { + return JSON.parse(raw); + } catch { + return []; + } }; const accessLabel = (raw: string): string => { const p = parseApprovedPosters(raw); @@ -28,47 +32,92 @@ const accessLabel = (raw: string): string => { return p.includes("poster") ? "poster + managers + op" : p.join(", "); }; const fmtDate = (s: string) => { - try { return new Date(s).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } - catch { return s; } + try { + return new Date(s).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return s; + } }; const highlightJson = (json: string): string => json.replace( /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (m) => { - if (/^"/.test(m)) return /:$/.test(m) - ? `${m}` - : `${m}`; + if (/^"/.test(m)) + return /:$/.test(m) + ? `${m}` + : `${m}`; if (/true|false/.test(m)) return `${m}`; return `${m}`; - } + }, ); -renderNavbar("map") +renderNavbar("map"); -interface GraphNode { id: string; type: string; label: string; data: Record; } -interface GraphEdge { id: string; source: string; target: string; label?: string; animated: boolean; } +interface GraphNode { + id: string; + type: string; + label: string; + data: Record; +} +interface GraphEdge { + id: string; + source: string; + target: string; + label?: string; + animated: boolean; +} function ChannelNode({ data }: { data: any }) { const posters = parseApprovedPosters(data.approvedPosters || ""); let schema: any = null; - try { schema = data.metadataSchema ? JSON.parse(data.metadataSchema) : null; } catch {} + try { + schema = data.metadataSchema ? JSON.parse(data.metadataSchema) : null; + } catch {} return (
- {data.name ? `#${data.name}` : data.channelId} - open ↗ + + {data.name ? `#${data.name}` : data.channelId} + + + open ↗ +
enabled: {data.enabled ? "yes" : "no"}
-
access: {accessLabel(data.approvedPosters)}
- {posters.length > 0 &&
approved: {posters.join(", ")}
} - {data.createdAt &&
created: {fmtDate(data.createdAt)}
} - {data.linkMode &&
link mode: {data.linkMode ? "yes" : "no"}
} +
+ access: {accessLabel(data.approvedPosters)} +
+ {posters.length > 0 && ( +
approved: {posters.join(", ")}
+ )} + {data.createdAt && ( +
created: {fmtDate(data.createdAt)}
+ )} + {data.linkMode && ( +
+ link mode: {data.linkMode ? "yes" : "no"} +
+ )} {schema && (
metadata schema -
+            
           
)}
@@ -77,12 +126,18 @@ function ChannelNode({ data }: { data: any }) { } function FeedNode({ data }: { data: any }) { - const labels: Record = { rss: "rss", api: "api", webhook: "webhook" }; + const labels: Record = { + rss: "rss", + api: "api", + webhook: "webhook", + }; const linkFor = (): string | null => { const channelId = data.channelId; if (!channelId) return null; - if (data.feedType === "rss") return `/feed/${encodeURIComponent(channelId)}`; - if (data.feedType === "api") return `/api/messages?channel=${encodeURIComponent(channelId)}`; + if (data.feedType === "rss") + return `/feed/${encodeURIComponent(channelId)}`; + if (data.feedType === "api") + return `/api/messages?channel=${encodeURIComponent(channelId)}`; if (data.feedType === "webhook") return data.url || null; return null; }; @@ -92,7 +147,12 @@ function FeedNode({ data }: { data: any }) { [{labels[data.feedType] || "feed"}] {href && ( - + open ↗ )} @@ -115,12 +175,25 @@ function SubscriberNode({ data }: { data: any }) {
- {data.name ? `#${data.name}` : data.channelId} - open ↗ + + {data.name ? `#${data.name}` : data.channelId} + + + open ↗ +
-
access: {accessLabel(data.approvedPosters)}
- {data.createdAt &&
created: {fmtDate(data.createdAt)}
} +
+ access: {accessLabel(data.approvedPosters)} +
+ {data.createdAt && ( +
created: {fmtDate(data.createdAt)}
+ )}
); @@ -133,7 +206,10 @@ const nodeTypes = { subscriber: SubscriberNode, }; -function layoutGraph(rawNodes: GraphNode[], rawEdges: GraphEdge[]): { nodes: Node[]; edges: Edge[] } { +function layoutGraph( + rawNodes: GraphNode[], + rawEdges: GraphEdge[], +): { nodes: Node[]; edges: Edge[] } { const channels = rawNodes.filter((n) => n.type === "channel"); const feeds = rawNodes.filter((n) => n.type === "feed"); const subscribers = rawNodes.filter((n) => n.type === "subscriber"); @@ -150,18 +226,49 @@ function layoutGraph(rawNodes: GraphNode[], rawEdges: GraphEdge[]): { nodes: Nod channels.forEach((ch) => { const chFeeds = feeds.filter((f) => f.data.channelId === ch.data.channelId); const chSubs = subEdges.filter((e) => e.source === ch.id); - nodes.push({ id: ch.id, type: "channel", position: { x: colX[0], y: cy }, data: ch.data }); + nodes.push({ + id: ch.id, + type: "channel", + position: { x: colX[0], y: cy }, + data: ch.data, + }); let ry = cy; chFeeds.forEach((f) => { - nodes.push({ id: f.id, type: "feed", position: { x: colX[1], y: ry }, data: f.data }); - edges.push({ id: `e-${ch.id}-${f.id}`, source: ch.id, target: f.id, type: "default" }); + nodes.push({ + id: f.id, + type: "feed", + position: { x: colX[1], y: ry }, + data: f.data, + }); + edges.push({ + id: `e-${ch.id}-${f.id}`, + source: ch.id, + target: f.id, + type: "default", + }); ry += rowH + gap; }); chSubs.forEach((edge) => { const subNodeId = `subnode:${edge.source}:${edge.target}`; - nodes.push({ id: subNodeId, type: "subscription", position: { x: colX[1], y: ry }, data: {} }); - edges.push({ id: `e-ch-${edge.source}-${edge.target}`, source: ch.id, target: subNodeId, type: "default" }); - edges.push({ id: `e-pub-${edge.source}-${edge.target}`, source: subNodeId, target: edge.target, label: "[sub]", type: "default" }); + nodes.push({ + id: subNodeId, + type: "subscription", + position: { x: colX[1], y: ry }, + data: {}, + }); + edges.push({ + id: `e-ch-${edge.source}-${edge.target}`, + source: ch.id, + target: subNodeId, + type: "default", + }); + edges.push({ + id: `e-pub-${edge.source}-${edge.target}`, + source: subNodeId, + target: edge.target, + label: "[sub]", + type: "default", + }); ry += rowH + gap; }); cy = Math.max(cy + 56, ry) + gap; @@ -169,7 +276,12 @@ function layoutGraph(rawNodes: GraphNode[], rawEdges: GraphEdge[]): { nodes: Nod let sy = 30; subscribers.forEach((sub) => { - nodes.push({ id: sub.id, type: "subscriber", position: { x: colX[2], y: sy }, data: sub.data }); + nodes.push({ + id: sub.id, + type: "subscriber", + position: { x: colX[2], y: sy }, + data: sub.data, + }); sy += rowH + chSubGap; }); @@ -184,30 +296,59 @@ function App() { useEffect(() => { fetch("/api/graph") - .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) .then((res) => { - const { nodes: n, edges: e } = layoutGraph(res.data?.nodes || [], res.data?.edges || []); + const { nodes: n, edges: e } = layoutGraph( + res.data?.nodes || [], + res.data?.edges || [], + ); setNodes(n); setEdges(e); setLoading(false); }) - .catch((err) => { setError(err.message); setLoading(false); }); + .catch((err) => { + setError(err.message); + setLoading(false); + }); }, [setNodes, setEdges]); - if (loading) return
loading...
; - if (error) return

{error}

; - if (nodes.length === 0) return ( -
-

feed map

-

no published channels. /in pub to start.

-
- ); + if (loading) + return ( +
+
loading...
+
+ ); + if (error) + return ( +
+
+

{error}

+
+
+ ); + if (nodes.length === 0) + return ( +
+
+

feed map

+
+
+

no published channels. /in pub to start.

+
+
+ ); return (

feed map

-

node key: #channel → [rss | api | webhook | pub] (if pub: → [sub] → #channel)

+

+ node key: #channel → [rss | api | webhook | pub] (if pub: → [sub] → + #channel) +

{ setChannels(Array.isArray(data) ? data : []); setChannelsPending(false) }) + .then((data) => { + setChannels(Array.isArray(data) ? data : []); + setChannelsPending(false); + }) + .catch((e) => setError(String(e))); + fetch("api/api-keys") + .then((r) => r.json()) + .then((data) => { + setKeys(Array.isArray(data) ? data : []); + setKeysPending(false); + }) .catch((e) => setError(String(e))); - fetch('api/api-keys').then((r) => r.json()).then((data) => { setKeys(Array.isArray(data) ? data : []); setKeysPending(false) }).catch((e) => setError(String(e))); } }, [session?.user?.slackId]); @@ -66,30 +75,43 @@ function KeysList() { return (
- {error &&

Error: {error}

} + {error &&

Error: {error}

} {createdKey && ( -
+

Key created!

Copy this key now — it won't be shown again:

- {createdKey} + + {createdKey} +
)}

make a new api key

- + - +
@@ -97,22 +119,31 @@ function KeysList() { {keysPending ? "loading keys..." : null} {keys.map((k) => (
- {k.name} ind_{k.keyPrefix}... {k.revokedBy && `Revoked by ${k.revokedBy}`} - {(k.channels != null && k.channels.length > 0) ? ( + {k.name} ind_{k.keyPrefix}...{" "} + {k.revokedBy && `Revoked by ${k.revokedBy}`}{" "} + + {k.channels != null && k.channels.length > 0 ? ( <> scoped channels: ({k.channels.length})
    {k.channels.map((c) => ( -
  • {c}
  • - ) - - )} + ))}
) : null} @@ -128,5 +159,5 @@ createRoot(document.getElementById("app")!).render(

integrate with indigest

for now, all keys are read-only

-
+
, ); diff --git a/src/web/lockdown.ts b/src/web/lockdown.ts index 954a596..0892d50 100644 --- a/src/web/lockdown.ts +++ b/src/web/lockdown.ts @@ -11,7 +11,8 @@ function renderLockdownBanner(users: LockdownUser[]) { banner.className = "lockdown-banner"; const message = document.createElement("span"); - message.textContent = "🔒 lockdown mode enabled: contact an authorized user ("; + message.textContent = + "🔒 lockdown mode enabled: contact an authorized user ("; banner.appendChild(message); users.forEach((user, index) => { @@ -22,7 +23,8 @@ function renderLockdownBanner(users: LockdownUser[]) { link.textContent = `@${user.name || user.id}`; banner.appendChild(link); - if (index < users.length - 1) banner.appendChild(document.createTextNode(", ")); + if (index < users.length - 1) + banner.appendChild(document.createTextNode(", ")); }); banner.appendChild(document.createTextNode(") to configure the bot")); @@ -30,7 +32,9 @@ function renderLockdownBanner(users: LockdownUser[]) { } fetch("/site-config") - .then((response): Promise<{ lockdownUsers: LockdownUser[] }> | null => response.ok ? response.json() : null) + .then((response): Promise<{ lockdownUsers: LockdownUser[] }> | null => + response.ok ? response.json() : null, + ) .then((config) => { const users = config?.lockdownUsers || []; if (users.length === 0 || !document.body) return; diff --git a/src/web/navbar.tsx b/src/web/navbar.tsx index 2e1eebd..36061f7 100644 --- a/src/web/navbar.tsx +++ b/src/web/navbar.tsx @@ -27,23 +27,36 @@ function Navbar({ active }: { active: string }) { return (
- ) +
, +); diff --git a/src/worker.ts b/src/worker.ts index 57940a2..048fc00 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,7 +1,11 @@ import app from "./app"; export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { return app.fetch(request, env, ctx); }, } satisfies ExportedHandler; diff --git a/tsconfig.json b/tsconfig.json index 89b51a6..5345d91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - "types": ["@cloudflare/workers-types", "bun" ], + "types": ["@cloudflare/workers-types", "bun"], // Bundler mode "moduleResolution": "bundler", diff --git a/usage.html b/usage.html index 663543b..62ac210 100644 --- a/usage.html +++ b/usage.html @@ -1,19 +1,25 @@ - + - - - - + + + - - indigest - slack bot usage - - - - -
- - - + + indigest - slack bot usage + + + + +
+ + + diff --git a/vite.config.ts b/vite.config.ts index 5232128..17c7938 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ main: "index.html", docs: "docs.html", usage: "usage.html", - keys: "keys.html" + keys: "keys.html", }, }, }, diff --git a/wrangler.jsonc b/wrangler.jsonc index 1697016..af9454b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,51 +1,49 @@ { - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "indigest", - "main": "src/worker.ts", - "compatibility_date": "2025-07-21", - "compatibility_flags": [ - "nodejs_compat" - ], - "observability": { - "enabled": true - }, - "routes": [ - { - "pattern": "indigest.matmanna.dev/*", - "zone_name": "matmanna.dev" - }, - { - "pattern": "*-indigest.matmanna.dev/*", - "zone_name": "matmanna.dev" - }, - { - "pattern": "indigest.matmanna.dev", - "zone_name": "matmanna.dev", - "custom_domain": true - } - ], - "assets": { - "directory": "./dist", - "not_found_handling": "none", - "run_worker_first": [ - "/api/*", - "/feed/*", - "/events", - "/interactions", - "/slack", - "/slack/", - "/spec.json" - ] - }, - "hyperdrive": [ - { - "binding": "HYPERDRIVE_BINDING", - "id": "af08c1f1e52543949fae23f29a7f939b" - } - ], - "vars": { - "API_USERNAME": "admin", - "LOCKDOWN_USERS": "U07VA44DNBA,U09Q8MLTE58,U07EB2Y76DP", - "SLACK_CLIENT_ID": "2210535565.11557816933424", - } + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "indigest", + "main": "src/worker.ts", + "compatibility_date": "2025-07-21", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true, + }, + "routes": [ + { + "pattern": "indigest.matmanna.dev/*", + "zone_name": "matmanna.dev", + }, + { + "pattern": "*-indigest.matmanna.dev/*", + "zone_name": "matmanna.dev", + }, + { + "pattern": "indigest.matmanna.dev", + "zone_name": "matmanna.dev", + "custom_domain": true, + }, + ], + "assets": { + "directory": "./dist", + "not_found_handling": "none", + "run_worker_first": [ + "/api/*", + "/feed/*", + "/events", + "/interactions", + "/slack", + "/slack/", + "/spec.json", + ], + }, + "hyperdrive": [ + { + "binding": "HYPERDRIVE_BINDING", + "id": "af08c1f1e52543949fae23f29a7f939b", + }, + ], + "vars": { + "API_USERNAME": "admin", + "LOCKDOWN_USERS": "U07VA44DNBA,U09Q8MLTE58,U07EB2Y76DP", + "SLACK_CLIENT_ID": "2210535565.11557816933424", + }, } -- 2.51.2