@axiom-lattice/gateway 2.1.53 → 2.1.55

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +8 -8
  2. package/CHANGELOG.md +19 -0
  3. package/dist/index.js +1043 -526
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +977 -449
  6. package/dist/index.mjs.map +1 -1
  7. package/jest.config.js +5 -0
  8. package/package.json +6 -5
  9. package/src/__tests__/__mocks__/e2b.ts +1 -0
  10. package/src/__tests__/channel-installations.test.ts +199 -0
  11. package/src/__tests__/sandbox-provider-registration.test.ts +74 -0
  12. package/src/__tests__/workspace.test.ts +119 -8
  13. package/src/channels/__tests__/routes.test.ts +71 -0
  14. package/src/channels/lark/README.md +187 -0
  15. package/src/channels/lark/__tests__/aggregator.test.ts +23 -0
  16. package/src/channels/lark/__tests__/controller.test.ts +270 -0
  17. package/src/channels/lark/__tests__/mapping-service.test.ts +118 -0
  18. package/src/channels/lark/__tests__/parser.test.ts +72 -0
  19. package/src/channels/lark/__tests__/sender.test.ts +37 -0
  20. package/src/channels/lark/__tests__/verification.test.ts +157 -0
  21. package/src/channels/lark/aggregator.ts +16 -0
  22. package/src/channels/lark/config.ts +44 -0
  23. package/src/channels/lark/controller.ts +189 -0
  24. package/src/channels/lark/mapping-service.ts +138 -0
  25. package/src/channels/lark/parser.ts +68 -0
  26. package/src/channels/lark/routes.ts +121 -0
  27. package/src/channels/lark/runner.ts +37 -0
  28. package/src/channels/lark/sender.ts +58 -0
  29. package/src/channels/lark/types.ts +33 -0
  30. package/src/channels/lark/verification.ts +67 -0
  31. package/src/channels/routes.ts +25 -0
  32. package/src/controllers/channel-installations.ts +354 -0
  33. package/src/controllers/sandbox.ts +30 -80
  34. package/src/controllers/skills.ts +71 -321
  35. package/src/controllers/threads.ts +8 -6
  36. package/src/controllers/workspace.ts +64 -179
  37. package/src/index.ts +28 -5
  38. package/src/routes/channel-installations.ts +33 -0
  39. package/src/routes/index.ts +6 -0
  40. package/src/schemas/index.ts +2 -2
  41. package/src/services/sandbox_service.ts +21 -21
  42. package/src/services/skill_service.ts +97 -0
