From 9d81a3680127bae768b8908d5edef3ac046ee6d8 Mon Sep 17 00:00:00 2001 From: Mat Manna Date: Sat, 1 Aug 2026 21:52:59 -0400 Subject: [PATCH] fix: store bot actions --- src/app.ts | 193 ++++++++++++++++++++++++++++++--------------- src/db/migrate.ts | 18 ++++- src/db/schema.ts | 20 ++++- src/store/neon.ts | 19 ++++- src/store/pg.ts | 31 +++++++- src/store/store.ts | 12 +++ 6 files changed, 223 insertions(+), 70 deletions(-) diff --git a/src/app.ts b/src/app.ts index b132321..af71b61 100644 --- a/src/app.ts +++ b/src/app.ts @@ -294,36 +294,41 @@ async function forwardToSubscribers( // 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); + try { if (sourceChannel.linkMode) { // Keep the raw permalink in the top-level text so Slack generates its unfurl. - await slack.chat.postMessage({ + const linkBlocks: any[] = []; + if (metadataSection) { + linkBlocks.push({ type: "section", text: { type: "mrkdwn", text: metadataSection.trim() } }); + } + linkBlocks.push({ type: "section", text: { type: "mrkdwn", text: footerText } }); + const res = await slack.chat.postMessage({ channel: sub.subscriberChannelId, username: postUsername, icon_url: postIcon, - unfurl_links: true, - unfurl_media: true, - text: forwardLink || footerText, - blocks: [ - { type: "section", text: { type: "mrkdwn", text: footerText } }, - ], + unfurl_links: !metadataSection, + unfurl_media: !metadataSection, + text: metadataSection + ? metadataSection.trim() + : (forwardLink || footerText), + blocks: linkBlocks, } as any); + if (res?.ts) { + await store.addBotAction({ type: "pub", sourceChannelId: msg.channelId, sourceMessageTs: msg.slackTs, botChannelId: sub.subscriberChannelId, botMessageTs: res.ts, createdAt: "" }); + } continue; } - 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: [ + const blocks: any[] = [ { type: "section", text: { @@ -336,12 +341,28 @@ async function forwardToSubscribers( ).slice(0, 3000), }, }, - { - type: "context", - elements: contextElements, - }, - ], - }); + ]; + 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 (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); } @@ -758,7 +779,7 @@ app.post("/events", async (c) => { 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 (ch.linkMode) await postPermalinkReply(slack, ev.channel, ev.ts); // If channel has a metadata schema, prompt the user to add metadata if (ch.metadataSchema) { @@ -784,7 +805,7 @@ app.post("/events", async (c) => { }, ], }); - } catch {} + } catch {} } } @@ -991,6 +1012,47 @@ app.post("/interactions", async (c) => { 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); @@ -1118,56 +1180,57 @@ app.post("/interactions", async (c) => { } if (action.action_id === "indigest_yes") { - // Permission check: lockdown users, channel managers always pass const clickingUser = cb.user?.id || ""; - 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) { - // Check if the message exists in the DB - const msgs = await store.getMessages(channelId, 100, 0); - const originalMsg = msgs.find((m) => m.slackTs === messageTs); - - if (originalMsg) { - // Message exists — apply normal permission check - if (ch.approvedPosters.length === 0) { - if (originalMsg.userId !== clickingUser) { - await slackResponse(responseUrl, "❌ Sorry, only the original poster or a channel manager can approve this message."); - return; - } - } else if (ch.approvedPosters.includes("poster")) { - if (originalMsg.userId !== clickingUser && !ch.approvedPosters.includes(clickingUser)) { - await slackResponse(responseUrl, "❌ Sorry, you can't do that due to the opt-in perms!"); - return; - } - } else { - if (!ch.approvedPosters.includes(clickingUser)) { - await slackResponse(responseUrl, "❌ Sorry, you can't do that due to the opt-in perms!"); - return; - } - } - } else { - // Message not in DB (was deleted via Undo/No) — allow re-approval - } - } + // 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); - await slackResponse(responseUrl, ""); - return; + await openMetadataModal(slack, triggerId, channelId, messageTs, schema, cb.container?.message_ts); } catch (err: any) { - await slackResponse(responseUrl, `Error opening form: ${err.message}`); + 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; } } + // 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, @@ -2036,6 +2099,8 @@ function retryingStore(store: Store): Store { 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)), close: () => store.close(), }; } diff --git a/src/db/migrate.ts b/src/db/migrate.ts index fb51f9d..7de90d8 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -3,7 +3,7 @@ import postgres from "postgres"; import { sql } from "drizzle-orm"; export async function pushSchema(connectionString: string) { - const client = postgres(connectionString, { max: 1 }); + const client = postgres(connectionString, { max: 1, debug: false, onnotice: () => {} }); const db = drizzle(client); await db.execute(sql` @@ -79,5 +79,21 @@ export async function pushSchema(connectionString: string) { CREATE INDEX IF NOT EXISTS idx_subscriptions_source ON subscriptions(source_channel_id) `); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS bot_actions ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + type TEXT NOT NULL, + source_channel_id TEXT NOT NULL, + source_message_ts TEXT NOT NULL, + bot_channel_id TEXT NOT NULL, + bot_message_ts TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT now() + ) + `); + + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_bot_actions_source ON bot_actions(source_channel_id, source_message_ts) + `); + await client.end(); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 8b7bafd..00067c2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,12 +4,12 @@ export const channels = pgTable("channels", { id: text("id").primaryKey(), name: text("name").notNull().default(""), teamId: text("team_id").notNull().default(""), - enabled: integer("enabled").notNull().default(0), + enabled: text("enabled").notNull().default("public"), linkMode: integer("link_mode").notNull().default(0), webhookUrl: text("webhook_url").notNull().default(""), autoApproveUsers: text("auto_approve_users").notNull().default(""), approvedPosters: text("approved_posters").notNull().default(""), - trackReplies: integer("track_replies").notNull().default(0), + trackReplies: text("track_replies").notNull().default("public"), metadataSchema: text("metadata_schema").notNull().default(""), createdAt: text("created_at").notNull().default("now()"), }); @@ -46,3 +46,19 @@ export const subscriptions = pgTable( sourceIdx: index("idx_subscriptions_source").on(t.sourceChannelId), }), ); + +export const botActions = pgTable( + "bot_actions", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + type: text("type").notNull(), + sourceChannelId: text("source_channel_id").notNull(), + sourceMessageTs: text("source_message_ts").notNull(), + botChannelId: text("bot_channel_id").notNull(), + botMessageTs: text("bot_message_ts").notNull(), + createdAt: text("created_at").notNull().default("now()"), + }, + (t) => ({ + sourceIdx: index("idx_bot_actions_source").on(t.sourceChannelId, t.sourceMessageTs), + }), +); diff --git a/src/store/neon.ts b/src/store/neon.ts index ce347f5..7457b20 100644 --- a/src/store/neon.ts +++ b/src/store/neon.ts @@ -1,5 +1,5 @@ import { neon, type NeonQueryFunction } from "@neondatabase/serverless"; -import type { Store, StoreChannel, StoreMessage, StoreSubscription } from "./store"; +import type { Store, StoreChannel, StoreMessage, StoreSubscription, StoreBotAction } from "./store"; function rows(result: any): any[] { return Array.isArray(result) ? result : result?.rows || []; @@ -138,4 +138,21 @@ export class NeonStore implements Store { 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) + 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) => ({ + id: r.id, + type: r.type, + sourceChannelId: r.source_channel_id, + sourceMessageTs: r.source_message_ts, + botChannelId: r.bot_channel_id, + botMessageTs: r.bot_message_ts, + createdAt: r.created_at, + })); + } } diff --git a/src/store/pg.ts b/src/store/pg.ts index a2f04f4..2e94f3b 100644 --- a/src/store/pg.ts +++ b/src/store/pg.ts @@ -2,14 +2,14 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { eq, sql, and } from "drizzle-orm"; import * as schema from "../db/schema"; -import type { Store, StoreChannel, StoreMessage, StoreSubscription } 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 }); + this.client = postgres(connectionString, { prepare: false, debug: false, onnotice: () => {} }); this.db = drizzle(this.client, { schema }); } @@ -193,4 +193,31 @@ export class PostgresStore implements Store { metadata: typeof r.metadata === "string" ? r.metadata : JSON.stringify(r.metadata || {}), })); } + + async addBotAction(action: StoreBotAction): Promise { + await this.db.insert(schema.botActions).values({ + type: action.type, + sourceChannelId: action.sourceChannelId, + sourceMessageTs: action.sourceMessageTs, + botChannelId: action.botChannelId, + botMessageTs: action.botMessageTs, + createdAt: new Date().toISOString(), + }); + } + + 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))); + return rows.map((r) => ({ + id: r.id, + type: r.type, + sourceChannelId: r.sourceChannelId, + sourceMessageTs: r.sourceMessageTs, + botChannelId: r.botChannelId, + botMessageTs: r.botMessageTs, + createdAt: r.createdAt, + })); + } } diff --git a/src/store/store.ts b/src/store/store.ts index f734dfe..b9be825 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -31,6 +31,16 @@ export interface StoreSubscription { createdAt: string; } +export interface StoreBotAction { + id?: number; + type: string; + sourceChannelId: string; + sourceMessageTs: string; + botChannelId: string; + botMessageTs: string; + createdAt: string; +} + export interface Store { getChannel(id: string): Promise; upsertChannel(ch: StoreChannel): Promise; @@ -44,5 +54,7 @@ export interface Store { getSubscribersBySource(sourceChannelId: string): Promise; getSubscriptionsBySubscriber(subscriberChannelId: string): Promise; getRecentMessages(channelId: string, since: Date): Promise; + addBotAction(action: StoreBotAction): Promise; + getBotActionsBySource(sourceChannelId: string, sourceMessageTs: string): Promise; close(): void; } -- 2.51.2