@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.
- package/.turbo/turbo-build.log +14 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +290 -0
- package/package.json +42 -0
- package/src/adapters/slack/index.ts +214 -0
- package/src/adapters/slack/utils.ts +107 -0
- package/src/adapters/slack/verify.ts +32 -0
- package/src/bridge.ts +88 -0
- package/src/index.ts +14 -0
- package/src/types.ts +98 -0
- package/test/adapters/slack.test.ts +241 -0
- package/test/bridge.test.ts +165 -0
- package/tsconfig.json +8 -0
|
@@ -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
|
+
[34mCLI[39m Building entry: src/index.ts
|
|
6
|
+
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
+
[34mCLI[39m tsup v8.5.1
|
|
8
|
+
[34mCLI[39m Target: es2022
|
|
9
|
+
[34mESM[39m Build start
|
|
10
|
+
[32mESM[39m [1mdist/index.js [22m[32m8.45 KB[39m
|
|
11
|
+
[32mESM[39m ⚡️ Build success in 29ms
|
|
12
|
+
[34mDTS[39m Build start
|
|
13
|
+
[32mDTS[39m ⚡️ Build success in 2735ms
|
|
14
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m3.23 KB[39m
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|