@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.
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +19 -0
- package/dist/index.js +1043 -526
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +977 -449
- package/dist/index.mjs.map +1 -1
- package/jest.config.js +5 -0
- package/package.json +6 -5
- package/src/__tests__/__mocks__/e2b.ts +1 -0
- package/src/__tests__/channel-installations.test.ts +199 -0
- package/src/__tests__/sandbox-provider-registration.test.ts +74 -0
- package/src/__tests__/workspace.test.ts +119 -8
- package/src/channels/__tests__/routes.test.ts +71 -0
- package/src/channels/lark/README.md +187 -0
- package/src/channels/lark/__tests__/aggregator.test.ts +23 -0
- package/src/channels/lark/__tests__/controller.test.ts +270 -0
- package/src/channels/lark/__tests__/mapping-service.test.ts +118 -0
- package/src/channels/lark/__tests__/parser.test.ts +72 -0
- package/src/channels/lark/__tests__/sender.test.ts +37 -0
- package/src/channels/lark/__tests__/verification.test.ts +157 -0
- package/src/channels/lark/aggregator.ts +16 -0
- package/src/channels/lark/config.ts +44 -0
- package/src/channels/lark/controller.ts +189 -0
- package/src/channels/lark/mapping-service.ts +138 -0
- package/src/channels/lark/parser.ts +68 -0
- package/src/channels/lark/routes.ts +121 -0
- package/src/channels/lark/runner.ts +37 -0
- package/src/channels/lark/sender.ts +58 -0
- package/src/channels/lark/types.ts +33 -0
- package/src/channels/lark/verification.ts +67 -0
- package/src/channels/routes.ts +25 -0
- package/src/controllers/channel-installations.ts +354 -0
- package/src/controllers/sandbox.ts +30 -80
- package/src/controllers/skills.ts +71 -321
- package/src/controllers/threads.ts +8 -6
- package/src/controllers/workspace.ts +64 -179
- package/src/index.ts +28 -5
- package/src/routes/channel-installations.ts +33 -0
- package/src/routes/index.ts +6 -0
- package/src/schemas/index.ts +2 -2
- package/src/services/sandbox_service.ts +21 -21
- 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
|
+
});
|