diff --git a/.env.example b/.env.example index 72dc71e..bca8176 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,10 @@ API_USERNAME=admin API_PASSWORD=your-api-password # Lockdown (optional — comma-separated Slack user IDs allowed to run commands) -# When set, only these users can use /indigestion (status is still public) LOCKDOWN_USERS=U07VA44DNBA,U09Q8MLTE58 +# Hack Club CDN (optional — upload file_input attachments to Hack Club CDN) +HACK_CLUB_CDN_KEY=sk_cdn_your_key_here + # Cloudflare Tunnel (optional) TUNNEL_TOKEN=your-cloudflare-tunnel-token diff --git a/README.md b/README.md index 664ecb5..17e72d9 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,17 @@ DATABASE_URL=postgres://... docker compose up -d ``` +after which db commands can be run with: + +```bash +docker compose run app bun run db:studio +``` + ## Commands | Command | Description | |---------|-------------| +| `/indigestion` | Show help message with all commands | | `/indigestion enable` | Enable manual mode (buttons on every message) | | `/indigestion enable manual` | Same as above | | `/indigestion enable auto @user` | Enable auto-approve for user, silently syncs their messages | @@ -70,9 +77,51 @@ docker compose up -d | `/indigestion enable #channel ...` | Run against another channel from anywhere | | `/indigestion disable` | Disable indigestion | | `/indigestion disable auto [@user]` | Remove auto-approve | -| `/indigestion status` | Check status, get feed URLs | +| `/indigestion status` | Check status, get feed URLs, see your permissions | | `/indigestion auto list` | List auto-approve users | | `/indigestion webhook ` | Set webhook for approved messages | +| `/indigestion webhook clear` | Remove webhook | +| `/indigestion schema set ` | Set metadata schema (form fields in modal on Yep) | +| `/indigestion schema get` | View current metadata schema | +| `/indigestion schema clear` | Remove metadata schema | + +## Permissions + +When you run `/indigestion status`, the bot shows your permissions for that channel: + +- **channel creator** — you created the channel, you can run all commands +- **lockdown override** — you're in the `LOCKDOWN_USERS` list, you can run all commands even without being channel creator +- **none** — you can only view feed URLs + +`/indigestion` alone (no subcommand) shows the full help message. + +## Metadata Schema + +Channel creators and lockdown users can define a metadata form that opens in a **modal** when someone clicks **Yep!**. Set one with: + +``` +/indigestion schema set {"title":"Message Metadata","fields":[ + {"action_id":"title","label":"Title","type":"plain_text_input","placeholder":"Enter a title"}, + {"action_id":"description","label":"Description","type":"plain_text_input","multiline":true}, + {"action_id":"priority","label":"Priority","type":"static_select","options":[{"label":"Low","value":"low"},{"label":"High","value":"high"}]}, + {"action_id":"due_date","label":"Due Date","type":"datepicker"} +]} +``` + +**Supported field types:** + +| Type | BlockKit Element | Notes | +|------|------------------|-------| +| `plain_text_input` | `plain_text_input` | Supports `multiline`, `min_length`, `max_length`, `placeholder`, `initial_value` | +| `url_text_input` | `url_text_input` | URL validation built in | +| `email_text_input` | `email_text_input` | Email validation built in | +| `number_input` | `number_input` | Integer only (`is_decimal_allowed: false`) | +| `static_select` | `static_select` | Single select, requires `options` array with `{label, value}` | +| `multi_static_select` | `multi_static_select` | Multi-select, requires `options` array | +| `datepicker` | `datepicker` | Date picker, supports `initial_value` as date string | +| `file_input` | `file_input` | File upload (requires Slack app config) | + +The submitted metadata is stored as JSON in the `metadata` column, included in webhook payloads, and returned in the JSON API. ## Webhooks @@ -87,11 +136,120 @@ When approved, fires `POST` to the webhook URL: "user_id": "U456", "user_name": "alice", "text": "the message text", - "timestamp": "2025-01-01T00:00:00.000Z" + "timestamp": "2025-01-01T00:00:00.000Z", + "metadata": {"title":"My Title","priority":"high"} + } +} +``` + +## Lockdown Mode + +Set `LOCKDOWN_USERS` env var to a comma-separated list of Slack user IDs. When set, only those users can run write commands (`enable`, `disable`, `webhook`, `schema`, `auto`). Everyone else only sees `status` and feed URLs. + +``` +LOCKDOWN_USERS=U07VA44DNBA,U09Q8MLTE58 +``` + +## REST API + +All endpoints require **Basic Auth** using the `API_USERNAME` / `API_PASSWORD` env vars. If `API_PASSWORD` is empty, auth is disabled. + +### List Messages + +``` +GET /api/messages?channel=&limit=50&page=1&after=2025-01-01T00:00:00Z&before=2026-01-01T00:00:00Z&user_id=U12345 +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `channel` | string | ✅ | Slack channel ID (e.g. `C0ACWHCA16F`) | +| `limit` | number | ❌ | Results per page (1-10000, default 50) | +| `page` | number | ❌ | Page number (default 1) | +| `after` | string | ❌ | ISO 8601 timestamp — only messages after this time | +| `before` | string | ❌ | ISO 8601 timestamp — only messages before this time | +| `user_id` | string | ❌ | Filter by Slack user ID | + +**Response:** +```json +{ + "data": [ + { + "id": 3, + "slack_ts": "1783464404.108959", + "channel_id": "C0ACWHCA16F", + "user_id": "U07VA44DNBA", + "user_name": "mat", + "text": "hello world", + "timestamp": "2026-07-07T22:46:44.000Z" + } + ], + "pagination": { + "page": 1, + "limit": 50, + "total": 1, + "total_pages": 1 } } ``` +### Get Single Message + +``` +GET /api/messages/{slack_ts}?channel= +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `slack_ts` | string | ✅ | Slack message timestamp (in path) | +| `channel` | string | ✅ | Slack channel ID (query param) | + +**Response:** +```json +{ + "data": { + "id": 3, + "slack_ts": "1783464404.108959", + "channel_id": "C0ACWHCA16F", + "user_id": "U07VA44DNBA", + "user_name": "mat", + "text": "hello world", + "timestamp": "2026-07-07T22:46:44.000Z" + } +} +``` + +### cURL Examples + +```bash +# List messages with auth +curl -u admin:your-password 'https://your-host/api/messages?channel=C0ACWHCA16F&limit=10&page=1' + +# Filter by time range +curl -u admin:your-password 'https://your-host/api/messages?channel=C0ACWHCA16F&after=2025-06-01T00:00:00Z&before=2025-07-01T00:00:00Z' + +# Filter by user +curl -u admin:your-password 'https://your-host/api/messages?channel=C0ACWHCA16F&user_id=U07VA44DNBA' + +# Get a specific message +curl -u admin:your-password 'https://your-host/api/messages/1783464404.108959?channel=C0ACWHCA16F' +``` + +### Postman Setup + +1. Create a new request +2. Set **Method** to `GET` +3. Enter URL: `https://slackfeed.matmanna.dev/api/messages?channel=C0ACWHCA16F` +4. Go to **Authorization** tab → select **Basic Auth** → enter username/password +5. Send — if you get `401`, check your `API_USERNAME`/`API_PASSWORD` env vars + +Or import this cURL into Postman: +```bash +curl --location 'https://slackfeed.matmanna.dev/api/messages?channel=C0ACWHCA16F&limit=10' \ +--header 'Authorization: Basic YWRtaW46eW91ci1wYXNzd29yZA==' +``` + +(Replace the Basic token with your own base64-encoded `username:password`.) + ## Feeds - **RSS**: `https://your-host/feed/{channel_id}` diff --git a/compose.yml b/compose.yml index 0454f94..ef88339 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,10 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} - SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET} + - LOCKDOWN_USERS=${LOCKDOWN_USERS} + - HACK_CLUB_CDN_KEY=${HACK_CLUB_CDN_KEY} + - API_USERNAME=${API_USERNAME} + - API_PASSWORD=${API_PASSWORD} - DATABASE_URL=postgres://indigestion:indigestion@db:5432/indigestion depends_on: db: diff --git a/src/api/modal.ts b/src/api/modal.ts new file mode 100644 index 0000000..cf0fea6 --- /dev/null +++ b/src/api/modal.ts @@ -0,0 +1,129 @@ +import type { WebClient } from "@slack/web-api"; + +interface SchemaField { + action_id: string; + label: string; + type: string; // "plain_text_input" | "file_input" | "multi_static_select" | "static_select" | "datepicker" | "url_text_input" | "email_text_input" | "number_input" + placeholder?: string; + initial_value?: string; + multiline?: boolean; + min_length?: number; + max_length?: number; + options?: { label: string; value: string }[]; +} + +export interface MetadataSchema { + title: string; + submit_label?: string; + fields: SchemaField[]; +} + +function buildBlocks(schema: MetadataSchema): any[] { + const blocks: any[] = [ + { type: "header", text: { type: "plain_text", text: schema.title, emoji: true } }, + { type: "divider" }, + ]; + + for (const field of schema.fields) { + const element: any = { type: field.type, action_id: field.action_id }; + + switch (field.type) { + case "plain_text_input": + case "url_text_input": + case "email_text_input": + 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; + element.max_length = field.max_length; + break; + + case "number_input": + 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; + element.is_decimal_allowed = false; + break; + + 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 })); + break; + + case "datepicker": + element.placeholder = field.placeholder ? { type: "plain_text", text: field.placeholder } : undefined; + element.initial_date = field.initial_value; + break; + + case "file_input": + // file_input requires specific setup in Slack — use as-is + break; + } + + blocks.push({ + type: "input", + block_id: `field_${field.action_id}`, + label: { type: "plain_text", text: field.label, emoji: true }, + element, + }); + } + + return blocks; +} + +function extractMetadata(schema: MetadataSchema, state: Record>): Record { + const metadata: Record = {}; + for (const field of schema.fields) { + const values = state?.[`field_${field.action_id}`]?.[field.action_id]; + if (!values) continue; + + switch (field.type) { + case "multi_static_select": + metadata[field.action_id] = values.selected_options?.map((o) => o.value) || []; + break; + case "datepicker": + metadata[field.action_id] = values.selected_date || ""; + break; + case "file_input": + metadata[field.action_id] = values.files || []; + break; + default: + metadata[field.action_id] = values.value || ""; + } + } + return metadata; +} + +export async function openMetadataModal( + client: WebClient, + triggerId: string, + channelId: string, + messageTs: string, + schema: MetadataSchema, +) { + const blocks = buildBlocks(schema); + + await client.views.open({ + trigger_id: triggerId, + view: { + 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 }, + close: { type: "plain_text", text: "Cancel", emoji: true }, + private_metadata: JSON.stringify({ channelId, messageTs }), + notify_on_close: true, + blocks, + } as any, + }); +} + +export function extractMetadataFromView( + schema: MetadataSchema, + state: Record>, +): string { + const metadata = extractMetadata(schema, state); + return JSON.stringify(metadata); +} diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 3e8e991..f6ed8d0 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -14,6 +14,7 @@ export async function pushSchema(connectionString: string) { enabled INTEGER NOT NULL DEFAULT 0, webhook_url TEXT NOT NULL DEFAULT '', auto_approve_users TEXT NOT NULL DEFAULT '', + metadata_schema TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT now() ) `); @@ -26,7 +27,8 @@ export async function pushSchema(connectionString: string) { user_id TEXT NOT NULL DEFAULT '', user_name TEXT NOT NULL DEFAULT '', text TEXT NOT NULL DEFAULT '', - timestamp TEXT NOT NULL + timestamp TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}' ) `); @@ -42,6 +44,12 @@ export async function pushSchema(connectionString: string) { await db.execute(sql` ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_approve_users TEXT NOT NULL DEFAULT '' `); + await db.execute(sql` + ALTER TABLE channels ADD COLUMN IF NOT EXISTS metadata_schema TEXT NOT NULL DEFAULT '' + `); + await db.execute(sql` + ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata TEXT NOT NULL DEFAULT '' + `); await client.end(); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 2ec94df..468250e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, text, integer, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { pgTable, text, integer, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core"; export const channels = pgTable("channels", { id: text("id").primaryKey(), @@ -7,6 +7,7 @@ export const channels = pgTable("channels", { enabled: integer("enabled").notNull().default(0), webhookUrl: text("webhook_url").notNull().default(""), autoApproveUsers: text("auto_approve_users").notNull().default(""), + metadataSchema: text("metadata_schema").notNull().default(""), createdAt: text("created_at").notNull().default("now()"), }); @@ -20,6 +21,7 @@ export const messages = pgTable( userName: text("user_name").notNull().default(""), text: text("text").notNull().default(""), timestamp: text("timestamp").notNull(), + metadata: jsonb("metadata").notNull().default("{}"), }, (t) => ({ uniqueMsg: uniqueIndex("uq_messages_channel_ts").on(t.channelId, t.slackTs), diff --git a/src/index.ts b/src/index.ts index 3d99809..99a40c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,10 @@ const DATABASE_URL = process.env.DATABASE_URL || ""; const API_USERNAME = process.env.API_USERNAME || "admin"; const API_PASSWORD = process.env.API_PASSWORD || ""; const LOCKDOWN_USERS = (process.env.LOCKDOWN_USERS || "").split(",").map((s) => s.trim()).filter(Boolean); +if (LOCKDOWN_USERS.length > 0) { + console.log(`lockdown active for users: [${LOCKDOWN_USERS.join(", ")}]`); +} +const HACK_CLUB_CDN_KEY = process.env.HACK_CLUB_CDN_KEY || ""; if (!SLACK_BOT_TOKEN || !SLACK_SIGNING_SECRET) { console.error("Missing SLACK_BOT_TOKEN or SLACK_SIGNING_SECRET"); @@ -123,12 +127,43 @@ async function fireWebhook(ch: StoreChannel, msg: StoreMessage) { user_name: msg.userName, text: msg.text, timestamp: msg.timestamp, + metadata: msg.metadata ? JSON.parse(msg.metadata) : undefined, }, }), }); } catch {} } +// --- Upload file from Slack to Hack Club CDN --- + +async function uploadToCDN(slackUrl: string): Promise { + if (!HACK_CLUB_CDN_KEY) return slackUrl; + + // Download from Slack using bot token + const resp = await fetch(slackUrl, { + headers: { Authorization: `Bearer ${SLACK_BOT_TOKEN}` }, + }); + if (!resp.ok) return slackUrl; + + const blob = await resp.blob(); + const formData = new FormData(); + formData.append("file", blob, "upload"); + + const cdnResp = await fetch("https://cdn.hackclub.com/api/v4/upload", { + method: "POST", + headers: { Authorization: `Bearer ${HACK_CLUB_CDN_KEY}` }, + body: formData, + }); + if (!cdnResp.ok) { + const err = await cdnResp.text(); + console.error("CDN upload failed:", err); + return slackUrl; + } + + const data = (await cdnResp.json()) as any; + return data.url || slackUrl; +} + // --- Event handlers --- async function handleMemberJoined(channelId: string, userId: string) { @@ -143,6 +178,7 @@ async function handleMemberJoined(channelId: string, userId: string) { enabled: false, webhookUrl: "", autoApproveUsers: [], + metadataSchema: "", createdAt: "", }; await store.upsertChannel(ch); @@ -174,15 +210,16 @@ async function handleMessage(channelId: string, userId: string, text: string, ts userName, text, timestamp: slackTsToTime(ts).toISOString(), + metadata: {}, }); - await fireWebhook(ch, { slackTs: ts, channelId, userId, userName, text, timestamp: slackTsToTime(ts).toISOString() }); + await fireWebhook(ch, { slackTs: ts, channelId, userId, userName, text, timestamp: slackTsToTime(ts).toISOString(), metadata: {} }); return; } // Manual mode: show Yep!/No buttons for every message const section = { type: "section", - text: { type: "mrkdwn", text: `Add this to the SlackFeed? 👇\n>${text}` }, + text: { type: "mrkdwn", text: `Expose this message to indigestion via RSS and API?\n>${text}\n` }, accessory: { type: "button", action_id: "slackfeed_yes", @@ -253,7 +290,7 @@ const server = Bun.serve({ return new Response("ok", { status: 200 }); } - // === Interactive Components (button clicks) === + // === Interactive Components (button clicks + view submissions) === if (path === "/interactions" && req.method === "POST") { const body = await readBody(req); @@ -262,6 +299,66 @@ const server = Bun.serve({ if (!payloadStr) return jsonResponse({ error: "missing payload" }, 400); const cb = JSON.parse(payloadStr); + + // Handle view submission (modal submit) + if (cb.type === "view_submission" && cb.view?.callback_id === "metadata_modal") { + const privateMeta = JSON.parse(cb.view.private_metadata || "{}"); + const { channelId, messageTs } = privateMeta; + const channel = await store.getChannel(channelId); + if (!channel || !channel.enabled) { + return jsonResponse({}); + } + + 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); + return { ...f, cdn_url: cdnUrl }; + }), + ); + } + else m[field.action_id] = values.value || ""; + } + return JSON.stringify(m); + })() : ""; + + const client = new WebClient(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 jsonResponse({}); + + 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, + }); + + await fireWebhook(channel, { slackTs: messageTs, channelId, userId: msg.user || "", userName, text: msg.text || "", timestamp: slackTsToTime(messageTs).toISOString(), metadata }); + } catch {} + return jsonResponse({}); + } + + // Handle block actions (button clicks) if (cb.type !== "block_actions") return jsonResponse({}); const action = cb.actions?.[0]; @@ -270,6 +367,7 @@ const server = Bun.serve({ const channelId = cb.channel?.id; const messageTs = action.value; const responseUrl = cb.response_url; + const triggerId = cb.trigger_id; const ch = await store.getChannel(channelId); if (!ch || !ch.enabled) { @@ -278,6 +376,24 @@ const server = Bun.serve({ } if (action.action_id === "slackfeed_yes") { + // If channel has a metadata schema, open modal instead of direct save + 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, ch.metadataSchema); + await slackResponse(responseUrl, ""); + return jsonResponse({}); + } catch (err: any) { + await slackResponse(responseUrl, `Error opening form: ${err.message}`); + return jsonResponse({}); + } + } + } + + // No schema — direct save (existing logic) try { const history = await slack.conversations.history({ channel: channelId, @@ -292,10 +408,7 @@ const server = Bun.serve({ } let userName = msg.user || ""; - try { - const u = await slack.users.info({ user: msg.user }); - userName = (u.user as any)?.name || userName; - } catch {} + try { const u = await slack.users.info({ user: msg.user }); userName = (u.user as any)?.name || userName; } catch {} await store.upsertMessage({ slackTs: messageTs, @@ -304,9 +417,10 @@ const server = Bun.serve({ 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() }; + const savedMsg = { slackTs: messageTs, channelId, userId: msg.user || "", userName, text: msg.text || "", timestamp: slackTsToTime(messageTs).toISOString(), metadata: {} }; await fireWebhook(ch, savedMsg); await slackResponse(responseUrl, "✅ Message added to the SlackFeed!"); @@ -372,7 +486,7 @@ const server = Bun.serve({ name = (conv.channel as any)?.name || targetChannelId; } catch {} const auth = await slack.auth.test(); - ch = { id: targetChannelId, name, teamId: auth.team_id || "", enabled: false, webhookUrl: "", autoApproveUsers: [], createdAt: "" }; + ch = { id: targetChannelId, name, teamId: auth.team_id || "", enabled: false, webhookUrl: "", autoApproveUsers: [], metadataSchema: "", createdAt: "" }; await store.upsertChannel(ch); } @@ -385,7 +499,8 @@ const server = Bun.serve({ const targetManager = () => isChannelManager(targetChannelId, userId); // --- Lockdown: restrict write commands to approved users --- - if (LOCKDOWN_USERS.length > 0 && !LOCKDOWN_USERS.includes(userId) && cmd !== "status") { + // Blank command (help) and status are always public + if (LOCKDOWN_USERS.length > 0 && !LOCKDOWN_USERS.includes(userId) && cmd !== "status" && cmd !== "") { return jsonResponse({ response_type: "ephemeral", text: "🔒 SlackFeed is in lockdown mode. Only authorized users can run commands.", @@ -486,22 +601,40 @@ const server = Bun.serve({ case "status": { const chName = targetChannelId === sourceChannelId ? "" : `#${ch.name} `; + + // Build permissions info + 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 (LOCKDOWN_USERS.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 = `✅ SlackFeed enabled for ${chName}\nRSS: ${BASE_URL}/feed/${targetChannelId}\nJSON: ${BASE_URL}/feed/${targetChannelId}.json`; if (ch.webhookUrl) msg += `\nWebhook: ${ch.webhookUrl}`; if (ch.autoApproveUsers.length > 0) { msg += `\nAuto-approve: ${ch.autoApproveUsers.map((id) => `<@${id}>`).join(", ")}`; } + if (ch.metadataSchema) { + try { + const s = JSON.parse(ch.metadataSchema); + msg += `\nMetadata schema: ${s.fields?.length || 0} field(s)`; + } catch {} + } + msg += permStr; return jsonResponse({ response_type: "ephemeral", text: msg }); } - return jsonResponse({ response_type: "ephemeral", text: `SlackFeed is not enabled for ${chName}Run \`/slackfeed enable\` to start.` }); + return jsonResponse({ response_type: "ephemeral", text: `SlackFeed is not enabled for ${chName}Run \`/slackfeed enable\` to start.${permStr}` }); } default: { if (!cmd) { return jsonResponse({ response_type: "ephemeral", - text: "Commands: \`enable [#channel]\` | \`disable [#channel]\` | \`status [#channel]\` | \`enable [#channel] auto @user\` | \`enable [#channel] manual\` | \`disable [#channel] auto [@user]\` | \`auto list [#channel]\` | \`webhook \`", + text: "Commands: \`enable [#channel]\` | \`disable [#channel]\` | \`status [#channel]\` | \`enable [#channel] auto @user\` | \`enable [#channel] manual\` | \`disable [#channel] auto [@user]\` | \`auto list [#channel]\` | \`webhook \` | \`schema set \` | \`schema get\` | \`schema clear\`", }); } @@ -536,9 +669,53 @@ const server = Bun.serve({ return jsonResponse({ response_type: "ephemeral", text: `Auto-approve in #${ch.name}: ${users}` }); } + if (cmd === "schema") { + // /slackfeed [#channel] schema set | schema get | schema clear + + if (subcmd === "get") { + if (!ch.metadataSchema) { + return jsonResponse({ response_type: "ephemeral", text: "No metadata schema configured for this channel." }); + } + try { + const pretty = JSON.stringify(JSON.parse(ch.metadataSchema), null, 2); + return jsonResponse({ response_type: "ephemeral", text: `\`\`\`${pretty}\`\`\`` }); + } catch { + return jsonResponse({ response_type: "ephemeral", text: `Raw schema:\n${ch.metadataSchema}` }); + } + } + + if (subcmd === "clear") { + ch.metadataSchema = ""; + await store.upsertChannel(ch); + return jsonResponse({ response_type: "ephemeral", text: "Metadata schema cleared." }); + } + + if (subcmd === "set") { + if (!arg) { + return jsonResponse({ response_type: "ephemeral", text: "Usage: \`/indigestion schema set \` — provide a valid metadata schema JSON." }); + } + try { + const parsed = JSON.parse(arg); + if (!parsed.fields || !Array.isArray(parsed.fields)) { + return jsonResponse({ response_type: "ephemeral", text: "Schema must have a \`fields\` array. Example: \`{\"title\": \"Metadata\", \"fields\": [{\"action_id\": \"title\", \"label\": \"Title\", \"type\": \"plain_text_input\"}]}\`" }); + } + ch.metadataSchema = JSON.stringify(parsed); + await store.upsertChannel(ch); + return jsonResponse({ response_type: "ephemeral", text: `✅ Metadata schema set with ${parsed.fields.length} field(s).` }); + } catch (e: any) { + return jsonResponse({ response_type: "ephemeral", text: `Invalid JSON: ${e.message}` }); + } + } + + return jsonResponse({ + response_type: "ephemeral", + text: "Usage: \`/indigestion schema set \` | \`/indigestion schema get\` | \`/indigestion schema clear\`", + }); + } + return jsonResponse({ response_type: "ephemeral", - text: "Commands: \`enable [#channel]\` | \`disable [#channel]\` | \`status [#channel]\` | \`enable [#channel] auto @user\` | \`enable [#channel] manual\` | \`disable [#channel] auto @user\` | \`auto list [#channel]\` | \`webhook \`", + text: "Commands: \`enable [#channel]\` | \`disable [#channel]\` | \`status [#channel]\` | \`enable [#channel] auto @user\` | \`enable [#channel] manual\` | \`disable [#channel] auto @user\` | \`auto list [#channel]\` | \`webhook \` | \`schema set \` | \`schema get\` | \`schema clear\`", }); } } diff --git a/src/store/pg.test.ts b/src/store/pg.test.ts index 679053f..9756a61 100644 --- a/src/store/pg.test.ts +++ b/src/store/pg.test.ts @@ -13,6 +13,15 @@ let store: PostgresStore; beforeAll(async () => { await pushSchema(TEST_DB_URL); store = new PostgresStore(TEST_DB_URL); + // Clean any leftover data from previous runs + const { sql } = await import("drizzle-orm"); + const { drizzle } = await import("drizzle-orm/postgres-js"); + const postgres = await import("postgres"); + const cleanClient = postgres.default(TEST_DB_URL, { max: 1 }); + const cleanDb = drizzle(cleanClient); + await cleanDb.execute(sql`DELETE FROM messages`); + await cleanDb.execute(sql`DELETE FROM channels`); + await cleanClient.end(); }); afterAll(() => { diff --git a/src/store/pg.ts b/src/store/pg.ts index 1ce3dbf..09918ae 100644 --- a/src/store/pg.ts +++ b/src/store/pg.ts @@ -17,7 +17,7 @@ export class PostgresStore implements Store { 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 { id: r.id, name: r.name, teamId: r.teamId, enabled: Boolean(r.enabled), webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], createdAt: r.createdAt }; + return { id: r.id, name: r.name, teamId: r.teamId, enabled: Boolean(r.enabled), webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], metadataSchema: r.metadataSchema, createdAt: r.createdAt }; } async upsertChannel(ch: StoreChannel): Promise { @@ -30,6 +30,7 @@ export class PostgresStore implements Store { enabled: ch.enabled ? 1 : 0, webhookUrl: ch.webhookUrl, autoApproveUsers: ch.autoApproveUsers.join(","), + metadataSchema: ch.metadataSchema, createdAt: sql`COALESCE((SELECT created_at FROM channels WHERE id = ${ch.id}), now()::text)`, }) .onConflictDoUpdate({ @@ -40,13 +41,14 @@ export class PostgresStore implements Store { enabled: ch.enabled ? 1 : 0, webhookUrl: ch.webhookUrl, autoApproveUsers: ch.autoApproveUsers.join(","), + metadataSchema: ch.metadataSchema, }, }); } 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, webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], createdAt: r.createdAt })); + return rows.map((r) => ({ id: r.id, name: r.name, teamId: r.teamId, enabled: true, webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) : [], metadataSchema: r.metadataSchema, createdAt: r.createdAt })); } async upsertMessage(msg: StoreMessage): Promise { @@ -59,6 +61,7 @@ export class PostgresStore implements Store { userName: msg.userName, text: msg.text, timestamp: msg.timestamp, + metadata: typeof msg.metadata === "string" ? msg.metadata : JSON.stringify(msg.metadata || {}), }) .onConflictDoUpdate({ target: [schema.messages.channelId, schema.messages.slackTs], @@ -67,6 +70,7 @@ export class PostgresStore implements Store { userName: msg.userName, text: msg.text, timestamp: msg.timestamp, + metadata: typeof msg.metadata === "string" ? msg.metadata : JSON.stringify(msg.metadata || {}), }, }); } @@ -88,6 +92,7 @@ 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 || {}), })); } diff --git a/src/store/store.ts b/src/store/store.ts index 264fe54..5dfa865 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -5,6 +5,7 @@ export interface StoreChannel { enabled: boolean; webhookUrl: string; autoApproveUsers: string[]; + metadataSchema: string; createdAt: string; } @@ -16,6 +17,7 @@ export interface StoreMessage { userName: string; text: string; timestamp: string; + metadata: any; } export interface Store {