@axiom-lattice/gateway 2.1.52 → 2.1.54

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,270 @@
1
+ import type { ThreadStore } from "@axiom-lattice/protocols";
2
+ import fastify from "fastify";
3
+ import { createLarkEventHandler } from "../controller";
4
+
5
+ const installationConfig = {
6
+ installationId: "install-1",
7
+ tenantId: "tenant-a",
8
+ assistantId: "assistant-a",
9
+ appId: "cli_app_1",
10
+ appSecret: "secret",
11
+ verificationToken: "token-1",
12
+ encryptKey: "encrypt-key",
13
+ mappingMode: "hybrid" as const,
14
+ };
15
+
16
+ describe("createLarkEventHandler", () => {
17
+ it("returns the challenge response during URL verification", async () => {
18
+ const app = fastify();
19
+ app.post(
20
+ "/api/channels/lark/installations/:installationId/events",
21
+ createLarkEventHandler({
22
+ getInstallationConfig: jest.fn().mockResolvedValue(installationConfig),
23
+ parseRequestBody: jest.fn().mockReturnValue({
24
+ type: "url_verification",
25
+ challenge: "challenge-token",
26
+ token: "token-1",
27
+ }),
28
+ verifyParsedBody: jest.fn().mockReturnValue(true),
29
+ parseEvent: jest.fn(),
30
+ claimInboundReceipt: jest.fn(),
31
+ markInboundReceiptCompleted: jest.fn(),
32
+ markInboundReceiptFailed: jest.fn(),
33
+ resolveThread: jest.fn(),
34
+ runAgentAndCollectText: jest.fn(),
35
+ sendTextReply: jest.fn(),
36
+ }),
37
+ );
38
+
39
+ const response = await app.inject({
40
+ method: "POST",
41
+ url: "/api/channels/lark/installations/install-1/events",
42
+ payload: {
43
+ challenge: "challenge-token",
44
+ type: "url_verification",
45
+ },
46
+ });
47
+
48
+ expect(response.statusCode).toBe(200);
49
+ expect(response.json()).toEqual({ challenge: "challenge-token" });
50
+
51
+ await app.close();
52
+ });
53
+
54
+ it("handles one direct text message end to end", async () => {
55
+ const app = fastify();
56
+ const parseEvent = jest.fn().mockReturnValue({
57
+ messageId: "om_1",
58
+ openId: "ou_1",
59
+ chatId: "oc_1",
60
+ chatType: "direct",
61
+ text: "hello",
62
+ });
63
+ const claimInboundReceipt = jest
64
+ .fn()
65
+ .mockResolvedValue({ accepted: true, status: "processing" });
66
+ const markInboundReceiptCompleted = jest.fn().mockResolvedValue(undefined);
67
+ const markInboundReceiptFailed = jest.fn().mockResolvedValue(undefined);
68
+ const resolveThread = jest.fn().mockResolvedValue({ threadId: "thread-1" });
69
+ const runAgentAndCollectText = jest.fn().mockResolvedValue("hi there");
70
+ const sendTextReply = jest.fn().mockResolvedValue(undefined);
71
+
72
+ app.post(
73
+ "/api/channels/lark/installations/:installationId/events",
74
+ createLarkEventHandler({
75
+ getInstallationConfig: jest.fn().mockResolvedValue(installationConfig),
76
+ parseRequestBody: jest.fn().mockImplementation((body) => body),
77
+ verifyParsedBody: jest.fn().mockReturnValue(true),
78
+ parseEvent,
79
+ claimInboundReceipt,
80
+ markInboundReceiptCompleted,
81
+ markInboundReceiptFailed,
82
+ resolveThread,
83
+ runAgentAndCollectText,
84
+ sendTextReply,
85
+ }),
86
+ );
87
+
88
+ const response = await app.inject({
89
+ method: "POST",
90
+ url: "/api/channels/lark/installations/install-1/events",
91
+ payload: {
92
+ header: { event_type: "im.message.receive_v1" },
93
+ event: {
94
+ sender: { sender_id: { open_id: "ou_1" } },
95
+ message: {
96
+ message_id: "om_1",
97
+ chat_id: "oc_1",
98
+ chat_type: "p2p",
99
+ message_type: "text",
100
+ content: '{"text":"hello"}',
101
+ },
102
+ },
103
+ },
104
+ });
105
+
106
+ expect(response.statusCode).toBe(200);
107
+ expect(claimInboundReceipt).toHaveBeenCalledWith({
108
+ channel: "lark",
109
+ channelAppId: "cli_app_1",
110
+ externalMessageId: "om_1",
111
+ tenantId: "tenant-a",
112
+ });
113
+ expect(resolveThread).toHaveBeenCalled();
114
+ expect(runAgentAndCollectText).toHaveBeenCalledWith({
115
+ assistantId: "assistant-a",
116
+ threadId: "thread-1",
117
+ text: "hello",
118
+ tenantId: "tenant-a",
119
+ workspaceId: undefined,
120
+ projectId: undefined,
121
+ });
122
+ expect(sendTextReply).toHaveBeenCalledWith({
123
+ chatId: "oc_1",
124
+ text: "hi there",
125
+ config: installationConfig,
126
+ });
127
+ expect(markInboundReceiptCompleted).toHaveBeenCalledWith({
128
+ channel: "lark",
129
+ channelAppId: "cli_app_1",
130
+ externalMessageId: "om_1",
131
+ tenantId: "tenant-a",
132
+ threadId: "thread-1",
133
+ });
134
+ expect(markInboundReceiptFailed).not.toHaveBeenCalled();
135
+
136
+ await app.close();
137
+ });
138
+
139
+ it("short-circuits when an inbound receipt is already processing", async () => {
140
+ const app = fastify();
141
+ const parseEvent = jest.fn().mockReturnValue({
142
+ messageId: "om_processing",
143
+ openId: "ou_1",
144
+ chatId: "oc_1",
145
+ chatType: "direct",
146
+ text: "hello",
147
+ });
148
+ const claimInboundReceipt = jest
149
+ .fn()
150
+ .mockResolvedValue({ accepted: false, status: "processing" });
151
+ const resolveThread = jest.fn();
152
+ const runAgentAndCollectText = jest.fn();
153
+ const sendTextReply = jest.fn();
154
+
155
+ app.post(
156
+ "/api/channels/lark/installations/:installationId/events",
157
+ createLarkEventHandler({
158
+ getInstallationConfig: jest.fn().mockResolvedValue(installationConfig),
159
+ parseRequestBody: jest.fn().mockImplementation((body) => body),
160
+ verifyParsedBody: jest.fn().mockReturnValue(true),
161
+ parseEvent,
162
+ claimInboundReceipt,
163
+ markInboundReceiptCompleted: jest.fn(),
164
+ markInboundReceiptFailed: jest.fn(),
165
+ resolveThread,
166
+ runAgentAndCollectText,
167
+ sendTextReply,
168
+ }),
169
+ );
170
+
171
+ const response = await app.inject({
172
+ method: "POST",
173
+ url: "/api/channels/lark/installations/install-1/events",
174
+ payload: {
175
+ header: { event_type: "im.message.receive_v1" },
176
+ event: {
177
+ sender: { sender_id: { open_id: "ou_1" } },
178
+ message: {
179
+ message_id: "om_processing",
180
+ chat_id: "oc_1",
181
+ chat_type: "p2p",
182
+ message_type: "text",
183
+ content: '{"text":"hello"}',
184
+ },
185
+ },
186
+ },
187
+ });
188
+
189
+ expect(response.statusCode).toBe(200);
190
+ expect(response.json()).toEqual({ success: true, processing: true });
191
+ expect(resolveThread).not.toHaveBeenCalled();
192
+ expect(runAgentAndCollectText).not.toHaveBeenCalled();
193
+ expect(sendTextReply).not.toHaveBeenCalled();
194
+
195
+ await app.close();
196
+ });
197
+
198
+ it("rejects requests when verification fails", async () => {
199
+ const app = fastify();
200
+
201
+ app.post(
202
+ "/api/channels/lark/installations/:installationId/events",
203
+ createLarkEventHandler({
204
+ getInstallationConfig: jest.fn().mockResolvedValue(installationConfig),
205
+ parseRequestBody: jest.fn().mockReturnValue({ header: { token: "bad" } }),
206
+ verifyParsedBody: jest.fn().mockReturnValue(false),
207
+ parseEvent: jest.fn(),
208
+ claimInboundReceipt: jest.fn(),
209
+ markInboundReceiptCompleted: jest.fn(),
210
+ markInboundReceiptFailed: jest.fn(),
211
+ resolveThread: jest.fn(),
212
+ runAgentAndCollectText: jest.fn(),
213
+ sendTextReply: jest.fn(),
214
+ }),
215
+ );
216
+
217
+ const response = await app.inject({
218
+ method: "POST",
219
+ url: "/api/channels/lark/installations/install-1/events",
220
+ payload: {
221
+ header: { event_type: "im.message.receive_v1" },
222
+ },
223
+ });
224
+
225
+ expect(response.statusCode).toBe(401);
226
+ expect(response.json()).toEqual({ success: false, message: "Invalid Lark request" });
227
+
228
+ await app.close();
229
+ });
230
+
231
+ it("loads installation-scoped config from the route parameter", async () => {
232
+ const app = fastify();
233
+ const getInstallationConfig = jest.fn().mockResolvedValue(installationConfig);
234
+
235
+ app.post(
236
+ "/api/channels/lark/installations/:installationId/events",
237
+ createLarkEventHandler({
238
+ getInstallationConfig,
239
+ parseRequestBody: jest.fn().mockReturnValue({
240
+ type: "url_verification",
241
+ challenge: "challenge-token",
242
+ token: "token-1",
243
+ }),
244
+ verifyParsedBody: jest.fn().mockReturnValue(true),
245
+ parseEvent: jest.fn(),
246
+ claimInboundReceipt: jest.fn(),
247
+ markInboundReceiptCompleted: jest.fn(),
248
+ markInboundReceiptFailed: jest.fn(),
249
+ resolveThread: jest.fn(),
250
+ runAgentAndCollectText: jest.fn(),
251
+ sendTextReply: jest.fn(),
252
+ }),
253
+ );
254
+
255
+ const response = await app.inject({
256
+ method: "POST",
257
+ url: "/api/channels/lark/installations/install-1/events",
258
+ payload: {
259
+ type: "url_verification",
260
+ challenge: "challenge-token",
261
+ token: "token-1",
262
+ },
263
+ });
264
+
265
+ expect(response.statusCode).toBe(200);
266
+ expect(getInstallationConfig).toHaveBeenCalledWith("install-1");
267
+
268
+ await app.close();
269
+ });
270
+ });
@@ -0,0 +1,118 @@
1
+ import type { ThreadStore } from "@axiom-lattice/protocols";
2
+ import { createChannelThreadMappingService } from "../mapping-service";
3
+
4
+ describe("createChannelThreadMappingService", () => {
5
+ it("uses a user subject key for direct chats in hybrid mode", async () => {
6
+ const mappingStore = {
7
+ getMappingBySubject: jest.fn().mockResolvedValue(null),
8
+ createMapping: jest.fn().mockResolvedValue({ threadId: "thread-1" }),
9
+ };
10
+
11
+ const threadStore: Pick<ThreadStore, "createThread"> = {
12
+ createThread: jest.fn().mockResolvedValue({ id: "thread-1" }),
13
+ };
14
+
15
+ const service = createChannelThreadMappingService({
16
+ mappingStore,
17
+ threadStore,
18
+ uuid: () => "thread-1",
19
+ });
20
+
21
+ const result = await service.getOrCreateThread({
22
+ channel: "lark",
23
+ channelAppId: "cli_app_1",
24
+ tenantId: "tenant-a",
25
+ assistantId: "assistant-a",
26
+ mappingMode: "hybrid",
27
+ openId: "ou_1",
28
+ chatId: "oc_1",
29
+ chatType: "direct",
30
+ messageId: "om_1",
31
+ });
32
+
33
+ expect(mappingStore.getMappingBySubject).toHaveBeenCalledWith(
34
+ expect.objectContaining({
35
+ externalSubjectKey:
36
+ "lark:cli_app_1:tenant:tenant-a:assistant:assistant-a:user:ou_1",
37
+ }),
38
+ );
39
+ expect(result.threadId).toBe("thread-1");
40
+ });
41
+
42
+ it("reuses an existing mapping when one already exists", async () => {
43
+ const mappingStore = {
44
+ getMappingBySubject: jest.fn().mockResolvedValue({ threadId: "thread-existing" }),
45
+ createMapping: jest.fn(),
46
+ };
47
+
48
+ const threadStore: Pick<ThreadStore, "createThread"> = {
49
+ createThread: jest.fn(),
50
+ };
51
+
52
+ const service = createChannelThreadMappingService({
53
+ mappingStore,
54
+ threadStore,
55
+ uuid: () => "thread-new",
56
+ });
57
+
58
+ const result = await service.getOrCreateThread({
59
+ channel: "lark",
60
+ channelAppId: "cli_app_1",
61
+ tenantId: "tenant-a",
62
+ assistantId: "assistant-a",
63
+ mappingMode: "group",
64
+ openId: "ou_1",
65
+ chatId: "oc_group_1",
66
+ chatType: "group",
67
+ messageId: "om_2",
68
+ });
69
+
70
+ expect(result.threadId).toBe("thread-existing");
71
+ expect(threadStore.createThread).not.toHaveBeenCalled();
72
+ expect(mappingStore.createMapping).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("re-reads the canonical mapping when createMapping hits a unique conflict", async () => {
76
+ const mappingStore = {
77
+ getMappingBySubject: jest
78
+ .fn()
79
+ .mockResolvedValueOnce(null)
80
+ .mockResolvedValueOnce({ threadId: "thread-canonical" }),
81
+ createMapping: jest.fn().mockRejectedValue(
82
+ Object.assign(new Error("duplicate key value violates unique constraint"), {
83
+ code: "23505",
84
+ }),
85
+ ),
86
+ };
87
+
88
+ const threadStore: Pick<ThreadStore, "createThread" | "deleteThread"> = {
89
+ createThread: jest.fn().mockResolvedValue({ id: "thread-new" }),
90
+ deleteThread: jest.fn().mockResolvedValue(true),
91
+ };
92
+
93
+ const service = createChannelThreadMappingService({
94
+ mappingStore,
95
+ threadStore,
96
+ uuid: () => "thread-new",
97
+ });
98
+
99
+ const result = await service.getOrCreateThread({
100
+ channel: "lark",
101
+ channelAppId: "cli_app_1",
102
+ tenantId: "tenant-a",
103
+ assistantId: "assistant-a",
104
+ mappingMode: "hybrid",
105
+ openId: "ou_1",
106
+ chatId: "oc_1",
107
+ chatType: "direct",
108
+ messageId: "om_1",
109
+ });
110
+
111
+ expect(result.threadId).toBe("thread-canonical");
112
+ expect(mappingStore.getMappingBySubject).toHaveBeenCalledTimes(2);
113
+ expect(threadStore.deleteThread).toHaveBeenCalledWith(
114
+ "tenant-a",
115
+ "thread-new",
116
+ );
117
+ });
118
+ });
@@ -0,0 +1,72 @@
1
+ import { parseLarkMessageEvent } from "../parser";
2
+
3
+ describe("parseLarkMessageEvent", () => {
4
+ it("parses a direct text message event", () => {
5
+ const parsed = parseLarkMessageEvent({
6
+ header: { event_type: "im.message.receive_v1" },
7
+ event: {
8
+ sender: { sender_id: { open_id: "ou_user_1" } },
9
+ message: {
10
+ message_id: "om_1",
11
+ chat_id: "oc_chat_1",
12
+ chat_type: "p2p",
13
+ message_type: "text",
14
+ content: '{"text":"hello"}',
15
+ },
16
+ },
17
+ });
18
+
19
+ expect(parsed).toEqual({
20
+ messageId: "om_1",
21
+ openId: "ou_user_1",
22
+ chatId: "oc_chat_1",
23
+ chatType: "direct",
24
+ text: "hello",
25
+ });
26
+ });
27
+
28
+ it("parses a group text message event", () => {
29
+ const parsed = parseLarkMessageEvent({
30
+ header: { event_type: "im.message.receive_v1" },
31
+ event: {
32
+ sender: { sender_id: { open_id: "ou_user_2" } },
33
+ message: {
34
+ message_id: "om_2",
35
+ chat_id: "oc_group_1",
36
+ chat_type: "group",
37
+ message_type: "text",
38
+ content: '{"text":"team update"}',
39
+ },
40
+ },
41
+ });
42
+
43
+ expect(parsed.chatType).toBe("group");
44
+ expect(parsed.text).toBe("team update");
45
+ });
46
+
47
+ it("returns null for unsupported event types", () => {
48
+ const parsed = parseLarkMessageEvent({
49
+ header: { event_type: "im.message.read_v1" },
50
+ });
51
+
52
+ expect(parsed).toBeNull();
53
+ });
54
+
55
+ it("returns null for non-text messages", () => {
56
+ const parsed = parseLarkMessageEvent({
57
+ header: { event_type: "im.message.receive_v1" },
58
+ event: {
59
+ sender: { sender_id: { open_id: "ou_user_3" } },
60
+ message: {
61
+ message_id: "om_3",
62
+ chat_id: "oc_chat_3",
63
+ chat_type: "p2p",
64
+ message_type: "image",
65
+ content: '{"image_key":"img_1"}',
66
+ },
67
+ },
68
+ });
69
+
70
+ expect(parsed).toBeNull();
71
+ });
72
+ });
@@ -0,0 +1,37 @@
1
+ import { createLarkSender } from "../sender";
2
+
3
+ describe("createLarkSender", () => {
4
+ it("sends a text reply through the lark sdk client", async () => {
5
+ const createMock = jest.fn().mockResolvedValue({ code: 0 });
6
+ const sdkClient = {
7
+ im: {
8
+ v1: {
9
+ message: {
10
+ create: createMock,
11
+ },
12
+ },
13
+ },
14
+ };
15
+
16
+ const sender = createLarkSender(
17
+ {
18
+ appId: "cli_app_1",
19
+ appSecret: "secret",
20
+ },
21
+ sdkClient as never,
22
+ );
23
+
24
+ await sender.sendTextReply({ chatId: "oc_1", text: "hello" });
25
+
26
+ expect(createMock).toHaveBeenCalledWith({
27
+ params: {
28
+ receive_id_type: "chat_id",
29
+ },
30
+ data: {
31
+ receive_id: "oc_1",
32
+ msg_type: "text",
33
+ content: JSON.stringify({ text: "hello" }),
34
+ },
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,157 @@
1
+ import type { FastifyRequest } from "fastify";
2
+ import crypto from "crypto";
3
+ import {
4
+ createLarkRequestVerifier,
5
+ decryptLarkPayload,
6
+ parseLarkRequestBody,
7
+ } from "../verification";
8
+
9
+ describe("createLarkRequestVerifier", () => {
10
+ it("accepts requests with the configured verification token", () => {
11
+ const verify = createLarkRequestVerifier({
12
+ enabled: true,
13
+ appId: "app-id",
14
+ appSecret: "app-secret",
15
+ verificationToken: "token-1",
16
+ tenantId: "tenant-a",
17
+ assistantId: "assistant-a",
18
+ mappingMode: "hybrid",
19
+ });
20
+
21
+ const request = {
22
+ headers: {},
23
+ body: { token: "token-1" },
24
+ } as FastifyRequest;
25
+
26
+ expect(verify(request)).toBe(true);
27
+ });
28
+
29
+ it("rejects requests with the wrong verification token", () => {
30
+ const verify = createLarkRequestVerifier({
31
+ enabled: true,
32
+ appId: "app-id",
33
+ appSecret: "app-secret",
34
+ verificationToken: "token-1",
35
+ tenantId: "tenant-a",
36
+ assistantId: "assistant-a",
37
+ mappingMode: "hybrid",
38
+ });
39
+
40
+ const request = {
41
+ headers: {},
42
+ body: { token: "token-2" },
43
+ } as FastifyRequest;
44
+
45
+ expect(verify(request)).toBe(false);
46
+ });
47
+
48
+ it("accepts requests carrying lark signature headers", () => {
49
+ const verify = createLarkRequestVerifier({
50
+ enabled: true,
51
+ appId: "app-id",
52
+ appSecret: "app-secret",
53
+ tenantId: "tenant-a",
54
+ assistantId: "assistant-a",
55
+ mappingMode: "hybrid",
56
+ });
57
+
58
+ const request = {
59
+ headers: {
60
+ "x-lark-request-timestamp": "1712730010",
61
+ "x-lark-signature": "signature",
62
+ },
63
+ body: {},
64
+ } as FastifyRequest;
65
+
66
+ expect(verify(request)).toBe(true);
67
+ });
68
+
69
+ it("accepts v2 payloads that carry the token in header.token", () => {
70
+ const verify = createLarkRequestVerifier({
71
+ enabled: true,
72
+ appId: "app-id",
73
+ appSecret: "app-secret",
74
+ verificationToken: "token-1",
75
+ tenantId: "tenant-a",
76
+ assistantId: "assistant-a",
77
+ mappingMode: "hybrid",
78
+ });
79
+
80
+ const request = {
81
+ headers: {},
82
+ body: {
83
+ schema: "2.0",
84
+ header: {
85
+ token: "token-1",
86
+ },
87
+ },
88
+ } as FastifyRequest;
89
+
90
+ expect(verify(request)).toBe(true);
91
+ });
92
+
93
+ it("decrypts an encrypted lark payload", () => {
94
+ const encryptKey = "encrypt-key-1";
95
+ const payload = JSON.stringify({
96
+ type: "url_verification",
97
+ challenge: "challenge-1",
98
+ token: "token-1",
99
+ });
100
+ const encrypted = encryptForTest(encryptKey, payload);
101
+
102
+ expect(decryptLarkPayload(encryptKey, encrypted)).toEqual({
103
+ type: "url_verification",
104
+ challenge: "challenge-1",
105
+ token: "token-1",
106
+ });
107
+ });
108
+
109
+ it("parses encrypted payloads before token verification", () => {
110
+ const encryptKey = "encrypt-key-1";
111
+ const verify = createLarkRequestVerifier({
112
+ enabled: true,
113
+ appId: "app-id",
114
+ appSecret: "app-secret",
115
+ verificationToken: "token-1",
116
+ encryptKey,
117
+ tenantId: "tenant-a",
118
+ assistantId: "assistant-a",
119
+ mappingMode: "hybrid",
120
+ });
121
+
122
+ const encrypted = encryptForTest(
123
+ encryptKey,
124
+ JSON.stringify({
125
+ type: "url_verification",
126
+ challenge: "challenge-1",
127
+ token: "token-1",
128
+ }),
129
+ );
130
+
131
+ const request = {
132
+ headers: {},
133
+ body: {
134
+ encrypt: encrypted,
135
+ },
136
+ } as FastifyRequest;
137
+
138
+ expect(verify(request)).toBe(true);
139
+ expect(parseLarkRequestBody(request.body, encryptKey)).toEqual({
140
+ type: "url_verification",
141
+ challenge: "challenge-1",
142
+ token: "token-1",
143
+ });
144
+ });
145
+ });
146
+
147
+ function encryptForTest(encryptKey: string, plaintext: string): string {
148
+ const key = crypto.createHash("sha256").update(encryptKey).digest();
149
+ const iv = Buffer.alloc(16, 1);
150
+ const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
151
+ const ciphertext = Buffer.concat([
152
+ cipher.update(Buffer.from(plaintext, "utf8")),
153
+ cipher.final(),
154
+ ]);
155
+
156
+ return Buffer.concat([iv, ciphertext]).toString("base64");
157
+ }
@@ -0,0 +1,16 @@
1
+ import type { MessageChunk } from "@axiom-lattice/protocols";
2
+ import { MessageChunkTypes } from "@axiom-lattice/protocols";
3
+
4
+ export function aggregateLarkReply(
5
+ messageId: string,
6
+ chunks: MessageChunk[],
7
+ ): string {
8
+ return chunks
9
+ .filter(
10
+ (chunk) =>
11
+ chunk.type === MessageChunkTypes.AI && chunk.data.id === messageId,
12
+ )
13
+ .map((chunk) => chunk.data.content || "")
14
+ .join("")
15
+ .trim();
16
+ }