Skip to content

Commit 46a097f

Browse files
committed
feat: add Slack message memory addon
1 parent 656eb3e commit 46a097f

8 files changed

Lines changed: 563 additions & 0 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ DISCORD_BOT_TOKEN=
9191
DISCORD_PUBLIC_KEY=
9292
SLACK_BOT_TOKEN=
9393
SLACK_SIGNING_SECRET=
94+
MESSAGE_MEMORY_SLACK_ALLOW_TEAM_IDS=
95+
MESSAGE_MEMORY_SLACK_INCLUDE_BOT_MESSAGES=0
96+
MESSAGE_MEMORY_SLACK_MAX_SKEW_SECONDS=300
9497

9598
# ── Public Eval API (Phase 62) ───────────────────────────────────────────────
9699
# URL used by the internal SDK client for dogfood calls (SEAL/autogen loop).
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// @vitest-environment node
2+
import crypto from "crypto";
3+
import fs from "fs";
4+
import os from "os";
5+
import path from "path";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
8+
const TEST_DB_DIR = path.join(os.tmpdir(), `memroos-slack-events-${crypto.randomUUID()}`);
9+
const TEST_DB_PATH = path.join(TEST_DB_DIR, "routes.db");
10+
const SIGNING_SECRET = "test-slack-signing-secret";
11+
12+
let closeDb: (() => void) | null = null;
13+
14+
function signedRequest(body: unknown, options: { secret?: string; timestamp?: number; signature?: string } = {}): Request {
15+
const rawBody = typeof body === "string" ? body : JSON.stringify(body);
16+
const timestamp = String(options.timestamp ?? Math.floor(Date.now() / 1000));
17+
const signature =
18+
options.signature ??
19+
`v0=${crypto.createHmac("sha256", options.secret ?? SIGNING_SECRET).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
20+
21+
return new Request("http://localhost/api/integrations/slack/events", {
22+
method: "POST",
23+
headers: {
24+
"Content-Type": "application/json",
25+
"x-slack-request-timestamp": timestamp,
26+
"x-slack-signature": signature,
27+
},
28+
body: rawBody,
29+
});
30+
}
31+
32+
function slackMessageEvent(overrides: Record<string, unknown> = {}) {
33+
return {
34+
type: "event_callback",
35+
team_id: "T123",
36+
api_app_id: "A123",
37+
event_id: "Ev123",
38+
event_time: 1782763200,
39+
authorizations: [{ team_id: "T123" }],
40+
event: {
41+
type: "message",
42+
channel: "C123",
43+
user: "U123",
44+
text: "Slack can be a MemroOS memory source.",
45+
ts: "1782763200.123456",
46+
thread_ts: "1782763000.000000",
47+
},
48+
...overrides,
49+
};
50+
}
51+
52+
async function loadRoute() {
53+
process.env.SQLITE_DB_PATH = TEST_DB_PATH;
54+
process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET;
55+
process.env.MESSAGE_MEMORY_SLACK_ALLOW_TEAM_IDS = "T123";
56+
vi.resetModules();
57+
const route = await import("../route");
58+
const dbModule = await import("@/lib/db");
59+
closeDb = dbModule.closeDb;
60+
return { ...route, getDb: dbModule.getDb };
61+
}
62+
63+
describe("Slack Events API memory route", () => {
64+
beforeEach(() => {
65+
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
66+
fs.mkdirSync(TEST_DB_DIR, { recursive: true });
67+
});
68+
69+
afterEach(() => {
70+
closeDb?.();
71+
closeDb = null;
72+
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
73+
delete process.env.SQLITE_DB_PATH;
74+
delete process.env.SLACK_SIGNING_SECRET;
75+
delete process.env.MESSAGE_MEMORY_SLACK_ALLOW_TEAM_IDS;
76+
delete process.env.MESSAGE_MEMORY_SLACK_INCLUDE_BOT_MESSAGES;
77+
delete process.env.MESSAGE_MEMORY_SLACK_MAX_SKEW_SECONDS;
78+
delete process.env.MEMROOS_PUBLIC_BASE_URL;
79+
vi.restoreAllMocks();
80+
});
81+
82+
it("returns a Slack app manifest with the configured Events API URL", async () => {
83+
process.env.MEMROOS_PUBLIC_BASE_URL = "https://memroos.example";
84+
const { GET } = await loadRoute();
85+
86+
const response = await GET(new Request("http://localhost/api/integrations/slack/events"));
87+
const body = await response.json();
88+
89+
expect(response.status).toBe(200);
90+
expect(body.display_information.name).toBe("MemroOS Memory");
91+
expect(body.settings.event_subscriptions.request_url).toBe("https://memroos.example/api/integrations/slack/events");
92+
expect(body.settings.event_subscriptions.bot_events).toContain("message.channels");
93+
});
94+
95+
it("answers Slack URL verification challenges after signature verification", async () => {
96+
const { POST } = await loadRoute();
97+
98+
const response = await POST(signedRequest({ type: "url_verification", challenge: "challenge-token" }));
99+
const body = await response.json();
100+
101+
expect(response.status).toBe(200);
102+
expect(body).toEqual({ challenge: "challenge-token" });
103+
});
104+
105+
it("ingests signed Slack message events into provider-agnostic memory", async () => {
106+
const { POST, getDb } = await loadRoute();
107+
108+
const response = await POST(signedRequest(slackMessageEvent()));
109+
const body = await response.json();
110+
111+
expect(response.status).toBe(201);
112+
expect(body).toMatchObject({ ok: true, created: true, duplicate: false });
113+
114+
const memoryRow = getDb()
115+
.prepare("SELECT provider, workspace_id, channel_id, thread_id, author_id, content FROM platform_message_memory")
116+
.get() as Record<string, unknown>;
117+
expect(memoryRow).toMatchObject({
118+
provider: "slack",
119+
workspace_id: "T123",
120+
channel_id: "C123",
121+
thread_id: "1782763000.000000",
122+
author_id: "U123",
123+
content: "Slack can be a MemroOS memory source.",
124+
});
125+
126+
const recallRow = getDb().prepare("SELECT session_id, project, request_id, policy FROM messages").get() as Record<string, unknown>;
127+
expect(recallRow).toMatchObject({
128+
session_id: "platform:slack:T123:C123",
129+
project: "platform:slack",
130+
request_id: body.dedupeKey,
131+
policy: "indexable",
132+
});
133+
});
134+
135+
it("dedupes retried Slack events by provider/team/channel/thread/message id", async () => {
136+
const { POST, getDb } = await loadRoute();
137+
const event = slackMessageEvent();
138+
139+
const first = await POST(signedRequest(event));
140+
const second = await POST(signedRequest(event));
141+
142+
expect(first.status).toBe(201);
143+
expect(second.status).toBe(200);
144+
expect(await second.json()).toMatchObject({ ok: true, created: false, duplicate: true });
145+
expect((getDb().prepare("SELECT COUNT(*) AS count FROM platform_message_memory").get() as { count: number }).count).toBe(1);
146+
expect((getDb().prepare("SELECT COUNT(*) AS count FROM messages").get() as { count: number }).count).toBe(1);
147+
});
148+
149+
it("rejects invalid signatures before reading events", async () => {
150+
const { POST } = await loadRoute();
151+
152+
const response = await POST(signedRequest(slackMessageEvent(), { signature: "v0=bad" }));
153+
const body = await response.json();
154+
155+
expect(response.status).toBe(401);
156+
expect(body).toEqual({ ok: false, error: "invalid_signature" });
157+
});
158+
159+
it("skips bot messages by default", async () => {
160+
const { POST } = await loadRoute();
161+
const botEvent = slackMessageEvent({
162+
event: {
163+
type: "message",
164+
channel: "C123",
165+
bot_id: "B123",
166+
username: "summary-bot",
167+
text: "Automated message",
168+
ts: "1782763201.000000",
169+
},
170+
});
171+
172+
const response = await POST(signedRequest(botEvent));
173+
const body = await response.json();
174+
175+
expect(response.status).toBe(200);
176+
expect(body).toEqual({ ok: true, ignored: true, reason: "bot_message" });
177+
});
178+
179+
it("rejects non-allowlisted Slack teams", async () => {
180+
const { POST } = await loadRoute();
181+
182+
const response = await POST(signedRequest(slackMessageEvent({ team_id: "T999", authorizations: [{ team_id: "T999" }] })));
183+
const body = await response.json();
184+
185+
expect(response.status).toBe(403);
186+
expect(body).toEqual({ ok: false, error: "Slack team is not allowlisted" });
187+
});
188+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { getDb } from "@/lib/db";
2+
import { ingestPlatformMessageMemory, normalizeSlackEvent, type SlackEventPayload } from "@/lib/message-memory";
3+
import { loadSlackMessageMemoryConfig, slackManifest, verifySlackRequest } from "@/lib/message-memory/slack";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
type SlackEventsEnvelope = SlackEventPayload & {
8+
type?: string;
9+
challenge?: string;
10+
event_id?: string;
11+
event_time?: number;
12+
api_app_id?: string;
13+
enterprise_id?: string;
14+
};
15+
16+
function jsonError(error: string, status: number) {
17+
return Response.json({ ok: false, error }, { status });
18+
}
19+
20+
function configuredRequestUrl(request: Request): string {
21+
const fallback = new URL(request.url);
22+
const base = process.env.MEMROOS_PUBLIC_BASE_URL ?? `${fallback.protocol}//${fallback.host}`;
23+
return new URL("/api/integrations/slack/events", base).toString();
24+
}
25+
26+
function parseJson(rawBody: string): SlackEventsEnvelope | null {
27+
try {
28+
const parsed = JSON.parse(rawBody) as unknown;
29+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as SlackEventsEnvelope) : null;
30+
} catch {
31+
return null;
32+
}
33+
}
34+
35+
export async function GET(request: Request) {
36+
return Response.json(slackManifest(configuredRequestUrl(request)));
37+
}
38+
39+
export async function POST(request: Request) {
40+
const config = loadSlackMessageMemoryConfig();
41+
if (!config.signingSecret) return jsonError("Slack signing secret not configured", 503);
42+
43+
const rawBody = await request.text();
44+
const verification = verifySlackRequest({
45+
headers: request.headers,
46+
rawBody,
47+
signingSecret: config.signingSecret,
48+
maxSkewSeconds: config.maxSkewSeconds,
49+
});
50+
if (!verification.ok) return jsonError(verification.error ?? "invalid_signature", 401);
51+
52+
const body = parseJson(rawBody);
53+
if (!body) return jsonError("invalid JSON body", 400);
54+
55+
if (body.type === "url_verification") {
56+
if (typeof body.challenge !== "string" || body.challenge.length === 0) {
57+
return jsonError("missing Slack challenge", 400);
58+
}
59+
return Response.json({ challenge: body.challenge });
60+
}
61+
62+
if (body.type !== "event_callback") {
63+
return Response.json({ ok: true, ignored: true, reason: "unsupported_envelope_type" });
64+
}
65+
66+
const normalized = normalizeSlackEvent(body);
67+
if (!normalized) {
68+
return Response.json({ ok: true, ignored: true, reason: "unsupported_event" });
69+
}
70+
71+
if (config.allowTeamIds.size > 0 && !config.allowTeamIds.has(normalized.workspaceId)) {
72+
return jsonError("Slack team is not allowlisted", 403);
73+
}
74+
75+
if (!config.includeBotMessages && normalized.author.isBot) {
76+
return Response.json({ ok: true, ignored: true, reason: "bot_message" });
77+
}
78+
79+
const result = ingestPlatformMessageMemory(getDb(), {
80+
...normalized,
81+
visibility: "internal",
82+
policy: "indexable",
83+
metadata: {
84+
provider: "slack",
85+
eventId: body.event_id ?? null,
86+
eventTime: body.event_time ?? null,
87+
apiAppId: body.api_app_id ?? null,
88+
enterpriseId: body.enterprise_id ?? null,
89+
teamId: normalized.workspaceId,
90+
},
91+
});
92+
93+
return Response.json(
94+
{
95+
ok: true,
96+
created: result.created,
97+
duplicate: result.duplicate,
98+
dedupeKey: result.dedupeKey,
99+
messageRowId: result.messageRowId,
100+
},
101+
{ status: result.created ? 201 : 200 }
102+
);
103+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment node
2+
import crypto from "crypto";
3+
import { describe, expect, it } from "vitest";
4+
import { loadSlackMessageMemoryConfig, slackManifest, verifySlackRequest } from "../slack";
5+
6+
function headersFor(body: string, secret: string, timestamp: number, signature?: string): Headers {
7+
const computed = `v0=${crypto.createHmac("sha256", secret).update(`v0:${timestamp}:${body}`).digest("hex")}`;
8+
return new Headers({
9+
"x-slack-request-timestamp": String(timestamp),
10+
"x-slack-signature": signature ?? computed,
11+
});
12+
}
13+
14+
describe("Slack message memory helpers", () => {
15+
it("verifies Slack HMAC signatures", () => {
16+
const body = JSON.stringify({ type: "event_callback" });
17+
const timestamp = 1_782_763_200;
18+
19+
expect(
20+
verifySlackRequest({
21+
headers: headersFor(body, "secret", timestamp),
22+
rawBody: body,
23+
signingSecret: "secret",
24+
nowMs: timestamp * 1000,
25+
})
26+
).toEqual({ ok: true });
27+
});
28+
29+
it("rejects stale Slack signatures", () => {
30+
const body = "{}";
31+
expect(
32+
verifySlackRequest({
33+
headers: headersFor(body, "secret", 100),
34+
rawBody: body,
35+
signingSecret: "secret",
36+
nowMs: 1_000_000,
37+
maxSkewSeconds: 300,
38+
})
39+
).toEqual({ ok: false, error: "stale_request" });
40+
});
41+
42+
it("loads allowlists and bot-message policy from env", () => {
43+
const config = loadSlackMessageMemoryConfig({
44+
SLACK_SIGNING_SECRET: "secret",
45+
MESSAGE_MEMORY_SLACK_ALLOW_TEAM_IDS: "T1, T2",
46+
MESSAGE_MEMORY_SLACK_INCLUDE_BOT_MESSAGES: "yes",
47+
MESSAGE_MEMORY_SLACK_MAX_SKEW_SECONDS: "600",
48+
} as unknown as NodeJS.ProcessEnv);
49+
50+
expect(config.signingSecret).toBe("secret");
51+
expect([...config.allowTeamIds]).toEqual(["T1", "T2"]);
52+
expect(config.includeBotMessages).toBe(true);
53+
expect(config.maxSkewSeconds).toBe(600);
54+
});
55+
56+
it("generates a public-safe Slack app manifest", () => {
57+
const manifest = slackManifest("https://memroos.example/api/integrations/slack/events");
58+
59+
expect(manifest.settings.event_subscriptions.request_url).toBe("https://memroos.example/api/integrations/slack/events");
60+
expect(manifest.oauth_config.scopes.bot).toContain("channels:history");
61+
expect(JSON.stringify(manifest)).not.toContain("secret");
62+
});
63+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./adapters";
22
export * from "./dedupe";
3+
export * from "./slack";
34
export * from "./store";
45
export * from "./types";

0 commit comments

Comments
 (0)