diff --git a/src/server.ts b/src/server.ts index 1e5c38e..a8a8d48 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,7 +45,12 @@ import { revokeSlackToken, signOauthState, verifyOauthState, + verifySlackSignature, } from "./slack-oauth.js"; +import { + processSlackEvent, + type SlackEventEnvelope, +} from "./slack-events-handler.js"; import { getActiveWorkspaceForTeam, getActiveWorkspaceForUser, @@ -533,7 +538,13 @@ export async function createServer(config: ServerConfig): Promise { - if (!request.url?.startsWith("/webhooks/")) return payload; + // Captures the raw body for routes that need to verify a request signature + // against the bytes (HMAC). Stripe webhooks and Slack events both need it. + const url = request.url ?? ""; + const needsRawBody = + url.startsWith("/webhooks/") || + url.startsWith("/slack/events"); + if (!needsRawBody) return payload; const chunks: Buffer[] = []; for await (const chunk of payload) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); @@ -907,6 +918,12 @@ export async function createServer(config: ServerConfig): Promise { + const cfg = getActiveSlackConfig(); + if (!cfg) { + reply.code(503); + return { error: "Slack OAuth is not configured on this instance" }; + } + const rawBody = + (request as unknown as { rawBody?: string }).rawBody ?? + (typeof request.body === "string" + ? request.body + : JSON.stringify(request.body ?? {})); + const ts = request.headers["x-slack-request-timestamp"]; + const sig = request.headers["x-slack-signature"]; + if (typeof ts !== "string" || typeof sig !== "string") { + reply.code(401); + return { error: "Missing Slack signature headers" }; + } + const valid = verifySlackSignature({ + signingSecret: cfg.signingSecret, + timestamp: ts, + signature: sig, + rawBody, + }); + if (!valid) { + reply.code(401); + return { error: "Invalid Slack signature" }; + } + + let envelope: SlackEventEnvelope; + try { + envelope = + typeof request.body === "object" && request.body !== null + ? (request.body as SlackEventEnvelope) + : (JSON.parse(rawBody) as SlackEventEnvelope); + } catch { + reply.code(400); + return { error: "Invalid JSON" }; + } + + const result = processSlackEvent(db, envelope, { + onAsyncError: (err) => + request.log.error({ err }, "[slack-events] async handler error"), + }); + + if (result.kind === "url_verification") { + return { challenge: result.challenge }; + } + // event_callback ack or ignored: respond 200 with a tiny body Slack + // discards. Don't return the body's identifying details. + return { ok: true }; + }, + ); + // =========================================================================== // POST /memories -- Create a memory (user path) // =========================================================================== diff --git a/src/slack-api.ts b/src/slack-api.ts new file mode 100644 index 0000000..b9f0061 --- /dev/null +++ b/src/slack-api.ts @@ -0,0 +1,153 @@ +/** + * Thin typed wrappers around the Slack Web API endpoints we use. + * Keep these dependency-free and side-effect free so they're easy to mock in + * tests. All error handling surfaces a discriminated union; callers decide + * whether to retry, give up, or surface to the user. + */ + +const SLACK_API = "https://slack.com/api"; + +export type SlackResult = { ok: true; data: T } | { ok: false; error: string }; + +interface SlackUserInfoResult { + email: string | null; + realName: string | null; + displayName: string | null; + isBot: boolean; + isDeleted: boolean; +} + +/** + * Looks up a Slack user by their Slack user ID. Returns the email (which we + * use to match to a Reflect user) plus a couple of display fields. Email may + * be null if the workspace admin has revoked the `users:read.email` scope. + */ +export async function slackUsersInfo( + botToken: string, + slackUserId: string, +): Promise> { + const params = new URLSearchParams({ user: slackUserId }); + let res: Response; + try { + res = await fetch(`${SLACK_API}/users.info?${params.toString()}`, { + method: "GET", + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + }, + }); + } catch (err) { + return { ok: false, error: `network: ${err instanceof Error ? err.message : err}` }; + } + let data: { + ok?: boolean; + error?: string; + user?: { + profile?: { email?: string | null; real_name?: string | null; display_name?: string | null }; + is_bot?: boolean; + deleted?: boolean; + }; + }; + try { + data = (await res.json()) as typeof data; + } catch (err) { + return { ok: false, error: `parse: ${err instanceof Error ? err.message : err}` }; + } + if (!data.ok) { + return { ok: false, error: data.error ?? "users.info returned ok=false" }; + } + const user = data.user ?? {}; + return { + ok: true, + data: { + email: user.profile?.email ?? null, + realName: user.profile?.real_name ?? null, + displayName: user.profile?.display_name ?? null, + isBot: user.is_bot === true, + isDeleted: user.deleted === true, + }, + }; +} + +interface PostMessageResult { + ts: string; + channel: string; +} + +/** + * Posts a message to a channel or DM. If `threadTs` is set, posts as a reply + * in that thread. + */ +export async function slackChatPostMessage( + botToken: string, + options: { channel: string; text: string; threadTs?: string | null }, +): Promise> { + const body: Record = { + channel: options.channel, + text: options.text, + }; + if (options.threadTs) body.thread_ts = options.threadTs; + + let res: Response; + try { + res = await fetch(`${SLACK_API}/chat.postMessage`, { + method: "POST", + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + }); + } catch (err) { + return { ok: false, error: `network: ${err instanceof Error ? err.message : err}` }; + } + let data: { ok?: boolean; error?: string; ts?: string; channel?: string }; + try { + data = (await res.json()) as typeof data; + } catch (err) { + return { ok: false, error: `parse: ${err instanceof Error ? err.message : err}` }; + } + if (!data.ok || !data.ts || !data.channel) { + return { ok: false, error: data.error ?? "chat.postMessage returned ok=false" }; + } + return { ok: true, data: { ts: data.ts, channel: data.channel } }; +} + +/** + * Posts an ephemeral message visible only to one user in a channel. We use + * this for refusals when the requester's email doesn't match a Reflect user + * — keeps the channel clean for everyone else. + */ +export async function slackPostEphemeral( + botToken: string, + options: { channel: string; user: string; text: string }, +): Promise> { + const body = { + channel: options.channel, + user: options.user, + text: options.text, + }; + let res: Response; + try { + res = await fetch(`${SLACK_API}/chat.postEphemeral`, { + method: "POST", + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + }); + } catch (err) { + return { ok: false, error: `network: ${err instanceof Error ? err.message : err}` }; + } + let data: { ok?: boolean; error?: string; message_ts?: string }; + try { + data = (await res.json()) as typeof data; + } catch (err) { + return { ok: false, error: `parse: ${err instanceof Error ? err.message : err}` }; + } + if (!data.ok) { + return { ok: false, error: data.error ?? "chat.postEphemeral returned ok=false" }; + } + return { ok: true, data: { messageTs: data.message_ts ?? "" } }; +} diff --git a/src/slack-events-handler.ts b/src/slack-events-handler.ts new file mode 100644 index 0000000..692c5f9 --- /dev/null +++ b/src/slack-events-handler.ts @@ -0,0 +1,245 @@ +/** + * Handles incoming Slack events for the Reflect Memory bot. + * + * Phase 3a (this file's current scope): identity resolution + canned reply. + * Phase 3b will add the Anthropic agent loop with read-only memory tools. + * + * Slack imposes a 3-second deadline on webhook ack. The route handler that + * calls `processSlackEvent` does so as fire-and-forget so the HTTP response + * goes out immediately. Errors here are logged but never thrown back to the + * caller. + */ + +import type Database from "better-sqlite3"; + +import { recordAuditEvent } from "./audit-service.js"; +import { slackChatPostMessage, slackPostEphemeral } from "./slack-api.js"; +import { resolveSlackUserToReflectUser } from "./slack-identity.js"; +import { + getWorkspaceWithToken, + type SlackWorkspaceWithToken, +} from "./slack-workspace-service.js"; + +// Minimal Slack event envelope shapes. We intentionally don't pull in +// Slack's massive type definitions; we only touch the fields we need. + +export interface SlackEventEnvelope { + type: string; + team_id?: string; + api_app_id?: string; + event?: { + type?: string; + user?: string; + text?: string; + channel?: string; + channel_type?: string; + ts?: string; + thread_ts?: string; + bot_id?: string; + subtype?: string; + app_id?: string; + }; + event_id?: string; + event_time?: number; + // url_verification only: + challenge?: string; +} + +export type ProcessResult = + | { kind: "url_verification"; challenge: string } + | { kind: "ack" } // event_callback ACK; async work continues in the background + | { kind: "ignored"; reason: string }; + +/** + * Synchronous part of event processing — picks the response type for the + * Slack-facing HTTP handler. For event_callback we return "ack" and kick the + * async work off in the background; for url_verification we return the + * challenge to echo back. + */ +export function processSlackEvent( + db: Database.Database, + body: SlackEventEnvelope, + options: { + onAsyncError?: (err: unknown) => void; + } = {}, +): ProcessResult { + if (body.type === "url_verification") { + return { kind: "url_verification", challenge: body.challenge ?? "" }; + } + if (body.type !== "event_callback" || !body.event || !body.team_id) { + return { kind: "ignored", reason: `unhandled top-level type: ${body.type}` }; + } + + const inner = body.event; + // Defend against bot-echo loops: any message that has a bot_id (or the + // bot_message subtype, or our own app_id) is skipped. + if (inner.bot_id) return { kind: "ignored", reason: "bot_id present" }; + if (inner.subtype === "bot_message") return { kind: "ignored", reason: "subtype=bot_message" }; + if (inner.subtype === "message_changed") return { kind: "ignored", reason: "subtype=message_changed" }; + if (inner.subtype === "message_deleted") return { kind: "ignored", reason: "subtype=message_deleted" }; + if (inner.app_id && body.api_app_id && inner.app_id === body.api_app_id) { + return { kind: "ignored", reason: "self-message" }; + } + + // Only handle the two event types we subscribe to. + if (inner.type !== "app_mention" && inner.type !== "message") { + return { kind: "ignored", reason: `event type: ${inner.type}` }; + } + // For DMs, Slack delivers a generic 'message' event. Restrict to im channels + // so we don't reply to arbitrary public channel messages without a mention. + if (inner.type === "message" && inner.channel_type !== "im") { + return { kind: "ignored", reason: "non-im message without mention" }; + } + if (!inner.user || !inner.channel || !inner.text) { + return { kind: "ignored", reason: "incomplete event payload" }; + } + + // Fire-and-forget the async handler. Errors logged via onAsyncError so we + // never throw past the route handler. + const handler = handleUserMessage(db, body.team_id, { + slackUserId: inner.user, + channel: inner.channel, + text: inner.text, + ts: inner.ts ?? "", + threadTs: inner.thread_ts ?? null, + isDirectMessage: inner.channel_type === "im", + eventType: inner.type, + }).catch((err) => { + if (options.onAsyncError) options.onAsyncError(err); + else console.error("[slack-events] async handler crashed", err); + }); + // Return the promise via the closure so callers (tests) can await it via + // the same fire-and-forget function below. + pendingAsyncHandlers.add(handler); + handler.finally(() => pendingAsyncHandlers.delete(handler)); + + return { kind: "ack" }; +} + +// Pool of in-flight async handlers, exposed for tests so they can await +// completion before assertions. Production code should never await this +// directly (we want to ack Slack instantly). +const pendingAsyncHandlers = new Set>(); + +/** Test-only: wait for all in-flight async handlers to settle. */ +export async function _waitForPendingHandlers(): Promise { + while (pendingAsyncHandlers.size > 0) { + await Promise.allSettled(Array.from(pendingAsyncHandlers)); + } +} + +interface IncomingMessage { + slackUserId: string; + channel: string; + text: string; + ts: string; + threadTs: string | null; + isDirectMessage: boolean; + eventType: "app_mention" | "message"; +} + +async function handleUserMessage( + db: Database.Database, + slackTeamId: string, + msg: IncomingMessage, +): Promise { + const workspace = getWorkspaceWithToken(db, slackTeamId); + if (!workspace) { + // No active workspace bound to this Slack team. Could happen during a + // race where the bot is still in a Slack channel after we soft-deleted + // the row. Nothing we can do server-side; just log. + console.warn(`[slack-events] no active workspace for slack_team_id=${slackTeamId}`); + return; + } + + const resolution = await resolveSlackUserToReflectUser( + db, + workspace, + workspace.botToken, + msg.slackUserId, + ); + + if (!resolution.ok) { + await postRefusal(workspace, msg, resolution.reason); + recordAuditEvent(db, { + userId: workspace.installedByUserId, + eventType: "slack.auth_refused", + metadata: { + slack_team_id: slackTeamId, + slack_user_id: msg.slackUserId, + slack_email: resolution.email, + reason: resolution.reason.slice(0, 200), + }, + }); + return; + } + + // Phase 3a: canned reply confirming identity resolution. Phase 3b will + // replace this with the Anthropic agent loop calling memory tools. + const where = msg.isDirectMessage ? "in DMs" : "in this channel"; + const text = buildPhase3aReply({ + realName: resolution.realName, + email: resolution.email, + where, + }); + + const post = await slackChatPostMessage(workspace.botToken, { + channel: msg.channel, + text, + threadTs: msg.threadTs ?? msg.ts, + }); + + recordAuditEvent(db, { + userId: resolution.reflectUserId, + eventType: "slack.message.handled", + metadata: { + slack_team_id: slackTeamId, + slack_user_id: msg.slackUserId, + slack_channel: msg.channel, + slack_event_type: msg.eventType, + reply_ok: post.ok, + reply_error: post.ok ? null : post.error, + }, + }); + + if (!post.ok) { + console.warn( + `[slack-events] failed to post reply: ${post.error}`, + ); + } +} + +function buildPhase3aReply(args: { + realName: string | null; + email: string; + where: string; +}): string { + const greeting = args.realName ? `Hi ${args.realName}` : "Hi"; + return [ + `${greeting} — I see you (\`${args.email}\`) and I'm here ${args.where}.`, + `My brain isn't wired up yet — that's the next deploy. Once it lands, I'll be able to read and search your Reflect memories from here.`, + ].join("\n\n"); +} + +async function postRefusal( + workspace: SlackWorkspaceWithToken, + msg: IncomingMessage, + reason: string, +): Promise { + const text = `Reflect Memory — access refused\n\n${reason}`; + if (msg.isDirectMessage) { + // In a DM there's only one user; a regular reply is fine. + await slackChatPostMessage(workspace.botToken, { + channel: msg.channel, + text, + threadTs: msg.threadTs ?? msg.ts, + }); + } else { + // Channel: ephemeral so we don't shame the requester publicly. + await slackPostEphemeral(workspace.botToken, { + channel: msg.channel, + user: msg.slackUserId, + text, + }); + } +} diff --git a/src/slack-identity.ts b/src/slack-identity.ts new file mode 100644 index 0000000..5e8d375 --- /dev/null +++ b/src/slack-identity.ts @@ -0,0 +1,126 @@ +/** + * Slack -> Reflect identity resolution. + * + * The single rule: a Slack user can use the bot only if their Slack email + * matches a Reflect user that is bound to the workspace's Reflect team + * (or, for a solo install, matches the workspace's bound Reflect user). + * + * No fallback links, no manual mapping UI. Email IS the key. + */ + +import type Database from "better-sqlite3"; +import type { SlackWorkspace } from "./slack-workspace-service.js"; +import { slackUsersInfo } from "./slack-api.js"; + +export type ResolveResult = + | { ok: true; reflectUserId: string; email: string; realName: string | null } + | { ok: false; reason: string; email: string | null }; + +interface UserRow { + id: string; + email: string | null; + team_id: string | null; + first_name: string | null; + last_name: string | null; +} + +/** + * Resolves a Slack user id to a Reflect user id by matching email. Returns: + * - { ok: true, reflectUserId, email, realName } when one Reflect user + * matches and is in the right team/solo scope. + * - { ok: false, reason } in every refusal case (no email scope, + * no matching reflect user, multiple matches, wrong team, ...). + * + * Caller is responsible for posting the refusal to Slack. + */ +export async function resolveSlackUserToReflectUser( + db: Database.Database, + workspace: SlackWorkspace, + botToken: string, + slackUserId: string, +): Promise { + const info = await slackUsersInfo(botToken, slackUserId); + if (!info.ok) { + return { + ok: false, + email: null, + reason: `Could not look up your Slack profile (Slack API: ${info.error}).`, + }; + } + if (info.data.isBot) { + return { ok: false, email: null, reason: "bot users cannot use Reflect" }; + } + if (info.data.isDeleted) { + return { ok: false, email: null, reason: "deactivated users cannot use Reflect" }; + } + const email = info.data.email?.trim().toLowerCase() ?? null; + if (!email) { + return { + ok: false, + email: null, + reason: + "Your Slack workspace hasn't granted Reflect the `users:read.email` scope, so I can't see your email to match it. Ask your workspace admin to re-authorise the install with the email scope.", + }; + } + + const matches = db + .prepare( + `SELECT id, email, team_id, first_name, last_name FROM users WHERE LOWER(email) = ? LIMIT 5`, + ) + .all(email) as UserRow[]; + + if (matches.length === 0) { + return { + ok: false, + email, + reason: `Your Slack email (${email}) doesn't match any Reflect Memory account on this team. Sign up at the dashboard with the same email, or ask the team admin to invite you.`, + }; + } + if (matches.length > 1) { + return { + ok: false, + email, + reason: `Your Slack email (${email}) matches multiple Reflect accounts. This shouldn't happen — please ping support.`, + }; + } + const candidate = matches[0]; + + // Workspace is bound to either a Reflect team or a solo Reflect user. The + // candidate must be on that team / be that user. + if (workspace.reflectTeamId) { + if (candidate.team_id !== workspace.reflectTeamId) { + return { + ok: false, + email, + reason: `Your Reflect account exists, but it isn't on the team this Slack workspace is connected to. Ask the team admin to add ${email} to the team.`, + }; + } + } else if (workspace.reflectUserId) { + if (candidate.id !== workspace.reflectUserId) { + return { + ok: false, + email, + reason: `This Slack workspace is connected to a single Reflect account that isn't yours.`, + }; + } + } else { + return { + ok: false, + email, + reason: "Workspace is not bound to a Reflect team or user (internal error).", + }; + } + + const realName = + info.data.realName ?? + info.data.displayName ?? + ([candidate.first_name, candidate.last_name].filter(Boolean).join(" ") || + null); + + return { + ok: true, + reflectUserId: candidate.id, + email, + realName, + }; +} diff --git a/tests/integration/slack-events.test.ts b/tests/integration/slack-events.test.ts new file mode 100644 index 0000000..89771de --- /dev/null +++ b/tests/integration/slack-events.test.ts @@ -0,0 +1,234 @@ +// /slack/events endpoint coverage. +// +// Two halves: +// 1. HTTP-level integration tests against the live test server: signature +// gate, URL verification handshake, ack-200 for valid envelopes. +// 2. In-process unit tests of processSlackEvent (no Slack network calls): +// url_verification synth, event filtering (bot_message, non-im channel +// messages, etc.). +// +// Note: end-to-end tests that exercise the async handler against a real DB +// would need a mocked Slack Web API in the SERVER process (cross-process, +// not the test process). We skip that for Phase 3a — manual smoke against +// the live `Reflect Dev` Slack app is faster + more honest. Phase 3b will +// pull the agent loop into a unit-testable shape. + +import { describe, expect, it } from "vitest"; +import Database from "better-sqlite3"; +import { createHmac, randomUUID } from "node:crypto"; +import { getTestServer } from "../helpers"; +import { _resetMasterKeyCacheForTests } from "../../src/llm-key-crypto"; +import { processSlackEvent } from "../../src/slack-events-handler"; + +process.env.RM_LLM_KEY_ENCRYPTION_KEY = getTestServer().llmKeyMasterKey; +_resetMasterKeyCacheForTests(); + +const SIGNING_SECRET = "test-signing-secret"; // matches global-setup.ts + +function signedHeaders(rawBody: string): Record { + const ts = String(Math.floor(Date.now() / 1000)); + const sig = `v0=${createHmac("sha256", SIGNING_SECRET).update(`v0:${ts}:${rawBody}`).digest("hex")}`; + return { + "x-slack-request-timestamp": ts, + "x-slack-signature": sig, + }; +} + +async function postEvent(envelope: unknown): Promise<{ status: number; body: unknown }> { + const raw = JSON.stringify(envelope); + const server = getTestServer(); + const res = await fetch(`${server.baseUrl}/slack/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...signedHeaders(raw), + }, + body: raw, + }); + const text = await res.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: res.status, body }; +} + +describe("POST /slack/events — url_verification", () => { + it("echoes the challenge", async () => { + const challenge = `c-${randomUUID()}`; + const { status, body } = await postEvent({ + type: "url_verification", + challenge, + }); + expect(status).toBe(200); + expect(body).toEqual({ challenge }); + }); +}); + +describe("POST /slack/events — signature gate", () => { + it("401 when signature header is missing", async () => { + const server = getTestServer(); + const res = await fetch(`${server.baseUrl}/slack/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "url_verification", challenge: "x" }), + }); + expect(res.status).toBe(401); + }); + + it("401 when signature is wrong", async () => { + const server = getTestServer(); + const raw = JSON.stringify({ type: "url_verification", challenge: "x" }); + const res = await fetch(`${server.baseUrl}/slack/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-slack-request-timestamp": String(Math.floor(Date.now() / 1000)), + "x-slack-signature": "v0=deadbeef", + }, + body: raw, + }); + expect(res.status).toBe(401); + }); + + it("401 when timestamp is stale (>5min)", async () => { + const server = getTestServer(); + const raw = JSON.stringify({ type: "url_verification", challenge: "x" }); + const stale = String(Math.floor(Date.now() / 1000) - 600); + const sig = `v0=${createHmac("sha256", SIGNING_SECRET).update(`v0:${stale}:${raw}`).digest("hex")}`; + const res = await fetch(`${server.baseUrl}/slack/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-slack-request-timestamp": stale, + "x-slack-signature": sig, + }, + body: raw, + }); + expect(res.status).toBe(401); + }); +}); + +describe("POST /slack/events — event_callback ack", () => { + it("returns 200 {ok:true} for a well-formed event_callback (handler runs async)", async () => { + const { status, body } = await postEvent({ + type: "event_callback", + team_id: "T-nonexistent", + api_app_id: "A-test", + event_id: `Ev${randomUUID()}`, + event: { + type: "app_mention", + user: "U-x", + text: "<@U-bot> hi", + channel: "C-x", + ts: "111.222", + }, + }); + expect(status).toBe(200); + expect(body).toEqual({ ok: true }); + // The async handler will fail to find the workspace and bail — that's + // fine for this test, we're only asserting the synchronous ack contract. + }); +}); + +// --------------------------------------------------------------------------- +// Pure unit tests of the synchronous event filter +// --------------------------------------------------------------------------- + +describe("processSlackEvent (unit) — synchronous decisions", () => { + // We don't hit the DB for these; pass a sentinel that would crash if used. + const dummyDb = null as unknown as Database.Database; + + it("returns url_verification with the challenge", () => { + const r = processSlackEvent(dummyDb, { type: "url_verification", challenge: "abc" }); + expect(r).toEqual({ kind: "url_verification", challenge: "abc" }); + }); + + it("ignores top-level types we don't handle", () => { + const r = processSlackEvent(dummyDb, { type: "block_actions" }); + expect(r.kind).toBe("ignored"); + }); + + it("ignores bot_message subtype without DB lookup", () => { + const r = processSlackEvent(dummyDb, { + type: "event_callback", + team_id: "T-x", + event: { + type: "message", + subtype: "bot_message", + bot_id: "B999", + user: "USLACKBOT", + text: "noise", + channel: "C-x", + channel_type: "im", + }, + }); + expect(r.kind).toBe("ignored"); + }); + + it("ignores message_changed and message_deleted subtypes", () => { + for (const subtype of ["message_changed", "message_deleted"]) { + const r = processSlackEvent(dummyDb, { + type: "event_callback", + team_id: "T-x", + event: { + type: "message", + subtype, + user: "U-x", + text: "edited", + channel: "C-x", + channel_type: "im", + }, + }); + expect(r.kind).toBe("ignored"); + } + }); + + it("ignores public-channel message without app_mention", () => { + const r = processSlackEvent(dummyDb, { + type: "event_callback", + team_id: "T-x", + event: { + type: "message", + channel_type: "channel", + user: "U-x", + text: "just chatting", + channel: "C-x", + ts: "111.222", + }, + }); + expect(r.kind).toBe("ignored"); + }); + + it("ignores incomplete event payloads (missing user/channel/text)", () => { + const r = processSlackEvent(dummyDb, { + type: "event_callback", + team_id: "T-x", + event: { + type: "app_mention", + channel: "C-x", + text: "hi", + }, + }); + expect(r.kind).toBe("ignored"); + }); + + it("ignores self-app messages (api_app_id matches event.app_id)", () => { + const r = processSlackEvent(dummyDb, { + type: "event_callback", + team_id: "T-x", + api_app_id: "A-self", + event: { + type: "app_mention", + app_id: "A-self", + user: "U-x", + text: "echo", + channel: "C-x", + ts: "111.222", + }, + }); + expect(r.kind).toBe("ignored"); + }); +});