@@ -0,0 +1,187 @@
1
+ # Lark Channel Ingress
2
+
3
+ This module adds Feishu/Lark message ingress to the existing `@axiom-lattice/gateway` service.
4
+
5
+ ## What It Supports
6
+
7
+ - inbound Lark event callbacks
8
+ - URL verification challenge
9
+ - plain and encrypted callback payloads
10
+ - v1 and v2 verification token extraction
11
+ - text-message parsing
12
+ - thread mapping to existing Axiom threads
13
+ - reactive text replies sent through the official Lark SDK
14
+
15
+ ## Current Scope
16
+
17
+ This is the MVP scope:
18
+
19
+ - one gateway deployment can handle multiple tenant-scoped Lark installations
20
+ - only text messages are supported
21
+ - only reactive replies are supported
22
+ - identity mapping is persisted in PostgreSQL
23
+
24
+ ## Environment Variables
25
+
26
+ Required when `LARK_ENABLED=true`:
27
+
28
+ ```bash
29
+ LARK_ENABLED=true
30
+
31
+ DATABASE_URL=postgresql://user:pass@localhost:5432/axiom_lattice
32
+ ```
33
+
34
+ Lark app credentials are no longer loaded from global environment variables. They are stored per installation in PostgreSQL.
35
+
36
+ ## Route
37
+
38
+ The webhook route is mounted on the existing gateway service:
39
+
40
+ ```text
41
+ POST /api/channels/lark/installations/:installationId/events
42
+ ```
43
+
44
+ ## Start The Gateway
45
+
46
+ Development:
47
+
48
+ ```bash
49
+ pnpm --filter @axiom-lattice/gateway dev
50
+ ```
51
+
52
+ Production:
53
+
54
+ ```bash
55
+ pnpm --filter @axiom-lattice/gateway build
56
+ pnpm --filter @axiom-lattice/gateway start
57
+ ```
58
+
59
+ ## Feishu/Lark Console Setup
60
+
61
+ In the Feishu developer console:
62
+
63
+ 1. Create or open an enterprise self-built app.
64
+ 2. Go to the event subscription / callback configuration page.
65
+ 3. Set the request URL to:
66
+
67
+ ```text
68
+ https://your-domain.com/api/channels/lark/installations/<installationId>/events
69
+ ```
70
+
71
+ 4. Create a matching installation record in PostgreSQL for that tenant. The installation config must include:
72
+
73
+ - appId
74
+ - appSecret
75
+ - Verification Token
76
+ - Encrypt Key
77
+ - assistantId
78
+ - mappingMode
79
+
80
+ 5. Configure the same values in the Feishu console:
81
+
82
+ - Verification Token
83
+ - Encrypt Key
84
+
85
+ 6. Subscribe to the message event:
86
+
87
+ ```text
88
+ im.message.receive_v1
89
+ ```
90
+
91
+ ## Callback Handling
92
+
93
+ This implementation handles the normal Feishu event callback flow, not the message-card callback signature flow.
94
+
95
+ Supported verification behavior:
96
+
97
+ - v1 callback token: `body.token`
98
+ - v2 callback token: `body.header.token`
99
+ - encrypted envelope: `body.encrypt`
100
+
101
+ If the callback is encrypted, the gateway decrypts it before:
102
+
103
+ - checking the verification token
104
+ - handling URL verification
105
+ - parsing the event body
106
+
107
+ ## Identity Mapping
108
+
109
+ The mapping mode controls how Lark conversations map to Axiom threads.
110
+
111
+ ### `user`
112
+
113
+ - one Lark user maps to one Axiom thread
114
+ - useful for personal assistant behavior
115
+
116
+ ### `group`
117
+
118
+ - one Lark chat maps to one Axiom thread
119
+ - useful for shared team assistant behavior
120
+
121
+ ### `hybrid`
122
+
123
+ - direct chats use user isolation
124
+ - group chats use group isolation
125
+
126
+ This is the recommended default because it matches normal user expectations.
127
+
128
+ Each installation also binds the request to:
129
+
130
+ - one tenant
131
+ - one default assistant
132
+ - optional workspace and project IDs
133
+
134
+ ## Message Flow
135
+
136
+ ```text
137
+ Lark callback
138
+ -> gateway route
139
+ -> decrypt / verify
140
+ -> parse text event
141
+ -> claim inbound receipt
142
+ -> resolve or create thread mapping
143
+ -> run assistant on existing gateway/core pipeline
144
+ -> aggregate assistant text
145
+ -> send reply through official Lark SDK
146
+ ```
147
+
148
+ ## Local Debugging
149
+
150
+ To test locally, the Feishu callback URL must be reachable from Feishu.
151
+
152
+ Typical options:
153
+
154
+ - a public dev domain
155
+ - an internal network tunnel
156
+
157
+ Example callback URL:
158
+
159
+ ```text
160
+ https://your-tunnel.example.com/api/channels/lark/installations/<installationId>/events
161
+ ```
162
+
163
+ ## What To Check When It Fails
164
+
165
+ 1. `LARK_ENABLED` is set to `true`
166
+ 2. `DATABASE_URL` is set and reachable
167
+ 3. the installation record exists in `lattice_channel_installations`
168
+ 4. the installation `appId` / `appSecret` are correct
169
+ 5. the installation `verificationToken` matches the Feishu console value
170
+ 6. the installation `encryptKey` matches the Feishu console value
171
+ 7. the callback URL is publicly reachable
172
+ 8. the Feishu app is subscribed to `im.message.receive_v1`
173
+
174
+ ## Data Written
175
+
176
+ This module writes to PostgreSQL tables created by the gateway migration path:
177
+
178
+ - `lattice_channel_installations`
179
+ - `channel_identity_mappings`
180
+ - `channel_inbound_message_receipts`
181
+
182
+ ## Known Limits
183
+
184
+ - text only
185
+ - one default assistant per installation
186
+ - no proactive outbound messaging workflow yet
187
+ - no card/file/image handling yet
@@ -0,0 +1,23 @@
1
+ import { MessageChunkTypes } from "@axiom-lattice/protocols";
2
+ import { aggregateLarkReply } from "../aggregator";
3
+
4
+ describe("aggregateLarkReply", () => {
5
+ it("joins ai chunks for the requested message id", () => {
6
+ const result = aggregateLarkReply("msg-1", [
7
+ { type: MessageChunkTypes.AI, data: { id: "msg-1", content: "Hel" } },
8
+ { type: MessageChunkTypes.AI, data: { id: "msg-2", content: "skip" } },
9
+ { type: MessageChunkTypes.TOOL, data: { id: "msg-1", content: "ignore" } },
10
+ { type: MessageChunkTypes.AI, data: { id: "msg-1", content: "lo" } },
11
+ ]);
12
+
13
+ expect(result).toBe("Hello");
14
+ });
15
+
16
+ it("returns an empty string when no ai content exists for the message", () => {
17
+ const result = aggregateLarkReply("msg-1", [
18
+ { type: MessageChunkTypes.MESSAGE_COMPLETED, data: { id: "msg-1" } },
19
+ ]);
20
+
21
+ expect(result).toBe("");
22
+ });
23
+ });
@@ -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
+ });