@vellumai/vellum-gateway 0.4.8 → 0.4.11
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/package.json +1 -1
- package/src/slack/normalize.ts +80 -1
- package/src/slack/socket-mode.ts +97 -24
package/package.json
CHANGED
package/src/slack/normalize.ts
CHANGED
|
@@ -17,6 +17,22 @@ export interface SlackAppMentionEvent {
|
|
|
17
17
|
event_ts?: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Slack `message` event shape for direct messages (IMs).
|
|
22
|
+
*/
|
|
23
|
+
export interface SlackDirectMessageEvent {
|
|
24
|
+
type: "message";
|
|
25
|
+
subtype?: string;
|
|
26
|
+
user?: string;
|
|
27
|
+
text: string;
|
|
28
|
+
ts: string;
|
|
29
|
+
channel: string;
|
|
30
|
+
channel_type: "im";
|
|
31
|
+
thread_ts?: string;
|
|
32
|
+
client_msg_id?: string;
|
|
33
|
+
event_ts?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* Strip leading bot-mention tokens (`<@U...>`) from the message text.
|
|
22
38
|
* Slack wraps mentions as `<@UXXXXXX>`, often at the start of an
|
|
@@ -37,6 +53,68 @@ export type NormalizedSlackEvent = {
|
|
|
37
53
|
channel: string;
|
|
38
54
|
};
|
|
39
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a Slack DM (`message` with `channel_type: "im"`) into the
|
|
58
|
+
* gateway's canonical inbound event shape. Used for guardian verification
|
|
59
|
+
* code replies and direct conversations with the bot.
|
|
60
|
+
*
|
|
61
|
+
* Returns null if the event cannot be routed or should be ignored
|
|
62
|
+
* (e.g. bot's own messages, subtypes like message_changed).
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeSlackDirectMessage(
|
|
65
|
+
event: SlackDirectMessageEvent,
|
|
66
|
+
eventId: string,
|
|
67
|
+
config: GatewayConfig,
|
|
68
|
+
botUserId?: string,
|
|
69
|
+
): NormalizedSlackEvent | null {
|
|
70
|
+
// Ignore messages from the bot itself
|
|
71
|
+
if (botUserId && event.user === botUserId) return null;
|
|
72
|
+
// Ignore message subtypes (edits, deletions, etc.) — only handle plain user messages
|
|
73
|
+
if (event.subtype) return null;
|
|
74
|
+
// user is required for routing
|
|
75
|
+
if (!event.user) return null;
|
|
76
|
+
|
|
77
|
+
// DMs are always directed at the bot, so use the default assistant even
|
|
78
|
+
// when the DM channel ID (D...) isn't in the routing table. This ensures
|
|
79
|
+
// guardian verification replies aren't silently dropped.
|
|
80
|
+
let routing = resolveAssistant(config, event.channel, event.user);
|
|
81
|
+
if (isRejection(routing) && config.defaultAssistantId) {
|
|
82
|
+
routing = {
|
|
83
|
+
assistantId: config.defaultAssistantId,
|
|
84
|
+
routeSource: "default" as const,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (isRejection(routing)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const externalMessageId =
|
|
92
|
+
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
event: {
|
|
96
|
+
version: "v1",
|
|
97
|
+
sourceChannel: "slack",
|
|
98
|
+
receivedAt: new Date().toISOString(),
|
|
99
|
+
message: {
|
|
100
|
+
content: event.text,
|
|
101
|
+
conversationExternalId: event.channel,
|
|
102
|
+
externalMessageId,
|
|
103
|
+
},
|
|
104
|
+
actor: {
|
|
105
|
+
actorExternalId: event.user,
|
|
106
|
+
},
|
|
107
|
+
source: {
|
|
108
|
+
updateId: eventId,
|
|
109
|
+
},
|
|
110
|
+
raw: event as unknown as Record<string, unknown>,
|
|
111
|
+
},
|
|
112
|
+
routing,
|
|
113
|
+
threadTs: event.thread_ts ?? event.ts,
|
|
114
|
+
channel: event.channel,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
40
118
|
/**
|
|
41
119
|
* Normalize a Slack `app_mention` event into the gateway's
|
|
42
120
|
* canonical inbound event shape, matching the pattern used by
|
|
@@ -55,7 +133,8 @@ export function normalizeSlackAppMention(
|
|
|
55
133
|
}
|
|
56
134
|
|
|
57
135
|
const content = stripBotMention(event.text);
|
|
58
|
-
const externalMessageId =
|
|
136
|
+
const externalMessageId =
|
|
137
|
+
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
59
138
|
|
|
60
139
|
return {
|
|
61
140
|
event: {
|
package/src/slack/socket-mode.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { getLogger } from "../logger.js";
|
|
2
2
|
import { fetchImpl } from "../fetch.js";
|
|
3
3
|
import type { GatewayConfig } from "../config.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
normalizeSlackAppMention,
|
|
6
|
+
normalizeSlackDirectMessage,
|
|
7
|
+
type SlackAppMentionEvent,
|
|
8
|
+
type SlackDirectMessageEvent,
|
|
9
|
+
type NormalizedSlackEvent,
|
|
10
|
+
} from "./normalize.js";
|
|
5
11
|
|
|
6
12
|
const log = getLogger("slack-socket-mode");
|
|
7
13
|
|
|
@@ -14,6 +20,8 @@ export type SlackSocketModeConfig = {
|
|
|
14
20
|
appToken: string;
|
|
15
21
|
botToken: string;
|
|
16
22
|
gatewayConfig: GatewayConfig;
|
|
23
|
+
/** Bot's own Slack user ID, used to ignore the bot's own DMs. */
|
|
24
|
+
botUserId?: string;
|
|
17
25
|
};
|
|
18
26
|
|
|
19
27
|
/**
|
|
@@ -34,7 +42,10 @@ export class SlackSocketModeClient {
|
|
|
34
42
|
private dedupMap = new Map<string, number>();
|
|
35
43
|
private dedupCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
36
44
|
|
|
37
|
-
constructor(
|
|
45
|
+
constructor(
|
|
46
|
+
config: SlackSocketModeConfig,
|
|
47
|
+
onEvent: (event: NormalizedSlackEvent) => void,
|
|
48
|
+
) {
|
|
38
49
|
this.config = config;
|
|
39
50
|
this.onEvent = onEvent;
|
|
40
51
|
}
|
|
@@ -43,6 +54,24 @@ export class SlackSocketModeClient {
|
|
|
43
54
|
if (this.running) return;
|
|
44
55
|
this.running = true;
|
|
45
56
|
this.startDedupCleanup();
|
|
57
|
+
|
|
58
|
+
// Resolve bot user ID via auth.test so we can filter the bot's own DMs
|
|
59
|
+
if (!this.config.botUserId) {
|
|
60
|
+
try {
|
|
61
|
+
const resp = await fetchImpl("https://slack.com/api/auth.test", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { Authorization: `Bearer ${this.config.botToken}` },
|
|
64
|
+
});
|
|
65
|
+
const data = (await resp.json()) as { ok: boolean; user_id?: string };
|
|
66
|
+
if (data.ok && data.user_id) {
|
|
67
|
+
this.config.botUserId = data.user_id;
|
|
68
|
+
log.info({ botUserId: data.user_id }, "Resolved Slack bot user ID");
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log.warn({ err }, "Failed to resolve bot user ID via auth.test");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
await this.connect();
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -91,13 +120,19 @@ export class SlackSocketModeClient {
|
|
|
91
120
|
});
|
|
92
121
|
|
|
93
122
|
ws.addEventListener("close", (closeEvent) => {
|
|
94
|
-
log.info(
|
|
123
|
+
log.info(
|
|
124
|
+
{ code: closeEvent.code, reason: closeEvent.reason },
|
|
125
|
+
"Slack Socket Mode disconnected",
|
|
126
|
+
);
|
|
95
127
|
this.ws = null;
|
|
96
128
|
this.scheduleReconnect();
|
|
97
129
|
});
|
|
98
130
|
|
|
99
131
|
ws.addEventListener("error", (errorEvent) => {
|
|
100
|
-
log.error(
|
|
132
|
+
log.error(
|
|
133
|
+
{ error: String(errorEvent) },
|
|
134
|
+
"Slack Socket Mode WebSocket error",
|
|
135
|
+
);
|
|
101
136
|
});
|
|
102
137
|
} catch (err) {
|
|
103
138
|
log.error({ err }, "Failed to create WebSocket connection");
|
|
@@ -107,21 +142,30 @@ export class SlackSocketModeClient {
|
|
|
107
142
|
}
|
|
108
143
|
|
|
109
144
|
private async getWebSocketUrl(): Promise<string> {
|
|
110
|
-
const resp = await fetchImpl(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
const resp = await fetchImpl(
|
|
146
|
+
"https://slack.com/api/apps.connections.open",
|
|
147
|
+
{
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: `Bearer ${this.config.appToken}`,
|
|
151
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
152
|
+
},
|
|
115
153
|
},
|
|
116
|
-
|
|
154
|
+
);
|
|
117
155
|
|
|
118
156
|
if (!resp.ok) {
|
|
119
157
|
throw new Error(`apps.connections.open HTTP ${resp.status}`);
|
|
120
158
|
}
|
|
121
159
|
|
|
122
|
-
const data = (await resp.json()) as {
|
|
160
|
+
const data = (await resp.json()) as {
|
|
161
|
+
ok: boolean;
|
|
162
|
+
url?: string;
|
|
163
|
+
error?: string;
|
|
164
|
+
};
|
|
123
165
|
if (!data.ok || !data.url) {
|
|
124
|
-
throw new Error(
|
|
166
|
+
throw new Error(
|
|
167
|
+
`apps.connections.open failed: ${data.error ?? "unknown error"}`,
|
|
168
|
+
);
|
|
125
169
|
}
|
|
126
170
|
|
|
127
171
|
return data.url;
|
|
@@ -133,7 +177,7 @@ export class SlackSocketModeClient {
|
|
|
133
177
|
type?: string;
|
|
134
178
|
payload?: {
|
|
135
179
|
event_id?: string;
|
|
136
|
-
event?: SlackAppMentionEvent;
|
|
180
|
+
event?: SlackAppMentionEvent | SlackDirectMessageEvent;
|
|
137
181
|
};
|
|
138
182
|
reason?: string;
|
|
139
183
|
};
|
|
@@ -146,13 +190,20 @@ export class SlackSocketModeClient {
|
|
|
146
190
|
}
|
|
147
191
|
|
|
148
192
|
// ACK every envelope immediately
|
|
149
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
envelope.envelope_id &&
|
|
195
|
+
this.ws &&
|
|
196
|
+
this.ws.readyState === WebSocket.OPEN
|
|
197
|
+
) {
|
|
150
198
|
this.ws.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
151
199
|
}
|
|
152
200
|
|
|
153
201
|
// Handle disconnect type: Slack asks us to reconnect
|
|
154
202
|
if (envelope.type === "disconnect") {
|
|
155
|
-
log.info(
|
|
203
|
+
log.info(
|
|
204
|
+
{ reason: envelope.reason },
|
|
205
|
+
"Slack requested disconnect, reconnecting",
|
|
206
|
+
);
|
|
156
207
|
if (this.ws) {
|
|
157
208
|
try {
|
|
158
209
|
this.ws.close(1000, "server requested disconnect");
|
|
@@ -173,8 +224,16 @@ export class SlackSocketModeClient {
|
|
|
173
224
|
const eventPayload = envelope.payload;
|
|
174
225
|
if (!eventPayload?.event || !eventPayload.event_id) return;
|
|
175
226
|
|
|
176
|
-
|
|
177
|
-
|
|
227
|
+
const event = eventPayload.event;
|
|
228
|
+
const dmEvent = event as SlackDirectMessageEvent;
|
|
229
|
+
|
|
230
|
+
// Process app_mention events and DM (message with channel_type: "im") events
|
|
231
|
+
if (
|
|
232
|
+
event.type !== "app_mention" &&
|
|
233
|
+
!(event.type === "message" && dmEvent.channel_type === "im")
|
|
234
|
+
) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
178
237
|
|
|
179
238
|
// Deduplicate on event_id
|
|
180
239
|
const eventId = eventPayload.event_id;
|
|
@@ -184,14 +243,25 @@ export class SlackSocketModeClient {
|
|
|
184
243
|
}
|
|
185
244
|
this.dedupMap.set(eventId, Date.now());
|
|
186
245
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
246
|
+
let normalized: NormalizedSlackEvent | null;
|
|
247
|
+
if (event.type === "app_mention") {
|
|
248
|
+
normalized = normalizeSlackAppMention(
|
|
249
|
+
event as SlackAppMentionEvent,
|
|
250
|
+
eventId,
|
|
251
|
+
this.config.gatewayConfig,
|
|
252
|
+
);
|
|
253
|
+
} else {
|
|
254
|
+
normalized = normalizeSlackDirectMessage(
|
|
255
|
+
event as SlackDirectMessageEvent,
|
|
256
|
+
eventId,
|
|
257
|
+
this.config.gatewayConfig,
|
|
258
|
+
this.config.botUserId,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
192
262
|
if (!normalized) {
|
|
193
263
|
log.info(
|
|
194
|
-
{ eventId, channel:
|
|
264
|
+
{ eventId, channel: event.channel, type: event.type },
|
|
195
265
|
"Slack event dropped by normalization/routing",
|
|
196
266
|
);
|
|
197
267
|
return;
|
|
@@ -212,7 +282,10 @@ export class SlackSocketModeClient {
|
|
|
212
282
|
const jitter = Math.random() * backoff * 0.5;
|
|
213
283
|
const delay = Math.round(backoff + jitter);
|
|
214
284
|
|
|
215
|
-
log.info(
|
|
285
|
+
log.info(
|
|
286
|
+
{ attempt: this.reconnectAttempt, delayMs: delay },
|
|
287
|
+
"Scheduling Socket Mode reconnect",
|
|
288
|
+
);
|
|
216
289
|
this.reconnectAttempt++;
|
|
217
290
|
|
|
218
291
|
this.reconnectTimer = setTimeout(() => {
|