@poncho-ai/messaging 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+
2
+ > @poncho-ai/messaging@0.2.0 build /home/runner/work/poncho-ai/poncho-ai/packages/messaging
3
+ > tsup src/index.ts --format esm --dts
4
+
5
+ CLI Building entry: src/index.ts
6
+ CLI Using tsconfig: tsconfig.json
7
+ CLI tsup v8.5.1
8
+ CLI Target: es2022
9
+ ESM Build start
10
+ ESM dist/index.js 8.45 KB
11
+ ESM ⚡️ Build success in 29ms
12
+ DTS Build start
13
+ DTS ⚡️ Build success in 2735ms
14
+ DTS dist/index.d.ts 3.23 KB
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @poncho-ai/messaging
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#10](https://github.com/cesr/poncho-ai/pull/10) [`d5bce7b`](https://github.com/cesr/poncho-ai/commit/d5bce7be5890c657bea915eb0926feb6de66b218) Thanks [@cesr](https://github.com/cesr)! - Add generic messaging layer with Slack as the first adapter. Agents can now respond to @mentions in Slack by adding `messaging: [{ platform: 'slack' }]` to `poncho.config.js`. Includes signature verification, threaded conversations, processing indicators, and Vercel `waitUntil` support.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`d5bce7b`](https://github.com/cesr/poncho-ai/commit/d5bce7be5890c657bea915eb0926feb6de66b218)]:
12
+ - @poncho-ai/sdk@1.0.1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Latitude
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,94 @@
1
+ import http from 'node:http';
2
+ import { Message } from '@poncho-ai/sdk';
3
+
4
+ interface ThreadRef {
5
+ platformThreadId: string;
6
+ channelId: string;
7
+ /** The specific message ID that triggered this interaction (for reactions). */
8
+ messageId?: string;
9
+ }
10
+ interface IncomingMessage {
11
+ text: string;
12
+ threadRef: ThreadRef;
13
+ sender: {
14
+ id: string;
15
+ name?: string;
16
+ };
17
+ platform: string;
18
+ raw: unknown;
19
+ }
20
+ type IncomingMessageHandler = (message: IncomingMessage) => Promise<void>;
21
+ type RouteHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void>;
22
+ type RouteRegistrar = (method: "GET" | "POST", path: string, handler: RouteHandler) => void;
23
+ interface MessagingAdapter {
24
+ readonly platform: string;
25
+ /** Register HTTP routes on the host server for receiving platform events. */
26
+ registerRoutes(router: RouteRegistrar): void;
27
+ /** One-time startup (e.g. validate credentials). */
28
+ initialize(): Promise<void>;
29
+ /** Set the handler that processes incoming messages. */
30
+ onMessage(handler: IncomingMessageHandler): void;
31
+ /** Post a reply back to the originating thread. */
32
+ sendReply(threadRef: ThreadRef, content: string): Promise<void>;
33
+ /**
34
+ * Show a processing indicator (e.g. reaction, typing).
35
+ * Returns a cleanup function that removes the indicator.
36
+ */
37
+ indicateProcessing(threadRef: ThreadRef): Promise<() => Promise<void>>;
38
+ }
39
+ interface AgentRunner {
40
+ getOrCreateConversation(conversationId: string, meta: {
41
+ platform: string;
42
+ ownerId: string;
43
+ title?: string;
44
+ }): Promise<{
45
+ messages: Message[];
46
+ }>;
47
+ run(conversationId: string, input: {
48
+ task: string;
49
+ messages: Message[];
50
+ }): Promise<{
51
+ response: string;
52
+ }>;
53
+ }
54
+ interface AgentBridgeOptions {
55
+ adapter: MessagingAdapter;
56
+ runner: AgentRunner;
57
+ /**
58
+ * Optional hook to keep serverless functions alive after the HTTP response.
59
+ * On Vercel, pass the real `waitUntil` from `@vercel/functions`.
60
+ */
61
+ waitUntil?: (promise: Promise<unknown>) => void;
62
+ }
63
+
64
+ declare class AgentBridge {
65
+ private readonly adapter;
66
+ private readonly runner;
67
+ private readonly waitUntil;
68
+ constructor(options: AgentBridgeOptions);
69
+ /** Wire the adapter's message handler and initialise. */
70
+ start(): Promise<void>;
71
+ private handleMessage;
72
+ }
73
+
74
+ interface SlackAdapterOptions {
75
+ botTokenEnv?: string;
76
+ signingSecretEnv?: string;
77
+ }
78
+ declare class SlackAdapter implements MessagingAdapter {
79
+ readonly platform: "slack";
80
+ private botToken;
81
+ private signingSecret;
82
+ private readonly botTokenEnv;
83
+ private readonly signingSecretEnv;
84
+ private handler;
85
+ constructor(options?: SlackAdapterOptions);
86
+ initialize(): Promise<void>;
87
+ onMessage(handler: IncomingMessageHandler): void;
88
+ registerRoutes(router: RouteRegistrar): void;
89
+ sendReply(threadRef: ThreadRef, content: string): Promise<void>;
90
+ indicateProcessing(threadRef: ThreadRef): Promise<() => Promise<void>>;
91
+ private handleRequest;
92
+ }
93
+
94
+ export { AgentBridge, type AgentBridgeOptions, type AgentRunner, type IncomingMessage, type IncomingMessageHandler, type MessagingAdapter, type RouteHandler, type RouteRegistrar, SlackAdapter, type SlackAdapterOptions, type ThreadRef };
package/dist/index.js ADDED
@@ -0,0 +1,290 @@
1
+ // src/bridge.ts
2
+ var conversationIdFromThread = (platform, ref) => `${platform}:${ref.channelId}:${ref.platformThreadId}`;
3
+ var AgentBridge = class {
4
+ adapter;
5
+ runner;
6
+ waitUntil;
7
+ constructor(options) {
8
+ this.adapter = options.adapter;
9
+ this.runner = options.runner;
10
+ this.waitUntil = options.waitUntil ?? ((_p) => {
11
+ });
12
+ }
13
+ /** Wire the adapter's message handler and initialise. */
14
+ async start() {
15
+ this.adapter.onMessage((msg) => {
16
+ const processing = this.handleMessage(msg);
17
+ this.waitUntil(processing);
18
+ return processing;
19
+ });
20
+ await this.adapter.initialize();
21
+ }
22
+ async handleMessage(message) {
23
+ let cleanup;
24
+ try {
25
+ cleanup = await this.adapter.indicateProcessing(message.threadRef);
26
+ const conversationId = conversationIdFromThread(
27
+ message.platform,
28
+ message.threadRef
29
+ );
30
+ const conversation = await this.runner.getOrCreateConversation(
31
+ conversationId,
32
+ {
33
+ platform: message.platform,
34
+ ownerId: message.sender.id,
35
+ title: `${message.platform} thread`
36
+ }
37
+ );
38
+ const result = await this.runner.run(conversationId, {
39
+ task: message.text,
40
+ messages: conversation.messages
41
+ });
42
+ await this.adapter.sendReply(message.threadRef, result.response);
43
+ } catch (error) {
44
+ const snippet = error instanceof Error ? error.message : "Unknown error";
45
+ try {
46
+ await this.adapter.sendReply(
47
+ message.threadRef,
48
+ `Sorry, something went wrong: ${snippet}`
49
+ );
50
+ } catch {
51
+ }
52
+ } finally {
53
+ if (cleanup) {
54
+ try {
55
+ await cleanup();
56
+ } catch {
57
+ }
58
+ }
59
+ }
60
+ }
61
+ };
62
+
63
+ // src/adapters/slack/verify.ts
64
+ import { createHmac, timingSafeEqual } from "crypto";
65
+ var MAX_TIMESTAMP_DRIFT_SECONDS = 300;
66
+ var verifySlackSignature = (signingSecret, headers, rawBody) => {
67
+ const { signature, timestamp } = headers;
68
+ if (!signature || !timestamp) return false;
69
+ const ts = Number(timestamp);
70
+ if (Number.isNaN(ts)) return false;
71
+ const drift = Math.abs(Math.floor(Date.now() / 1e3) - ts);
72
+ if (drift > MAX_TIMESTAMP_DRIFT_SECONDS) return false;
73
+ const basestring = `v0:${timestamp}:${rawBody}`;
74
+ const computed = `v0=${createHmac("sha256", signingSecret).update(basestring).digest("hex")}`;
75
+ if (computed.length !== signature.length) return false;
76
+ return timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
77
+ };
78
+
79
+ // src/adapters/slack/utils.ts
80
+ var SLACK_MAX_MESSAGE_LENGTH = 4e3;
81
+ var MENTION_PATTERN = /^\s*<@[A-Z0-9]+>\s*/i;
82
+ var stripMention = (text) => text.replace(MENTION_PATTERN, "").trim();
83
+ var splitMessage = (text) => {
84
+ if (text.length <= SLACK_MAX_MESSAGE_LENGTH) return [text];
85
+ const chunks = [];
86
+ let remaining = text;
87
+ while (remaining.length > 0) {
88
+ if (remaining.length <= SLACK_MAX_MESSAGE_LENGTH) {
89
+ chunks.push(remaining);
90
+ break;
91
+ }
92
+ let cutPoint = remaining.lastIndexOf(
93
+ "\n",
94
+ SLACK_MAX_MESSAGE_LENGTH
95
+ );
96
+ if (cutPoint <= 0) {
97
+ cutPoint = SLACK_MAX_MESSAGE_LENGTH;
98
+ }
99
+ chunks.push(remaining.slice(0, cutPoint));
100
+ remaining = remaining.slice(cutPoint).replace(/^\n/, "");
101
+ }
102
+ return chunks;
103
+ };
104
+ var SLACK_API = "https://slack.com/api";
105
+ var slackFetch = async (method, token, body) => {
106
+ const res = await fetch(`${SLACK_API}/${method}`, {
107
+ method: "POST",
108
+ headers: {
109
+ authorization: `Bearer ${token}`,
110
+ "content-type": "application/json; charset=utf-8"
111
+ },
112
+ body: JSON.stringify(body)
113
+ });
114
+ return await res.json();
115
+ };
116
+ var postMessage = async (token, channel, text, threadTs) => {
117
+ const result = await slackFetch("chat.postMessage", token, {
118
+ channel,
119
+ text,
120
+ thread_ts: threadTs
121
+ });
122
+ if (!result.ok) {
123
+ throw new Error(`Slack chat.postMessage failed: ${result.error}`);
124
+ }
125
+ };
126
+ var addReaction = async (token, channel, timestamp, reaction) => {
127
+ const result = await slackFetch("reactions.add", token, {
128
+ channel,
129
+ timestamp,
130
+ name: reaction
131
+ });
132
+ if (!result.ok && result.error !== "already_reacted") {
133
+ throw new Error(`Slack reactions.add failed: ${result.error}`);
134
+ }
135
+ };
136
+ var removeReaction = async (token, channel, timestamp, reaction) => {
137
+ const result = await slackFetch("reactions.remove", token, {
138
+ channel,
139
+ timestamp,
140
+ name: reaction
141
+ });
142
+ if (!result.ok && result.error !== "no_reaction") {
143
+ throw new Error(`Slack reactions.remove failed: ${result.error}`);
144
+ }
145
+ };
146
+
147
+ // src/adapters/slack/index.ts
148
+ var PROCESSING_REACTION = "eyes";
149
+ var collectBody = (req) => new Promise((resolve, reject) => {
150
+ const chunks = [];
151
+ req.on("data", (chunk) => chunks.push(chunk));
152
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
153
+ req.on("error", reject);
154
+ });
155
+ var SlackAdapter = class {
156
+ platform = "slack";
157
+ botToken = "";
158
+ signingSecret = "";
159
+ botTokenEnv;
160
+ signingSecretEnv;
161
+ handler;
162
+ constructor(options = {}) {
163
+ this.botTokenEnv = options.botTokenEnv ?? "SLACK_BOT_TOKEN";
164
+ this.signingSecretEnv = options.signingSecretEnv ?? "SLACK_SIGNING_SECRET";
165
+ }
166
+ // -----------------------------------------------------------------------
167
+ // MessagingAdapter implementation
168
+ // -----------------------------------------------------------------------
169
+ async initialize() {
170
+ this.botToken = process.env[this.botTokenEnv] ?? "";
171
+ this.signingSecret = process.env[this.signingSecretEnv] ?? "";
172
+ if (!this.botToken) {
173
+ throw new Error(
174
+ `Slack messaging: ${this.botTokenEnv} environment variable is not set`
175
+ );
176
+ }
177
+ if (!this.signingSecret) {
178
+ throw new Error(
179
+ `Slack messaging: ${this.signingSecretEnv} environment variable is not set`
180
+ );
181
+ }
182
+ }
183
+ onMessage(handler) {
184
+ this.handler = handler;
185
+ }
186
+ registerRoutes(router) {
187
+ router(
188
+ "POST",
189
+ "/api/messaging/slack",
190
+ (req, res) => this.handleRequest(req, res)
191
+ );
192
+ }
193
+ async sendReply(threadRef, content) {
194
+ const chunks = splitMessage(content);
195
+ for (const chunk of chunks) {
196
+ await postMessage(
197
+ this.botToken,
198
+ threadRef.channelId,
199
+ chunk,
200
+ threadRef.platformThreadId
201
+ );
202
+ }
203
+ }
204
+ async indicateProcessing(threadRef) {
205
+ const reactionTarget = threadRef.messageId ?? threadRef.platformThreadId;
206
+ await addReaction(
207
+ this.botToken,
208
+ threadRef.channelId,
209
+ reactionTarget,
210
+ PROCESSING_REACTION
211
+ );
212
+ return () => removeReaction(
213
+ this.botToken,
214
+ threadRef.channelId,
215
+ reactionTarget,
216
+ PROCESSING_REACTION
217
+ );
218
+ }
219
+ // -----------------------------------------------------------------------
220
+ // HTTP request handling
221
+ // -----------------------------------------------------------------------
222
+ async handleRequest(req, res) {
223
+ const rawBody = await collectBody(req);
224
+ const isValid = verifySlackSignature(
225
+ this.signingSecret,
226
+ {
227
+ signature: req.headers["x-slack-signature"],
228
+ timestamp: req.headers["x-slack-request-timestamp"]
229
+ },
230
+ rawBody
231
+ );
232
+ if (!isValid) {
233
+ res.writeHead(401);
234
+ res.end("Invalid signature");
235
+ return;
236
+ }
237
+ let payload;
238
+ try {
239
+ payload = JSON.parse(rawBody);
240
+ } catch {
241
+ res.writeHead(400);
242
+ res.end("Invalid JSON");
243
+ return;
244
+ }
245
+ if (payload.type === "url_verification") {
246
+ res.writeHead(200, { "content-type": "application/json" });
247
+ res.end(JSON.stringify({ challenge: payload.challenge }));
248
+ return;
249
+ }
250
+ if (req.headers["x-slack-retry-num"]) {
251
+ res.writeHead(200);
252
+ res.end();
253
+ return;
254
+ }
255
+ if (payload.type === "event_callback") {
256
+ res.writeHead(200);
257
+ res.end();
258
+ const event = payload.event;
259
+ if (event?.type === "app_mention" && this.handler) {
260
+ const text = stripMention(String(event.text ?? ""));
261
+ if (!text) return;
262
+ const threadTs = String(event.thread_ts ?? event.ts ?? "");
263
+ const messageTs = String(event.ts ?? "");
264
+ const channel = String(event.channel ?? "");
265
+ const userId = String(event.user ?? "");
266
+ const message = {
267
+ text,
268
+ threadRef: {
269
+ platformThreadId: threadTs,
270
+ channelId: channel,
271
+ messageId: messageTs
272
+ },
273
+ sender: { id: userId },
274
+ platform: "slack",
275
+ raw: event
276
+ };
277
+ void this.handler(message).catch((err) => {
278
+ console.error("[slack-adapter] unhandled message handler error", err);
279
+ });
280
+ }
281
+ return;
282
+ }
283
+ res.writeHead(200);
284
+ res.end();
285
+ }
286
+ };
287
+ export {
288
+ AgentBridge,
289
+ SlackAdapter
290
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@poncho-ai/messaging",
3
+ "version": "0.2.0",
4
+ "description": "Messaging platform adapters for Poncho agents (Slack, Telegram, etc.)",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/cesr/poncho-ai.git",
8
+ "directory": "packages/messaging"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@poncho-ai/sdk": "1.0.1"
24
+ },
25
+ "devDependencies": {
26
+ "tsup": "^8.0.0",
27
+ "vitest": "^1.4.0"
28
+ },
29
+ "keywords": [
30
+ "ai",
31
+ "agent",
32
+ "messaging",
33
+ "slack"
34
+ ],
35
+ "license": "MIT",
36
+ "scripts": {
37
+ "build": "tsup src/index.ts --format esm --dts",
38
+ "dev": "tsup src/index.ts --format esm --dts --watch",
39
+ "test": "vitest",
40
+ "lint": "eslint src/"
41
+ }
42
+ }
@@ -0,0 +1,214 @@
1
+ import type http from "node:http";
2
+ import type {
3
+ IncomingMessage as PonchoIncomingMessage,
4
+ IncomingMessageHandler,
5
+ MessagingAdapter,
6
+ RouteRegistrar,
7
+ ThreadRef,
8
+ } from "../../types.js";
9
+ import { verifySlackSignature } from "./verify.js";
10
+ import {
11
+ addReaction,
12
+ postMessage,
13
+ removeReaction,
14
+ splitMessage,
15
+ stripMention,
16
+ } from "./utils.js";
17
+
18
+ const PROCESSING_REACTION = "eyes";
19
+
20
+ export interface SlackAdapterOptions {
21
+ botTokenEnv?: string;
22
+ signingSecretEnv?: string;
23
+ }
24
+
25
+ /**
26
+ * Collect the raw request body from a Node `http.IncomingMessage`.
27
+ */
28
+ const collectBody = (req: http.IncomingMessage): Promise<string> =>
29
+ new Promise((resolve, reject) => {
30
+ const chunks: Buffer[] = [];
31
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
32
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
33
+ req.on("error", reject);
34
+ });
35
+
36
+ export class SlackAdapter implements MessagingAdapter {
37
+ readonly platform = "slack" as const;
38
+
39
+ private botToken = "";
40
+ private signingSecret = "";
41
+ private readonly botTokenEnv: string;
42
+ private readonly signingSecretEnv: string;
43
+ private handler: IncomingMessageHandler | undefined;
44
+
45
+ constructor(options: SlackAdapterOptions = {}) {
46
+ this.botTokenEnv = options.botTokenEnv ?? "SLACK_BOT_TOKEN";
47
+ this.signingSecretEnv =
48
+ options.signingSecretEnv ?? "SLACK_SIGNING_SECRET";
49
+ }
50
+
51
+ // -----------------------------------------------------------------------
52
+ // MessagingAdapter implementation
53
+ // -----------------------------------------------------------------------
54
+
55
+ async initialize(): Promise<void> {
56
+ this.botToken = process.env[this.botTokenEnv] ?? "";
57
+ this.signingSecret = process.env[this.signingSecretEnv] ?? "";
58
+
59
+ if (!this.botToken) {
60
+ throw new Error(
61
+ `Slack messaging: ${this.botTokenEnv} environment variable is not set`,
62
+ );
63
+ }
64
+ if (!this.signingSecret) {
65
+ throw new Error(
66
+ `Slack messaging: ${this.signingSecretEnv} environment variable is not set`,
67
+ );
68
+ }
69
+ }
70
+
71
+ onMessage(handler: IncomingMessageHandler): void {
72
+ this.handler = handler;
73
+ }
74
+
75
+ registerRoutes(router: RouteRegistrar): void {
76
+ router("POST", "/api/messaging/slack", (req, res) =>
77
+ this.handleRequest(req, res),
78
+ );
79
+ }
80
+
81
+ async sendReply(threadRef: ThreadRef, content: string): Promise<void> {
82
+ const chunks = splitMessage(content);
83
+ for (const chunk of chunks) {
84
+ await postMessage(
85
+ this.botToken,
86
+ threadRef.channelId,
87
+ chunk,
88
+ threadRef.platformThreadId,
89
+ );
90
+ }
91
+ }
92
+
93
+ async indicateProcessing(
94
+ threadRef: ThreadRef,
95
+ ): Promise<() => Promise<void>> {
96
+ // React to the specific message that triggered the event, not the
97
+ // thread parent. Falls back to platformThreadId for non-threaded msgs.
98
+ const reactionTarget =
99
+ threadRef.messageId ?? threadRef.platformThreadId;
100
+
101
+ await addReaction(
102
+ this.botToken,
103
+ threadRef.channelId,
104
+ reactionTarget,
105
+ PROCESSING_REACTION,
106
+ );
107
+
108
+ return () =>
109
+ removeReaction(
110
+ this.botToken,
111
+ threadRef.channelId,
112
+ reactionTarget,
113
+ PROCESSING_REACTION,
114
+ );
115
+ }
116
+
117
+ // -----------------------------------------------------------------------
118
+ // HTTP request handling
119
+ // -----------------------------------------------------------------------
120
+
121
+ private async handleRequest(
122
+ req: http.IncomingMessage,
123
+ res: http.ServerResponse,
124
+ ): Promise<void> {
125
+ const rawBody = await collectBody(req);
126
+
127
+ // -- Signature verification ------------------------------------------
128
+ const isValid = verifySlackSignature(
129
+ this.signingSecret,
130
+ {
131
+ signature: req.headers["x-slack-signature"] as string | undefined,
132
+ timestamp: req.headers["x-slack-request-timestamp"] as
133
+ | string
134
+ | undefined,
135
+ },
136
+ rawBody,
137
+ );
138
+
139
+ if (!isValid) {
140
+ res.writeHead(401);
141
+ res.end("Invalid signature");
142
+ return;
143
+ }
144
+
145
+ let payload: Record<string, unknown>;
146
+ try {
147
+ payload = JSON.parse(rawBody) as Record<string, unknown>;
148
+ } catch {
149
+ res.writeHead(400);
150
+ res.end("Invalid JSON");
151
+ return;
152
+ }
153
+
154
+ // -- URL verification challenge --------------------------------------
155
+ if (payload.type === "url_verification") {
156
+ res.writeHead(200, { "content-type": "application/json" });
157
+ res.end(JSON.stringify({ challenge: payload.challenge }));
158
+ return;
159
+ }
160
+
161
+ // -- Retry deduplication ---------------------------------------------
162
+ if (req.headers["x-slack-retry-num"]) {
163
+ res.writeHead(200);
164
+ res.end();
165
+ return;
166
+ }
167
+
168
+ // -- Event dispatch --------------------------------------------------
169
+ if (payload.type === "event_callback") {
170
+ // Acknowledge immediately so Slack doesn't retry.
171
+ res.writeHead(200);
172
+ res.end();
173
+
174
+ const event = payload.event as Record<string, unknown> | undefined;
175
+ if (event?.type === "app_mention" && this.handler) {
176
+ const text = stripMention(String(event.text ?? ""));
177
+ if (!text) return;
178
+
179
+ // thread_ts = parent message (for threading replies).
180
+ // ts = this specific message (for reactions).
181
+ const threadTs = String(event.thread_ts ?? event.ts ?? "");
182
+ const messageTs = String(event.ts ?? "");
183
+ const channel = String(event.channel ?? "");
184
+ const userId = String(event.user ?? "");
185
+
186
+ const message: PonchoIncomingMessage = {
187
+ text,
188
+ threadRef: {
189
+ platformThreadId: threadTs,
190
+ channelId: channel,
191
+ messageId: messageTs,
192
+ },
193
+ sender: { id: userId },
194
+ platform: "slack",
195
+ raw: event,
196
+ };
197
+
198
+ // Processing is fire-and-forget; the bridge's waitUntil keeps
199
+ // serverless functions alive. If the handler was wired via
200
+ // AgentBridge.scheduleProcessing, it already uses waitUntil.
201
+ // If wired via onMessage, we await here (long-running server).
202
+ void this.handler(message).catch((err) => {
203
+ console.error("[slack-adapter] unhandled message handler error", err);
204
+ });
205
+ }
206
+
207
+ return;
208
+ }
209
+
210
+ // Unknown payload type
211
+ res.writeHead(200);
212
+ res.end();
213
+ }
214
+ }