@vellumai/cli 0.11.0-dev.202607302326.a10ae93 → 0.11.0-dev.202607310047.8d3af62
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/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/conversation-handle.test.ts +316 -0
- package/node_modules/@vellumai/service-contracts/src/conversation-handle.ts +246 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./channels": "./src/channels.ts",
|
|
10
|
+
"./conversation-handle": "./src/conversation-handle.ts",
|
|
10
11
|
"./client-metadata": "./src/client-metadata.ts",
|
|
11
12
|
"./credential-rpc": "./src/credential-rpc.ts",
|
|
12
13
|
"./ingress": "./src/ingress.ts",
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { CHANNEL_IDS } from "../channels.js";
|
|
4
|
+
import {
|
|
5
|
+
CONVERSATION_HANDLE_PROVIDERS,
|
|
6
|
+
ConversationHandleSchema,
|
|
7
|
+
deserializeConversationHandle,
|
|
8
|
+
safeDeserializeConversationHandle,
|
|
9
|
+
serializeConversationHandle,
|
|
10
|
+
type ConversationHandle,
|
|
11
|
+
} from "../conversation-handle.js";
|
|
12
|
+
|
|
13
|
+
// Representative handles, one per provider surface the contract must cover.
|
|
14
|
+
const slackChannel: ConversationHandle = {
|
|
15
|
+
provider: "slack",
|
|
16
|
+
connectionId: "conn-slack-1",
|
|
17
|
+
teamId: "T0123ABCD",
|
|
18
|
+
channelId: "C0123ABCD",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const slackThread: ConversationHandle = {
|
|
22
|
+
provider: "slack",
|
|
23
|
+
connectionId: "conn-slack-1",
|
|
24
|
+
teamId: "T0123ABCD",
|
|
25
|
+
channelId: "C0123ABCD",
|
|
26
|
+
threadTs: "1700000000.000100",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const slackDm: ConversationHandle = {
|
|
30
|
+
provider: "slack",
|
|
31
|
+
connectionId: "conn-slack-1",
|
|
32
|
+
teamId: "T0123ABCD",
|
|
33
|
+
channelId: "D0456EFGH",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const telegramChat: ConversationHandle = {
|
|
37
|
+
provider: "telegram",
|
|
38
|
+
connectionId: "conn-telegram-1",
|
|
39
|
+
chatId: "123456789",
|
|
40
|
+
subconversation: { kind: "chat" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const telegramMessageThread: ConversationHandle = {
|
|
44
|
+
provider: "telegram",
|
|
45
|
+
connectionId: "conn-telegram-1",
|
|
46
|
+
chatId: "-1001234567890",
|
|
47
|
+
subconversation: { kind: "message_thread", id: "42" },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const telegramDirectMessagesTopic: ConversationHandle = {
|
|
51
|
+
provider: "telegram",
|
|
52
|
+
connectionId: "conn-telegram-1",
|
|
53
|
+
chatId: "-1009876543210",
|
|
54
|
+
subconversation: { kind: "direct_messages_topic", id: "77" },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const discordChannel: ConversationHandle = {
|
|
58
|
+
provider: "discord",
|
|
59
|
+
connectionId: "conn-discord-1",
|
|
60
|
+
guildId: "123456789012345678",
|
|
61
|
+
channelId: "223456789012345678",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const discordThread: ConversationHandle = {
|
|
65
|
+
provider: "discord",
|
|
66
|
+
connectionId: "conn-discord-1",
|
|
67
|
+
guildId: "123456789012345678",
|
|
68
|
+
channelId: "223456789012345678",
|
|
69
|
+
threadId: "323456789012345678",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const REPRESENTATIVE_HANDLES: ConversationHandle[] = [
|
|
73
|
+
slackChannel,
|
|
74
|
+
slackThread,
|
|
75
|
+
slackDm,
|
|
76
|
+
telegramChat,
|
|
77
|
+
telegramMessageThread,
|
|
78
|
+
telegramDirectMessagesTopic,
|
|
79
|
+
discordChannel,
|
|
80
|
+
discordThread,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
describe("CONVERSATION_HANDLE_PROVIDERS", () => {
|
|
84
|
+
test("is a subset of the canonical channel ids", () => {
|
|
85
|
+
for (const provider of CONVERSATION_HANDLE_PROVIDERS) {
|
|
86
|
+
expect(CHANNEL_IDS).toContain(provider);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("round-trip", () => {
|
|
92
|
+
test.each(REPRESENTATIVE_HANDLES.map((h) => [h] as const))(
|
|
93
|
+
"serialization and schema parsing preserve %j",
|
|
94
|
+
(handle) => {
|
|
95
|
+
const wire = serializeConversationHandle(handle);
|
|
96
|
+
expect(safeDeserializeConversationHandle(wire)).toEqual(handle);
|
|
97
|
+
expect(deserializeConversationHandle(wire)).toEqual(handle);
|
|
98
|
+
// The wire form is plain JSON, so a generic JSON hop plus schema parse
|
|
99
|
+
// reconstructs the same handle.
|
|
100
|
+
expect(ConversationHandleSchema.parse(JSON.parse(wire))).toEqual(handle);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("provider discrimination", () => {
|
|
106
|
+
test("rejects unknown providers", () => {
|
|
107
|
+
expect(
|
|
108
|
+
ConversationHandleSchema.safeParse({
|
|
109
|
+
provider: "mastodon",
|
|
110
|
+
connectionId: "conn-1",
|
|
111
|
+
}).success,
|
|
112
|
+
).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("rejects canonical channels that have no handle shape", () => {
|
|
116
|
+
// whatsapp is a canonical ChannelId but not (yet) a handle provider, so
|
|
117
|
+
// it must fail schema parsing rather than fall through to some default.
|
|
118
|
+
expect(
|
|
119
|
+
ConversationHandleSchema.safeParse({
|
|
120
|
+
provider: "whatsapp",
|
|
121
|
+
connectionId: "conn-1",
|
|
122
|
+
chatId: "+15555550100",
|
|
123
|
+
}).success,
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("rejects cross-provider coordinate mixes", () => {
|
|
128
|
+
// Telegram handle carrying Slack routing fields.
|
|
129
|
+
expect(
|
|
130
|
+
ConversationHandleSchema.safeParse({
|
|
131
|
+
provider: "telegram",
|
|
132
|
+
connectionId: "conn-1",
|
|
133
|
+
teamId: "T0123ABCD",
|
|
134
|
+
channelId: "C0123ABCD",
|
|
135
|
+
}).success,
|
|
136
|
+
).toBe(false);
|
|
137
|
+
// Slack handle carrying Telegram routing fields.
|
|
138
|
+
expect(
|
|
139
|
+
ConversationHandleSchema.safeParse({
|
|
140
|
+
...slackChannel,
|
|
141
|
+
chatId: "123456789",
|
|
142
|
+
}).success,
|
|
143
|
+
).toBe(false);
|
|
144
|
+
// Discord handle carrying a Telegram subconversation.
|
|
145
|
+
expect(
|
|
146
|
+
ConversationHandleSchema.safeParse({
|
|
147
|
+
...discordChannel,
|
|
148
|
+
subconversation: { kind: "chat" },
|
|
149
|
+
}).success,
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("telegram subconversations", () => {
|
|
155
|
+
test("chat, message_thread, and direct_messages_topic are distinct", () => {
|
|
156
|
+
const parsedThread = ConversationHandleSchema.parse(telegramMessageThread);
|
|
157
|
+
const parsedTopic = ConversationHandleSchema.parse(
|
|
158
|
+
telegramDirectMessagesTopic,
|
|
159
|
+
);
|
|
160
|
+
if (
|
|
161
|
+
parsedThread.provider !== "telegram" ||
|
|
162
|
+
parsedTopic.provider !== "telegram"
|
|
163
|
+
) {
|
|
164
|
+
throw new Error("expected telegram handles");
|
|
165
|
+
}
|
|
166
|
+
expect(parsedThread.subconversation.kind).toBe("message_thread");
|
|
167
|
+
expect(parsedTopic.subconversation.kind).toBe("direct_messages_topic");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("rejects a chat subconversation carrying an id", () => {
|
|
171
|
+
expect(
|
|
172
|
+
ConversationHandleSchema.safeParse({
|
|
173
|
+
...telegramChat,
|
|
174
|
+
subconversation: { kind: "chat", id: "42" },
|
|
175
|
+
}).success,
|
|
176
|
+
).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("rejects a message_thread subconversation missing its id", () => {
|
|
180
|
+
expect(
|
|
181
|
+
ConversationHandleSchema.safeParse({
|
|
182
|
+
...telegramChat,
|
|
183
|
+
subconversation: { kind: "message_thread" },
|
|
184
|
+
}).success,
|
|
185
|
+
).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("rejects an unknown subconversation kind", () => {
|
|
189
|
+
expect(
|
|
190
|
+
ConversationHandleSchema.safeParse({
|
|
191
|
+
...telegramChat,
|
|
192
|
+
subconversation: { kind: "topic", id: "42" },
|
|
193
|
+
}).success,
|
|
194
|
+
).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("rejects flattened untyped topic fields", () => {
|
|
198
|
+
// message_thread_id and direct_messages_topic_id must not ride along as
|
|
199
|
+
// loose fields next to (or instead of) the typed subconversation.
|
|
200
|
+
for (const extra of [
|
|
201
|
+
{ topicId: "42" },
|
|
202
|
+
{ messageThreadId: "42" },
|
|
203
|
+
{ directMessagesTopicId: "42" },
|
|
204
|
+
]) {
|
|
205
|
+
expect(
|
|
206
|
+
ConversationHandleSchema.safeParse({
|
|
207
|
+
...telegramChat,
|
|
208
|
+
...extra,
|
|
209
|
+
}).success,
|
|
210
|
+
).toBe(false);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("message coordinates stay out of the handle", () => {
|
|
216
|
+
test.each([
|
|
217
|
+
[{ ...telegramChat, messageId: "9001" }],
|
|
218
|
+
[{ ...telegramChat, replyToMessageId: "9001" }],
|
|
219
|
+
[{ ...slackThread, messageTs: "1700000000.000200" }],
|
|
220
|
+
[{ ...discordThread, messageId: "423456789012345678" }],
|
|
221
|
+
])("rejects %j", (value) => {
|
|
222
|
+
expect(ConversationHandleSchema.safeParse(value).success).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("rejects a smuggled internal conversationId", () => {
|
|
226
|
+
// The Vellum internal conversation id is a separate identity and never
|
|
227
|
+
// lives inside the provider handle.
|
|
228
|
+
expect(
|
|
229
|
+
ConversationHandleSchema.safeParse({
|
|
230
|
+
...slackChannel,
|
|
231
|
+
conversationId: "conv-xyz",
|
|
232
|
+
}).success,
|
|
233
|
+
).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("field validation", () => {
|
|
238
|
+
test("requires a non-empty connectionId on every variant", () => {
|
|
239
|
+
for (const handle of [slackChannel, telegramChat, discordChannel]) {
|
|
240
|
+
expect(
|
|
241
|
+
ConversationHandleSchema.safeParse({
|
|
242
|
+
...handle,
|
|
243
|
+
connectionId: "",
|
|
244
|
+
}).success,
|
|
245
|
+
).toBe(false);
|
|
246
|
+
const { connectionId: _omitted, ...withoutConnection } = handle;
|
|
247
|
+
expect(ConversationHandleSchema.safeParse(withoutConnection).success).toBe(
|
|
248
|
+
false,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("rejects whitespace and control characters in connectionId", () => {
|
|
254
|
+
expect(
|
|
255
|
+
ConversationHandleSchema.safeParse({
|
|
256
|
+
...slackChannel,
|
|
257
|
+
connectionId: "conn 1",
|
|
258
|
+
}).success,
|
|
259
|
+
).toBe(false);
|
|
260
|
+
expect(
|
|
261
|
+
ConversationHandleSchema.safeParse({
|
|
262
|
+
...slackChannel,
|
|
263
|
+
connectionId: "conn\n1",
|
|
264
|
+
}).success,
|
|
265
|
+
).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test.each([
|
|
269
|
+
[{ ...slackChannel, teamId: "C0123ABCD" }],
|
|
270
|
+
[{ ...slackChannel, channelId: "T0123ABCD" }],
|
|
271
|
+
[{ ...slackThread, threadTs: "not-a-ts" }],
|
|
272
|
+
// The gateway's block-actions reply routing falls back to a Slack
|
|
273
|
+
// envelope id (a UUID) when no thread_ts exists. A handle only ever
|
|
274
|
+
// carries a real thread_ts, so envelope-id-shaped values must fail here
|
|
275
|
+
// instead of becoming durable identity.
|
|
276
|
+
[{ ...slackThread, threadTs: "8a17bf85-6a58-4b60-8c81-63d8b5ab8c73" }],
|
|
277
|
+
[{ ...telegramChat, chatId: "12ab34" }],
|
|
278
|
+
[{ ...telegramMessageThread, subconversation: { kind: "message_thread", id: "-42" } }],
|
|
279
|
+
[{ ...discordChannel, guildId: "123456789012345678901" }],
|
|
280
|
+
[{ ...discordThread, threadId: "thread-1" }],
|
|
281
|
+
])("rejects malformed coordinates in %j", (value) => {
|
|
282
|
+
expect(ConversationHandleSchema.safeParse(value).success).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("coordinates are strings, not numbers", () => {
|
|
286
|
+
expect(
|
|
287
|
+
ConversationHandleSchema.safeParse({
|
|
288
|
+
...telegramChat,
|
|
289
|
+
chatId: 123456789,
|
|
290
|
+
}).success,
|
|
291
|
+
).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("serialization helpers", () => {
|
|
296
|
+
test("serializeConversationHandle validates before serializing", () => {
|
|
297
|
+
expect(() =>
|
|
298
|
+
serializeConversationHandle({
|
|
299
|
+
...telegramChat,
|
|
300
|
+
chatId: "not-a-chat-id",
|
|
301
|
+
} as ConversationHandle),
|
|
302
|
+
).toThrow();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("safeDeserializeConversationHandle returns null on malformed input", () => {
|
|
306
|
+
expect(safeDeserializeConversationHandle("not json")).toBeNull();
|
|
307
|
+
expect(safeDeserializeConversationHandle("42")).toBeNull();
|
|
308
|
+
expect(safeDeserializeConversationHandle('{"provider":"slack"}')).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("deserializeConversationHandle throws on malformed input", () => {
|
|
312
|
+
expect(() => deserializeConversationHandle("not json")).toThrow(
|
|
313
|
+
"not a valid conversation handle serialization",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable conversation handles: the external provider conversation a Vellum
|
|
3
|
+
* conversation is bound to, in a serializable, schema-validated form.
|
|
4
|
+
*
|
|
5
|
+
* A handle answers "which provider conversation, reached through which Vellum
|
|
6
|
+
* channel connection". The same handle is usable for inbound conversation
|
|
7
|
+
* binding and for later outbound delivery, so it carries exactly two things:
|
|
8
|
+
* the connection identity needed to recover the correct provider credentials,
|
|
9
|
+
* and the provider-native routing coordinates. Message coordinates (message
|
|
10
|
+
* ids, reply-to relationships), actors, and content are per-event data and are
|
|
11
|
+
* deliberately not part of the handle.
|
|
12
|
+
*
|
|
13
|
+
* The handle names the provider-side conversation only. Vellum's internal
|
|
14
|
+
* `conversationId` is a separate identity and never appears inside a handle;
|
|
15
|
+
* the binding between the two lives wherever handles are persisted.
|
|
16
|
+
*
|
|
17
|
+
* Every variant is a strict object, so a field from another provider, a
|
|
18
|
+
* flattened `threadId`/`topicId`, or a smuggled message id fails validation
|
|
19
|
+
* instead of riding along untyped.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
import type { ChannelId } from "./channels.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Field validators
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Upper bound on any single handle field. */
|
|
31
|
+
const MAX_FIELD_LENGTH = 256;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Vellum channel connection (installation) the conversation belongs to.
|
|
35
|
+
* Opaque to this contract: it identifies which stored provider credentials
|
|
36
|
+
* and provider account reach the conversation, not any provider object.
|
|
37
|
+
*/
|
|
38
|
+
const connectionId = () =>
|
|
39
|
+
z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(MAX_FIELD_LENGTH)
|
|
43
|
+
.regex(
|
|
44
|
+
/^[^\s\p{Cc}]+$/u,
|
|
45
|
+
"must not contain whitespace or control characters",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/** A Telegram Bot API integer id as decimal digits (int64 width). */
|
|
49
|
+
const telegramId = () =>
|
|
50
|
+
z.string().regex(/^\d{1,19}$/, "must be decimal digits");
|
|
51
|
+
|
|
52
|
+
/** Bot API `chat.id`: negative for groups, supergroups, and channels. */
|
|
53
|
+
const telegramChatId = () =>
|
|
54
|
+
z
|
|
55
|
+
.string()
|
|
56
|
+
.regex(/^-?\d{1,19}$/, "must be decimal digits, optionally negative");
|
|
57
|
+
|
|
58
|
+
const slackTeamId = () =>
|
|
59
|
+
z
|
|
60
|
+
.string()
|
|
61
|
+
.max(MAX_FIELD_LENGTH)
|
|
62
|
+
.regex(/^T[A-Z0-9]{2,}$/, "must be a Slack workspace id, e.g. T0123ABCD");
|
|
63
|
+
|
|
64
|
+
/** `C…` public channel, `D…` DM, `G…` private channel or legacy group DM. */
|
|
65
|
+
const slackChannelId = () =>
|
|
66
|
+
z
|
|
67
|
+
.string()
|
|
68
|
+
.max(MAX_FIELD_LENGTH)
|
|
69
|
+
.regex(
|
|
70
|
+
/^[CDG][A-Z0-9]{2,}$/,
|
|
71
|
+
"must be a Slack conversation id, e.g. C0123ABCD",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/** A Slack message timestamp, the thread key (`thread_ts`). */
|
|
75
|
+
const slackThreadTs = () =>
|
|
76
|
+
z
|
|
77
|
+
.string()
|
|
78
|
+
.regex(
|
|
79
|
+
/^\d{10}\.\d{6}$/,
|
|
80
|
+
"must be a Slack message timestamp, e.g. 1700000000.000100",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** A Discord snowflake as decimal digits. */
|
|
84
|
+
const discordSnowflake = () =>
|
|
85
|
+
z.string().regex(/^\d{1,20}$/, "must be a Discord snowflake");
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Telegram
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where inside a Telegram chat the conversation lives. `message_thread`
|
|
93
|
+
* carries a Bot API `message_thread_id` (forum topics and private-chat topic
|
|
94
|
+
* mode); `direct_messages_topic` carries a `direct_messages_topic_id`
|
|
95
|
+
* (channel direct-messages monoforums). They are different Bot API routing
|
|
96
|
+
* fields, so they are distinct variants rather than one untyped topic id, and
|
|
97
|
+
* the union makes a mixed shape unrepresentable.
|
|
98
|
+
*/
|
|
99
|
+
export const TelegramSubconversationSchema = z.discriminatedUnion("kind", [
|
|
100
|
+
z.strictObject({ kind: z.literal("chat") }),
|
|
101
|
+
z.strictObject({ kind: z.literal("message_thread"), id: telegramId() }),
|
|
102
|
+
z.strictObject({
|
|
103
|
+
kind: z.literal("direct_messages_topic"),
|
|
104
|
+
id: telegramId(),
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
export type TelegramSubconversation = z.infer<
|
|
109
|
+
typeof TelegramSubconversationSchema
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
export const TelegramConversationHandleSchema = z.strictObject({
|
|
113
|
+
provider: z.literal("telegram"),
|
|
114
|
+
connectionId: connectionId(),
|
|
115
|
+
/** Bot API `chat.id`. Negative for groups, supergroups, and channels. */
|
|
116
|
+
chatId: telegramChatId(),
|
|
117
|
+
subconversation: TelegramSubconversationSchema,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Slack
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slack routing keeps its native shape: conversation ids are unique only
|
|
126
|
+
* within a workspace, and a thread is keyed by the parent message's
|
|
127
|
+
* `thread_ts`. `threadTs` absent means the conversation is the channel
|
|
128
|
+
* itself, not that the producer may omit it.
|
|
129
|
+
*/
|
|
130
|
+
export const SlackConversationHandleSchema = z.strictObject({
|
|
131
|
+
provider: z.literal("slack"),
|
|
132
|
+
connectionId: connectionId(),
|
|
133
|
+
/** Workspace id (`T…`). */
|
|
134
|
+
teamId: slackTeamId(),
|
|
135
|
+
/** Conversation id: `C…` channel, `D…` DM, `G…` private or group DM. */
|
|
136
|
+
channelId: slackChannelId(),
|
|
137
|
+
/** `thread_ts` of the thread parent, on a threaded conversation only. */
|
|
138
|
+
threadTs: slackThreadTs().optional(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Discord
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Discord routing keeps its native shape: a thread is itself a channel
|
|
147
|
+
* snowflake under a parent channel, and the adapter admits guild messages
|
|
148
|
+
* only, so the guild is always part of the handle.
|
|
149
|
+
*/
|
|
150
|
+
export const DiscordConversationHandleSchema = z.strictObject({
|
|
151
|
+
provider: z.literal("discord"),
|
|
152
|
+
connectionId: connectionId(),
|
|
153
|
+
/** Guild (server) snowflake. */
|
|
154
|
+
guildId: discordSnowflake(),
|
|
155
|
+
/** Parent channel snowflake, the conversation itself outside a thread. */
|
|
156
|
+
channelId: discordSnowflake(),
|
|
157
|
+
/** Thread or forum-post snowflake, on a thread conversation only. */
|
|
158
|
+
threadId: discordSnowflake().optional(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// The union
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Providers that have a durable handle shape. Every entry must be a canonical
|
|
167
|
+
* `ChannelId`; the `satisfies` clause enforces that at compile time.
|
|
168
|
+
*/
|
|
169
|
+
export const CONVERSATION_HANDLE_PROVIDERS = [
|
|
170
|
+
"discord",
|
|
171
|
+
"slack",
|
|
172
|
+
"telegram",
|
|
173
|
+
] as const satisfies readonly ChannelId[];
|
|
174
|
+
|
|
175
|
+
export type ConversationHandleProvider =
|
|
176
|
+
(typeof CONVERSATION_HANDLE_PROVIDERS)[number];
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Handle variant per provider. The `satisfies Record<…>` annotation is the
|
|
180
|
+
* exhaustiveness gate: adding a provider to `CONVERSATION_HANDLE_PROVIDERS`
|
|
181
|
+
* fails to compile until its handle shape is spelled out here.
|
|
182
|
+
*/
|
|
183
|
+
export const CONVERSATION_HANDLE_SCHEMAS = {
|
|
184
|
+
discord: DiscordConversationHandleSchema,
|
|
185
|
+
slack: SlackConversationHandleSchema,
|
|
186
|
+
telegram: TelegramConversationHandleSchema,
|
|
187
|
+
} as const satisfies Record<ConversationHandleProvider, z.ZodObject>;
|
|
188
|
+
|
|
189
|
+
export const ConversationHandleSchema = z.discriminatedUnion("provider", [
|
|
190
|
+
CONVERSATION_HANDLE_SCHEMAS.discord,
|
|
191
|
+
CONVERSATION_HANDLE_SCHEMAS.slack,
|
|
192
|
+
CONVERSATION_HANDLE_SCHEMAS.telegram,
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
export type ConversationHandle = z.infer<typeof ConversationHandleSchema>;
|
|
196
|
+
|
|
197
|
+
/** The handle variant for one provider, e.g. `ConversationHandleFor<"slack">`. */
|
|
198
|
+
export type ConversationHandleFor<P extends ConversationHandleProvider> =
|
|
199
|
+
Extract<ConversationHandle, { provider: P }>;
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Serialization
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Serialize a handle to its JSON wire form. The handle is validated first, so
|
|
207
|
+
* the output is always the serialization of a valid handle.
|
|
208
|
+
*
|
|
209
|
+
* @throws {z.ZodError} when the value is not a valid {@link ConversationHandle}.
|
|
210
|
+
*/
|
|
211
|
+
export function serializeConversationHandle(
|
|
212
|
+
handle: ConversationHandle,
|
|
213
|
+
): string {
|
|
214
|
+
return JSON.stringify(ConversationHandleSchema.parse(handle));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Inverse of {@link serializeConversationHandle}. Returns `null` for anything
|
|
219
|
+
* that is not the JSON serialization of a valid handle: malformed JSON, an
|
|
220
|
+
* unknown provider, a missing or malformed coordinate, or a field the
|
|
221
|
+
* provider's shape does not declare.
|
|
222
|
+
*/
|
|
223
|
+
export function safeDeserializeConversationHandle(
|
|
224
|
+
text: string,
|
|
225
|
+
): ConversationHandle | null {
|
|
226
|
+
let value: unknown;
|
|
227
|
+
try {
|
|
228
|
+
value = JSON.parse(text);
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const result = ConversationHandleSchema.safeParse(value);
|
|
233
|
+
return result.success ? result.data : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Inverse of {@link serializeConversationHandle}, throwing instead of
|
|
238
|
+
* returning `null`.
|
|
239
|
+
*/
|
|
240
|
+
export function deserializeConversationHandle(text: string): ConversationHandle {
|
|
241
|
+
const handle = safeDeserializeConversationHandle(text);
|
|
242
|
+
if (handle === null) {
|
|
243
|
+
throw new Error("not a valid conversation handle serialization");
|
|
244
|
+
}
|
|
245
|
+
return handle;
|
|
246
|
+
}
|