@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,107 @@
1
+ const SLACK_MAX_MESSAGE_LENGTH = 4000;
2
+ const MENTION_PATTERN = /^\s*<@[A-Z0-9]+>\s*/i;
3
+
4
+ /** Strip the leading `<@BOT_ID>` mention from a Slack message. */
5
+ export const stripMention = (text: string): string =>
6
+ text.replace(MENTION_PATTERN, "").trim();
7
+
8
+ /**
9
+ * Split a long message into chunks that fit within Slack's character limit.
10
+ * Attempts to split on newlines, falling back to hard cuts.
11
+ */
12
+ export const splitMessage = (text: string): string[] => {
13
+ if (text.length <= SLACK_MAX_MESSAGE_LENGTH) return [text];
14
+
15
+ const chunks: string[] = [];
16
+ let remaining = text;
17
+
18
+ while (remaining.length > 0) {
19
+ if (remaining.length <= SLACK_MAX_MESSAGE_LENGTH) {
20
+ chunks.push(remaining);
21
+ break;
22
+ }
23
+
24
+ let cutPoint = remaining.lastIndexOf(
25
+ "\n",
26
+ SLACK_MAX_MESSAGE_LENGTH,
27
+ );
28
+ if (cutPoint <= 0) {
29
+ cutPoint = SLACK_MAX_MESSAGE_LENGTH;
30
+ }
31
+
32
+ chunks.push(remaining.slice(0, cutPoint));
33
+ remaining = remaining.slice(cutPoint).replace(/^\n/, "");
34
+ }
35
+
36
+ return chunks;
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Minimal Slack Web API helpers (avoids @slack/web-api dependency)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const SLACK_API = "https://slack.com/api";
44
+
45
+ const slackFetch = async (
46
+ method: string,
47
+ token: string,
48
+ body: Record<string, unknown>,
49
+ ): Promise<{ ok: boolean; error?: string }> => {
50
+ const res = await fetch(`${SLACK_API}/${method}`, {
51
+ method: "POST",
52
+ headers: {
53
+ authorization: `Bearer ${token}`,
54
+ "content-type": "application/json; charset=utf-8",
55
+ },
56
+ body: JSON.stringify(body),
57
+ });
58
+ return (await res.json()) as { ok: boolean; error?: string };
59
+ };
60
+
61
+ export const postMessage = async (
62
+ token: string,
63
+ channel: string,
64
+ text: string,
65
+ threadTs: string,
66
+ ): Promise<void> => {
67
+ const result = await slackFetch("chat.postMessage", token, {
68
+ channel,
69
+ text,
70
+ thread_ts: threadTs,
71
+ });
72
+ if (!result.ok) {
73
+ throw new Error(`Slack chat.postMessage failed: ${result.error}`);
74
+ }
75
+ };
76
+
77
+ export const addReaction = async (
78
+ token: string,
79
+ channel: string,
80
+ timestamp: string,
81
+ reaction: string,
82
+ ): Promise<void> => {
83
+ const result = await slackFetch("reactions.add", token, {
84
+ channel,
85
+ timestamp,
86
+ name: reaction,
87
+ });
88
+ if (!result.ok && result.error !== "already_reacted") {
89
+ throw new Error(`Slack reactions.add failed: ${result.error}`);
90
+ }
91
+ };
92
+
93
+ export const removeReaction = async (
94
+ token: string,
95
+ channel: string,
96
+ timestamp: string,
97
+ reaction: string,
98
+ ): Promise<void> => {
99
+ const result = await slackFetch("reactions.remove", token, {
100
+ channel,
101
+ timestamp,
102
+ name: reaction,
103
+ });
104
+ if (!result.ok && result.error !== "no_reaction") {
105
+ throw new Error(`Slack reactions.remove failed: ${result.error}`);
106
+ }
107
+ };
@@ -0,0 +1,32 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ const MAX_TIMESTAMP_DRIFT_SECONDS = 300; // 5 minutes
4
+
5
+ /**
6
+ * Verify a Slack request signature per
7
+ * https://api.slack.com/authentication/verifying-requests-from-slack
8
+ */
9
+ export const verifySlackSignature = (
10
+ signingSecret: string,
11
+ headers: {
12
+ signature: string | undefined;
13
+ timestamp: string | undefined;
14
+ },
15
+ rawBody: string,
16
+ ): boolean => {
17
+ const { signature, timestamp } = headers;
18
+ if (!signature || !timestamp) return false;
19
+
20
+ const ts = Number(timestamp);
21
+ if (Number.isNaN(ts)) return false;
22
+
23
+ const drift = Math.abs(Math.floor(Date.now() / 1000) - ts);
24
+ if (drift > MAX_TIMESTAMP_DRIFT_SECONDS) return false;
25
+
26
+ const basestring = `v0:${timestamp}:${rawBody}`;
27
+ const computed = `v0=${createHmac("sha256", signingSecret).update(basestring).digest("hex")}`;
28
+
29
+ if (computed.length !== signature.length) return false;
30
+
31
+ return timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
32
+ };
package/src/bridge.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type {
2
+ AgentBridgeOptions,
3
+ IncomingMessage,
4
+ MessagingAdapter,
5
+ AgentRunner,
6
+ ThreadRef,
7
+ } from "./types.js";
8
+
9
+ /**
10
+ * Derive a stable conversation ID from a platform thread reference.
11
+ * Format: `<platform>:<channelId>:<threadId>`
12
+ */
13
+ const conversationIdFromThread = (
14
+ platform: string,
15
+ ref: ThreadRef,
16
+ ): string => `${platform}:${ref.channelId}:${ref.platformThreadId}`;
17
+
18
+ export class AgentBridge {
19
+ private readonly adapter: MessagingAdapter;
20
+ private readonly runner: AgentRunner;
21
+ private readonly waitUntil: (promise: Promise<unknown>) => void;
22
+
23
+ constructor(options: AgentBridgeOptions) {
24
+ this.adapter = options.adapter;
25
+ this.runner = options.runner;
26
+ this.waitUntil = options.waitUntil ?? ((_p: Promise<unknown>) => {});
27
+ }
28
+
29
+ /** Wire the adapter's message handler and initialise. */
30
+ async start(): Promise<void> {
31
+ this.adapter.onMessage((msg) => {
32
+ const processing = this.handleMessage(msg);
33
+ // On serverless (Vercel), waitUntil keeps the function alive after
34
+ // the HTTP 200 response so the agent run completes.
35
+ this.waitUntil(processing);
36
+ return processing;
37
+ });
38
+ await this.adapter.initialize();
39
+ }
40
+
41
+ private async handleMessage(message: IncomingMessage): Promise<void> {
42
+ let cleanup: (() => Promise<void>) | undefined;
43
+
44
+ try {
45
+ cleanup = await this.adapter.indicateProcessing(message.threadRef);
46
+
47
+ const conversationId = conversationIdFromThread(
48
+ message.platform,
49
+ message.threadRef,
50
+ );
51
+
52
+ const conversation = await this.runner.getOrCreateConversation(
53
+ conversationId,
54
+ {
55
+ platform: message.platform,
56
+ ownerId: message.sender.id,
57
+ title: `${message.platform} thread`,
58
+ },
59
+ );
60
+
61
+ const result = await this.runner.run(conversationId, {
62
+ task: message.text,
63
+ messages: conversation.messages,
64
+ });
65
+
66
+ await this.adapter.sendReply(message.threadRef, result.response);
67
+ } catch (error) {
68
+ const snippet =
69
+ error instanceof Error ? error.message : "Unknown error";
70
+ try {
71
+ await this.adapter.sendReply(
72
+ message.threadRef,
73
+ `Sorry, something went wrong: ${snippet}`,
74
+ );
75
+ } catch {
76
+ // Best-effort error reporting — nothing more we can do.
77
+ }
78
+ } finally {
79
+ if (cleanup) {
80
+ try {
81
+ await cleanup();
82
+ } catch {
83
+ // Indicator removal is best-effort.
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type {
2
+ AgentBridgeOptions,
3
+ AgentRunner,
4
+ IncomingMessage,
5
+ IncomingMessageHandler,
6
+ MessagingAdapter,
7
+ RouteHandler,
8
+ RouteRegistrar,
9
+ ThreadRef,
10
+ } from "./types.js";
11
+
12
+ export { AgentBridge } from "./bridge.js";
13
+ export { SlackAdapter } from "./adapters/slack/index.js";
14
+ export type { SlackAdapterOptions } from "./adapters/slack/index.js";
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type http from "node:http";
2
+ import type { Message } from "@poncho-ai/sdk";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Thread & message primitives
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface ThreadRef {
9
+ platformThreadId: string;
10
+ channelId: string;
11
+ /** The specific message ID that triggered this interaction (for reactions). */
12
+ messageId?: string;
13
+ }
14
+
15
+ export interface IncomingMessage {
16
+ text: string;
17
+ threadRef: ThreadRef;
18
+ sender: { id: string; name?: string };
19
+ platform: string;
20
+ raw: unknown;
21
+ }
22
+
23
+ export type IncomingMessageHandler = (
24
+ message: IncomingMessage,
25
+ ) => Promise<void>;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Route registration (adapter ↔ HTTP server contract)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type RouteHandler = (
32
+ req: http.IncomingMessage,
33
+ res: http.ServerResponse,
34
+ ) => Promise<void>;
35
+
36
+ export type RouteRegistrar = (
37
+ method: "GET" | "POST",
38
+ path: string,
39
+ handler: RouteHandler,
40
+ ) => void;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Messaging adapter interface (one per platform)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export interface MessagingAdapter {
47
+ readonly platform: string;
48
+
49
+ /** Register HTTP routes on the host server for receiving platform events. */
50
+ registerRoutes(router: RouteRegistrar): void;
51
+
52
+ /** One-time startup (e.g. validate credentials). */
53
+ initialize(): Promise<void>;
54
+
55
+ /** Set the handler that processes incoming messages. */
56
+ onMessage(handler: IncomingMessageHandler): void;
57
+
58
+ /** Post a reply back to the originating thread. */
59
+ sendReply(threadRef: ThreadRef, content: string): Promise<void>;
60
+
61
+ /**
62
+ * Show a processing indicator (e.g. reaction, typing).
63
+ * Returns a cleanup function that removes the indicator.
64
+ */
65
+ indicateProcessing(
66
+ threadRef: ThreadRef,
67
+ ): Promise<() => Promise<void>>;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Agent runner interface (bridge ↔ agent contract)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export interface AgentRunner {
75
+ getOrCreateConversation(
76
+ conversationId: string,
77
+ meta: { platform: string; ownerId: string; title?: string },
78
+ ): Promise<{ messages: Message[] }>;
79
+
80
+ run(
81
+ conversationId: string,
82
+ input: { task: string; messages: Message[] },
83
+ ): Promise<{ response: string }>;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Bridge options
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export interface AgentBridgeOptions {
91
+ adapter: MessagingAdapter;
92
+ runner: AgentRunner;
93
+ /**
94
+ * Optional hook to keep serverless functions alive after the HTTP response.
95
+ * On Vercel, pass the real `waitUntil` from `@vercel/functions`.
96
+ */
97
+ waitUntil?: (promise: Promise<unknown>) => void;
98
+ }
@@ -0,0 +1,241 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3
+ import { verifySlackSignature } from "../../src/adapters/slack/verify.js";
4
+ import { splitMessage, stripMention } from "../../src/adapters/slack/utils.js";
5
+ import { SlackAdapter } from "../../src/adapters/slack/index.js";
6
+ import type { IncomingMessage as PonchoIncomingMessage } from "../../src/types.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Signature verification
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe("verifySlackSignature", () => {
13
+ const secret = "test-signing-secret";
14
+
15
+ const sign = (body: string, timestamp: number): string => {
16
+ const basestring = `v0:${timestamp}:${body}`;
17
+ return `v0=${createHmac("sha256", secret).update(basestring).digest("hex")}`;
18
+ };
19
+
20
+ it("accepts a valid signature", () => {
21
+ const ts = Math.floor(Date.now() / 1000);
22
+ const body = '{"type":"event_callback"}';
23
+ const sig = sign(body, ts);
24
+
25
+ expect(
26
+ verifySlackSignature(secret, { signature: sig, timestamp: String(ts) }, body),
27
+ ).toBe(true);
28
+ });
29
+
30
+ it("rejects a tampered body", () => {
31
+ const ts = Math.floor(Date.now() / 1000);
32
+ const sig = sign("original", ts);
33
+
34
+ expect(
35
+ verifySlackSignature(secret, { signature: sig, timestamp: String(ts) }, "tampered"),
36
+ ).toBe(false);
37
+ });
38
+
39
+ it("rejects when timestamp is too old", () => {
40
+ const ts = Math.floor(Date.now() / 1000) - 600;
41
+ const body = "body";
42
+ const sig = sign(body, ts);
43
+
44
+ expect(
45
+ verifySlackSignature(secret, { signature: sig, timestamp: String(ts) }, body),
46
+ ).toBe(false);
47
+ });
48
+
49
+ it("rejects missing headers", () => {
50
+ expect(
51
+ verifySlackSignature(secret, { signature: undefined, timestamp: undefined }, "body"),
52
+ ).toBe(false);
53
+ });
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Utility functions
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe("stripMention", () => {
61
+ it("removes a leading bot mention", () => {
62
+ expect(stripMention("<@U12345> what is the weather?")).toBe("what is the weather?");
63
+ });
64
+
65
+ it("handles multiple mentions (only strips leading)", () => {
66
+ expect(stripMention("<@U12345> ping <@U99999>")).toBe("ping <@U99999>");
67
+ });
68
+
69
+ it("returns the text unchanged when no mention", () => {
70
+ expect(stripMention("hello world")).toBe("hello world");
71
+ });
72
+
73
+ it("handles whitespace around the mention", () => {
74
+ expect(stripMention(" <@UABC> hello ")).toBe("hello");
75
+ });
76
+ });
77
+
78
+ describe("splitMessage", () => {
79
+ it("returns a single chunk for short messages", () => {
80
+ expect(splitMessage("short")).toEqual(["short"]);
81
+ });
82
+
83
+ it("splits long messages at newlines", () => {
84
+ const line = "x".repeat(3000);
85
+ const text = `${line}\n${"y".repeat(2000)}`;
86
+ const chunks = splitMessage(text);
87
+ expect(chunks.length).toBe(2);
88
+ expect(chunks[0]).toBe(line);
89
+ expect(chunks[1]).toBe("y".repeat(2000));
90
+ });
91
+
92
+ it("hard-cuts when there are no newlines", () => {
93
+ const text = "a".repeat(5000);
94
+ const chunks = splitMessage(text);
95
+ expect(chunks.length).toBe(2);
96
+ expect(chunks[0]!.length).toBe(4000);
97
+ expect(chunks[1]!.length).toBe(1000);
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // SlackAdapter
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("SlackAdapter", () => {
106
+ const secret = "test-secret";
107
+ const token = "xoxb-test-token";
108
+
109
+ beforeEach(() => {
110
+ process.env.SLACK_BOT_TOKEN = token;
111
+ process.env.SLACK_SIGNING_SECRET = secret;
112
+ });
113
+
114
+ afterEach(() => {
115
+ delete process.env.SLACK_BOT_TOKEN;
116
+ delete process.env.SLACK_SIGNING_SECRET;
117
+ vi.restoreAllMocks();
118
+ });
119
+
120
+ const makeReqRes = (
121
+ body: string,
122
+ headers: Record<string, string> = {},
123
+ ): { req: any; res: any; resBody: () => string; resStatus: () => number } => {
124
+ const ts = String(Math.floor(Date.now() / 1000));
125
+ const sig = `v0=${createHmac("sha256", secret).update(`v0:${ts}:${body}`).digest("hex")}`;
126
+
127
+ let _resBody = "";
128
+ let _resStatus = 0;
129
+ const dataListeners: Array<(chunk: Buffer) => void> = [];
130
+ const endListeners: Array<() => void> = [];
131
+ const errorListeners: Array<(err: Error) => void> = [];
132
+
133
+ const req = {
134
+ method: "POST",
135
+ url: "/api/messaging/slack",
136
+ headers: {
137
+ "x-slack-signature": sig,
138
+ "x-slack-request-timestamp": ts,
139
+ ...headers,
140
+ },
141
+ on(event: string, cb: (...args: any[]) => void) {
142
+ if (event === "data") {
143
+ dataListeners.push(cb as any);
144
+ } else if (event === "end") {
145
+ endListeners.push(cb as any);
146
+ } else if (event === "error") {
147
+ errorListeners.push(cb as any);
148
+ }
149
+ // Fire body data on next tick after both data+end listeners are registered
150
+ if (dataListeners.length > 0 && endListeners.length > 0) {
151
+ queueMicrotask(() => {
152
+ for (const fn of dataListeners) fn(Buffer.from(body));
153
+ for (const fn of endListeners) fn();
154
+ });
155
+ }
156
+ return req;
157
+ },
158
+ };
159
+
160
+ const res = {
161
+ writeHead(status: number, _headers?: Record<string, string>) {
162
+ _resStatus = status;
163
+ },
164
+ end(data?: string) {
165
+ _resBody = data ?? "";
166
+ },
167
+ };
168
+
169
+ return {
170
+ req,
171
+ res,
172
+ resBody: () => _resBody,
173
+ resStatus: () => _resStatus,
174
+ };
175
+ };
176
+
177
+ it("initializes successfully with valid env vars", async () => {
178
+ const adapter = new SlackAdapter();
179
+ await expect(adapter.initialize()).resolves.not.toThrow();
180
+ });
181
+
182
+ it("throws on missing SLACK_BOT_TOKEN", async () => {
183
+ delete process.env.SLACK_BOT_TOKEN;
184
+ const adapter = new SlackAdapter();
185
+ await expect(adapter.initialize()).rejects.toThrow("SLACK_BOT_TOKEN");
186
+ });
187
+
188
+ it("throws on missing SLACK_SIGNING_SECRET", async () => {
189
+ delete process.env.SLACK_SIGNING_SECRET;
190
+ const adapter = new SlackAdapter();
191
+ await expect(adapter.initialize()).rejects.toThrow("SLACK_SIGNING_SECRET");
192
+ });
193
+
194
+ it("registers a POST route at /api/messaging/slack", () => {
195
+ const adapter = new SlackAdapter();
196
+ const routes: Array<{ method: string; path: string }> = [];
197
+ adapter.registerRoutes((method, path) => {
198
+ routes.push({ method, path });
199
+ });
200
+ expect(routes).toEqual([{ method: "POST", path: "/api/messaging/slack" }]);
201
+ });
202
+
203
+ it("handles url_verification challenge", async () => {
204
+ const adapter = new SlackAdapter();
205
+ await adapter.initialize();
206
+
207
+ let handler: any;
208
+ adapter.registerRoutes((_m, _p, h) => { handler = h; });
209
+
210
+ const body = JSON.stringify({ type: "url_verification", challenge: "abc123" });
211
+ const { req, res, resBody } = makeReqRes(body);
212
+
213
+ await handler(req, res);
214
+
215
+ const parsed = JSON.parse(resBody());
216
+ expect(parsed.challenge).toBe("abc123");
217
+ });
218
+
219
+ it("skips retry requests", async () => {
220
+ const adapter = new SlackAdapter();
221
+ await adapter.initialize();
222
+ const received: PonchoIncomingMessage[] = [];
223
+ adapter.onMessage(async (msg) => { received.push(msg); });
224
+
225
+ let handler: any;
226
+ adapter.registerRoutes((_m, _p, h) => { handler = h; });
227
+
228
+ const body = JSON.stringify({
229
+ type: "event_callback",
230
+ event: { type: "app_mention", text: "<@U1> hi", ts: "1", channel: "C1", user: "U2" },
231
+ });
232
+ const { req, res, resStatus } = makeReqRes(body, { "x-slack-retry-num": "1" });
233
+
234
+ await handler(req, res);
235
+
236
+ // Give time for any async handler to fire
237
+ await new Promise((r) => setTimeout(r, 50));
238
+ expect(received).toHaveLength(0);
239
+ expect(resStatus()).toBe(200);
240
+ });
241
+ });