@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.8",
3
+ "version": "0.4.11",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -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 = event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
136
+ const externalMessageId =
137
+ event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
59
138
 
60
139
  return {
61
140
  event: {
@@ -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 { normalizeSlackAppMention, type SlackAppMentionEvent, type NormalizedSlackEvent } from "./normalize.js";
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(config: SlackSocketModeConfig, onEvent: (event: NormalizedSlackEvent) => void) {
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({ code: closeEvent.code, reason: closeEvent.reason }, "Slack Socket Mode disconnected");
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({ error: String(errorEvent) }, "Slack Socket Mode WebSocket 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("https://slack.com/api/apps.connections.open", {
111
- method: "POST",
112
- headers: {
113
- Authorization: `Bearer ${this.config.appToken}`,
114
- "Content-Type": "application/x-www-form-urlencoded",
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 { ok: boolean; url?: string; error?: string };
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(`apps.connections.open failed: ${data.error ?? "unknown 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 (envelope.envelope_id && this.ws && this.ws.readyState === WebSocket.OPEN) {
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({ reason: envelope.reason }, "Slack requested disconnect, reconnecting");
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
- // Only process app_mention events in MVP
177
- if (eventPayload.event.type !== "app_mention") return;
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
- const normalized = normalizeSlackAppMention(
188
- eventPayload.event,
189
- eventId,
190
- this.config.gatewayConfig,
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: eventPayload.event.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({ attempt: this.reconnectAttempt, delayMs: delay }, "Scheduling Socket Mode reconnect");
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(() => {