@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,44 @@
1
+ import type { LarkIngressConfig } from "./types";
2
+
3
+ export function loadLarkIngressConfig(): LarkIngressConfig {
4
+ return {
5
+ enabled: process.env.LARK_ENABLED !== "false",
6
+ appId: process.env.LARK_APP_ID || "",
7
+ appSecret: process.env.LARK_APP_SECRET || "",
8
+ verificationToken: process.env.LARK_VERIFICATION_TOKEN,
9
+ encryptKey: process.env.LARK_ENCRYPT_KEY,
10
+ tenantId: process.env.LARK_TENANT_ID || "default",
11
+ assistantId: process.env.LARK_ASSISTANT_ID || "default_agent",
12
+ workspaceId: process.env.LARK_WORKSPACE_ID,
13
+ projectId: process.env.LARK_PROJECT_ID,
14
+ mappingMode:
15
+ (process.env.LARK_MAPPING_MODE as "user" | "group" | "hybrid") ||
16
+ "hybrid",
17
+ };
18
+ }
19
+
20
+ export function isLarkIngressEnabled(config: LarkIngressConfig): boolean {
21
+ return config.enabled;
22
+ }
23
+
24
+ export function assertLarkIngressConfig(config: LarkIngressConfig): void {
25
+ if (!config.enabled) {
26
+ return;
27
+ }
28
+
29
+ if (!config.appId) {
30
+ throw new Error("LARK_APP_ID is required when Lark ingress is enabled");
31
+ }
32
+
33
+ if (!config.appSecret) {
34
+ throw new Error("LARK_APP_SECRET is required when Lark ingress is enabled");
35
+ }
36
+
37
+ if (!config.tenantId) {
38
+ throw new Error("LARK_TENANT_ID is required when Lark ingress is enabled");
39
+ }
40
+
41
+ if (!config.assistantId) {
42
+ throw new Error("LARK_ASSISTANT_ID is required when Lark ingress is enabled");
43
+ }
44
+ }
@@ -0,0 +1,189 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ import { parseLarkMessageEvent } from "./parser";
3
+ import type {
4
+ LarkIngressConfig,
5
+ LarkUrlVerificationPayload,
6
+ ParsedLarkMessageEvent,
7
+ } from "./types";
8
+
9
+ export interface LarkEventHandlerDependencies {
10
+ getInstallationConfig: (
11
+ installationId: string,
12
+ ) => Promise<LarkIngressConfig | null>;
13
+ parseRequestBody: (
14
+ body: unknown,
15
+ encryptKey?: string,
16
+ ) => LarkUrlVerificationPayload;
17
+ verifyParsedBody: (
18
+ body: LarkUrlVerificationPayload,
19
+ config: LarkIngressConfig,
20
+ ) => boolean;
21
+ parseEvent: (payload: unknown) => ParsedLarkMessageEvent | null;
22
+ claimInboundReceipt: (input: {
23
+ channel: string;
24
+ channelAppId: string;
25
+ externalMessageId: string;
26
+ tenantId: string;
27
+ }) => Promise<{ accepted: boolean; status: "processing" | "completed" }>;
28
+ markInboundReceiptCompleted: (input: {
29
+ channel: string;
30
+ channelAppId: string;
31
+ externalMessageId: string;
32
+ tenantId: string;
33
+ threadId: string;
34
+ }) => Promise<void>;
35
+ markInboundReceiptFailed: (input: {
36
+ channel: string;
37
+ channelAppId: string;
38
+ externalMessageId: string;
39
+ tenantId: string;
40
+ }) => Promise<void>;
41
+ resolveThread: (input: {
42
+ channel: string;
43
+ channelAppId: string;
44
+ tenantId: string;
45
+ assistantId: string;
46
+ mappingMode: "user" | "group" | "hybrid";
47
+ openId: string;
48
+ chatId: string;
49
+ chatType: "direct" | "group";
50
+ messageId: string;
51
+ workspaceId?: string;
52
+ projectId?: string;
53
+ }) => Promise<{ threadId: string }>;
54
+ runAgentAndCollectText: (input: {
55
+ tenantId: string;
56
+ assistantId: string;
57
+ threadId: string;
58
+ text: string;
59
+ workspaceId?: string;
60
+ projectId?: string;
61
+ }) => Promise<string>;
62
+ sendTextReply: (input: {
63
+ chatId: string;
64
+ text: string;
65
+ config: LarkIngressConfig;
66
+ }) => Promise<void>;
67
+ }
68
+
69
+ export function createLarkEventHandler(
70
+ dependencies: LarkEventHandlerDependencies,
71
+ ) {
72
+ return async function handleLarkEvent(
73
+ request: FastifyRequest,
74
+ reply: FastifyReply,
75
+ ): Promise<void> {
76
+ const installationId = (request.params as { installationId?: string })
77
+ ?.installationId;
78
+
79
+ if (!installationId) {
80
+ reply.status(400).send({ success: false, message: "Missing installationId" });
81
+ return;
82
+ }
83
+
84
+ const config = await dependencies.getInstallationConfig(installationId);
85
+ if (!config) {
86
+ reply.status(404).send({ success: false, message: "Lark installation not found" });
87
+ return;
88
+ }
89
+
90
+ const body = dependencies.parseRequestBody(
91
+ request.body,
92
+ config.encryptKey,
93
+ ) as LarkUrlVerificationPayload;
94
+
95
+ if (!dependencies.verifyParsedBody(body, config)) {
96
+ reply.status(401).send({ success: false, message: "Invalid Lark request" });
97
+ return;
98
+ }
99
+
100
+ if (body.type === "url_verification" && body.challenge) {
101
+ reply.status(200).send({ challenge: body.challenge });
102
+ return;
103
+ }
104
+
105
+ const parsed = dependencies.parseEvent(request.body);
106
+ if (!parsed) {
107
+ reply.status(200).send({ success: true, ignored: true });
108
+ return;
109
+ }
110
+
111
+ const receipt = await dependencies.claimInboundReceipt({
112
+ channel: "lark",
113
+ channelAppId: config.appId,
114
+ externalMessageId: parsed.messageId,
115
+ tenantId: config.tenantId,
116
+ });
117
+
118
+ if (!receipt.accepted) {
119
+ reply.status(200).send(
120
+ receipt.status === "processing"
121
+ ? { success: true, processing: true }
122
+ : { success: true, duplicate: true },
123
+ );
124
+ return;
125
+ }
126
+
127
+ try {
128
+ const { threadId } = await dependencies.resolveThread({
129
+ channel: "lark",
130
+ channelAppId: config.appId,
131
+ tenantId: config.tenantId,
132
+ assistantId: config.assistantId,
133
+ mappingMode: config.mappingMode,
134
+ openId: parsed.openId,
135
+ chatId: parsed.chatId,
136
+ chatType: parsed.chatType,
137
+ messageId: parsed.messageId,
138
+ workspaceId: config.workspaceId,
139
+ projectId: config.projectId,
140
+ });
141
+
142
+ const text = await dependencies.runAgentAndCollectText({
143
+ tenantId: config.tenantId,
144
+ assistantId: config.assistantId,
145
+ threadId,
146
+ text: parsed.text,
147
+ workspaceId: config.workspaceId,
148
+ projectId: config.projectId,
149
+ });
150
+
151
+ await dependencies.sendTextReply({
152
+ chatId: parsed.chatId,
153
+ text,
154
+ config,
155
+ });
156
+
157
+ await dependencies.markInboundReceiptCompleted({
158
+ channel: "lark",
159
+ channelAppId: config.appId,
160
+ externalMessageId: parsed.messageId,
161
+ tenantId: config.tenantId,
162
+ threadId,
163
+ });
164
+
165
+ reply.status(200).send({ success: true, threadId });
166
+ } catch (error) {
167
+ await dependencies.markInboundReceiptFailed({
168
+ channel: "lark",
169
+ channelAppId: config.appId,
170
+ externalMessageId: parsed.messageId,
171
+ tenantId: config.tenantId,
172
+ });
173
+ throw error;
174
+ }
175
+ };
176
+ }
177
+
178
+ export const handleLarkEvent = createLarkEventHandler({
179
+ getInstallationConfig: async () => null,
180
+ parseRequestBody: (body) => (body || {}) as LarkUrlVerificationPayload,
181
+ verifyParsedBody: () => true,
182
+ parseEvent: parseLarkMessageEvent,
183
+ claimInboundReceipt: async () => ({ accepted: true, status: "processing" }),
184
+ markInboundReceiptCompleted: async () => undefined,
185
+ markInboundReceiptFailed: async () => undefined,
186
+ resolveThread: async () => ({ threadId: "" }),
187
+ runAgentAndCollectText: async () => "",
188
+ sendTextReply: async () => undefined,
189
+ });
@@ -0,0 +1,138 @@
1
+ import type { ThreadStore } from "@axiom-lattice/protocols";
2
+ import type { ChannelIdentityMappingStore } from "@axiom-lattice/pg-stores";
3
+ import { randomUUID } from "crypto";
4
+
5
+ interface GetOrCreateThreadInput {
6
+ channel: string;
7
+ channelAppId: string;
8
+ tenantId: string;
9
+ assistantId: string;
10
+ mappingMode: "user" | "group" | "hybrid";
11
+ openId: string;
12
+ chatId: string;
13
+ chatType: "direct" | "group";
14
+ messageId: string;
15
+ workspaceId?: string;
16
+ projectId?: string;
17
+ }
18
+
19
+ interface ChannelThreadMappingServiceDeps {
20
+ mappingStore: Pick<
21
+ ChannelIdentityMappingStore,
22
+ "getMappingBySubject" | "createMapping"
23
+ >;
24
+ threadStore: Pick<ThreadStore, "createThread" | "deleteThread">;
25
+ uuid?: () => string;
26
+ }
27
+
28
+ export function createChannelThreadMappingService(
29
+ deps: ChannelThreadMappingServiceDeps,
30
+ ) {
31
+ return {
32
+ async getOrCreateThread(input: GetOrCreateThreadInput): Promise<{ threadId: string }> {
33
+ const externalSubjectKey = buildExternalSubjectKey(input);
34
+ const existing = await deps.mappingStore.getMappingBySubject({
35
+ channel: input.channel,
36
+ channelAppId: input.channelAppId,
37
+ tenantId: input.tenantId,
38
+ assistantId: input.assistantId,
39
+ externalSubjectKey,
40
+ });
41
+
42
+ if (existing) {
43
+ return { threadId: existing.threadId };
44
+ }
45
+
46
+ const threadId = (deps.uuid || randomUUID)();
47
+
48
+ await deps.threadStore.createThread(input.tenantId, input.assistantId, threadId, {
49
+ metadata: {
50
+ tenantId: input.tenantId,
51
+ workspaceId: input.workspaceId,
52
+ projectId: input.projectId,
53
+ channel: input.channel,
54
+ larkChatId: input.chatId,
55
+ larkOpenId: input.openId,
56
+ },
57
+ });
58
+
59
+ try {
60
+ await deps.mappingStore.createMapping({
61
+ channel: input.channel,
62
+ channelAppId: input.channelAppId,
63
+ tenantId: input.tenantId,
64
+ assistantId: input.assistantId,
65
+ mappingMode: input.mappingMode,
66
+ externalSubjectType:
67
+ resolveSubjectType(input.mappingMode, input.chatType) === "user"
68
+ ? "user"
69
+ : "chat",
70
+ externalSubjectKey,
71
+ larkOpenId: input.openId,
72
+ larkChatId: input.chatId,
73
+ larkMessageId: input.messageId,
74
+ threadId,
75
+ });
76
+ } catch (error) {
77
+ if (!isUniqueViolation(error)) {
78
+ throw error;
79
+ }
80
+
81
+ const canonical = await deps.mappingStore.getMappingBySubject({
82
+ channel: input.channel,
83
+ channelAppId: input.channelAppId,
84
+ tenantId: input.tenantId,
85
+ assistantId: input.assistantId,
86
+ externalSubjectKey,
87
+ });
88
+
89
+ if (!canonical) {
90
+ throw error;
91
+ }
92
+
93
+ await deps.threadStore.deleteThread(input.tenantId, threadId);
94
+
95
+ return { threadId: canonical.threadId };
96
+ }
97
+
98
+ return { threadId };
99
+ },
100
+ };
101
+ }
102
+
103
+ function isUniqueViolation(error: unknown): boolean {
104
+ return (
105
+ typeof error === "object" &&
106
+ error !== null &&
107
+ "code" in error &&
108
+ (error as { code?: string }).code === "23505"
109
+ );
110
+ }
111
+
112
+ function buildExternalSubjectKey(input: GetOrCreateThreadInput): string {
113
+ const subjectType = resolveSubjectType(input.mappingMode, input.chatType);
114
+ const subjectValue = subjectType === "user" ? input.openId : input.chatId;
115
+
116
+ return [
117
+ input.channel,
118
+ input.channelAppId,
119
+ `tenant:${input.tenantId}`,
120
+ `assistant:${input.assistantId}`,
121
+ `${subjectType}:${subjectValue}`,
122
+ ].join(":");
123
+ }
124
+
125
+ function resolveSubjectType(
126
+ mappingMode: "user" | "group" | "hybrid",
127
+ chatType: "direct" | "group",
128
+ ): "user" | "chat" {
129
+ if (mappingMode === "user") {
130
+ return "user";
131
+ }
132
+
133
+ if (mappingMode === "group") {
134
+ return "chat";
135
+ }
136
+
137
+ return chatType === "direct" ? "user" : "chat";
138
+ }
@@ -0,0 +1,68 @@
1
+ import type { ParsedLarkMessageEvent } from "./types";
2
+
3
+ interface RawLarkPayload {
4
+ header?: {
5
+ event_type?: string;
6
+ };
7
+ event?: {
8
+ sender?: {
9
+ sender_id?: {
10
+ open_id?: string;
11
+ };
12
+ };
13
+ message?: {
14
+ message_id?: string;
15
+ chat_id?: string;
16
+ chat_type?: string;
17
+ message_type?: string;
18
+ content?: string;
19
+ };
20
+ };
21
+ }
22
+
23
+ export function parseLarkMessageEvent(
24
+ payload: unknown,
25
+ ): ParsedLarkMessageEvent | null {
26
+ const raw = payload as RawLarkPayload;
27
+
28
+ if (raw.header?.event_type !== "im.message.receive_v1") {
29
+ return null;
30
+ }
31
+
32
+ const message = raw.event?.message;
33
+ const openId = raw.event?.sender?.sender_id?.open_id;
34
+
35
+ if (!message || !openId || message.message_type !== "text") {
36
+ return null;
37
+ }
38
+
39
+ const parsedContent = parseLarkTextContent(message.content);
40
+ if (!message.message_id || !message.chat_id || !parsedContent) {
41
+ return null;
42
+ }
43
+
44
+ return {
45
+ messageId: message.message_id,
46
+ openId,
47
+ chatId: message.chat_id,
48
+ chatType: normalizeChatType(message.chat_type),
49
+ text: parsedContent,
50
+ };
51
+ }
52
+
53
+ function parseLarkTextContent(content: string | undefined): string | null {
54
+ if (!content) {
55
+ return null;
56
+ }
57
+
58
+ try {
59
+ const parsed = JSON.parse(content) as { text?: string };
60
+ return typeof parsed.text === "string" ? parsed.text : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function normalizeChatType(chatType: string | undefined): "direct" | "group" {
67
+ return chatType === "p2p" ? "direct" : "group";
68
+ }
@@ -0,0 +1,121 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import { getStoreLattice } from "@axiom-lattice/core";
3
+ import {
4
+ ChannelIdentityMappingStore,
5
+ PostgreSQLChannelInstallationStore,
6
+ } from "@axiom-lattice/pg-stores";
7
+ import {
8
+ createLarkEventHandler,
9
+ type LarkEventHandlerDependencies,
10
+ } from "./controller";
11
+ import {
12
+ isLarkIngressEnabled,
13
+ loadLarkIngressConfig,
14
+ } from "./config";
15
+ import { createChannelThreadMappingService } from "./mapping-service";
16
+ import { parseLarkMessageEvent } from "./parser";
17
+ import { runAgentAndCollectLarkReply } from "./runner";
18
+ import { createLarkSender } from "./sender";
19
+ import { createLarkRequestVerifier, parseLarkRequestBody } from "./verification";
20
+
21
+ export function registerLarkChannelRoutes(
22
+ app: FastifyInstance,
23
+ dependencies?: LarkEventHandlerDependencies,
24
+ ): void {
25
+ const config = loadLarkIngressConfig();
26
+ if (!dependencies && !isLarkIngressEnabled(config)) {
27
+ return;
28
+ }
29
+ const handlerDependencies = dependencies || createDefaultLarkDependencies();
30
+
31
+ app.post(
32
+ "/api/channels/lark/installations/:installationId/events",
33
+ createLarkEventHandler({
34
+ ...handlerDependencies,
35
+ }),
36
+ );
37
+ }
38
+
39
+ function createDefaultLarkDependencies(): LarkEventHandlerDependencies {
40
+ const installationStore = new PostgreSQLChannelInstallationStore({
41
+ poolConfig: getDatabaseUrl(),
42
+ });
43
+ const threadStore = getStoreLattice("default", "thread").store;
44
+ const mappingStore = new ChannelIdentityMappingStore({
45
+ poolConfig: getDatabaseUrl(),
46
+ });
47
+ const mappingService = createChannelThreadMappingService({
48
+ mappingStore,
49
+ threadStore,
50
+ });
51
+
52
+ return {
53
+ getInstallationConfig: async (installationId) => {
54
+ const installation = await installationStore.getInstallationById(
55
+ installationId,
56
+ );
57
+
58
+ if (!installation || installation.channel !== "lark") {
59
+ return null;
60
+ }
61
+
62
+ return {
63
+ enabled: true,
64
+ installationId: installation.id,
65
+ tenantId: installation.tenantId,
66
+ assistantId: installation.config.assistantId,
67
+ appId: installation.config.appId,
68
+ appSecret: installation.config.appSecret,
69
+ verificationToken: installation.config.verificationToken,
70
+ encryptKey: installation.config.encryptKey,
71
+ workspaceId: installation.config.workspaceId,
72
+ projectId: installation.config.projectId,
73
+ mappingMode: installation.config.mappingMode,
74
+ };
75
+ },
76
+ parseRequestBody: (body, encryptKey) => parseLarkRequestBody(body, encryptKey),
77
+ verifyParsedBody: (body, config) => {
78
+ if (!config.verificationToken) {
79
+ return true;
80
+ }
81
+
82
+ return createLarkRequestVerifier(config)({
83
+ body,
84
+ } as unknown as Parameters<ReturnType<typeof createLarkRequestVerifier>>[0]);
85
+ },
86
+ parseEvent: parseLarkMessageEvent,
87
+ claimInboundReceipt: (input) => mappingStore.claimInboundReceipt(input),
88
+ markInboundReceiptCompleted: (input) =>
89
+ mappingStore.markInboundReceiptCompleted(input),
90
+ markInboundReceiptFailed: (input) =>
91
+ mappingStore.markInboundReceiptFailed(input),
92
+ resolveThread: (input) => mappingService.getOrCreateThread(input),
93
+ runAgentAndCollectText: ({ tenantId, assistantId, threadId, text, workspaceId, projectId }) =>
94
+ runAgentAndCollectLarkReply({
95
+ tenantId,
96
+ assistantId,
97
+ threadId,
98
+ text,
99
+ workspaceId,
100
+ projectId,
101
+ }),
102
+ sendTextReply: async ({ chatId, text, config }) => {
103
+ const sender = createLarkSender({
104
+ appId: config.appId,
105
+ appSecret: config.appSecret,
106
+ });
107
+
108
+ await sender.sendTextReply({ chatId, text });
109
+ },
110
+ };
111
+ }
112
+
113
+ function getDatabaseUrl(): string {
114
+ const databaseUrl = process.env.DATABASE_URL;
115
+
116
+ if (!databaseUrl) {
117
+ throw new Error("DATABASE_URL is required for Lark channel ingress");
118
+ }
119
+
120
+ return databaseUrl;
121
+ }
@@ -0,0 +1,37 @@
1
+ import { agentInstanceManager } from "@axiom-lattice/core";
2
+ import { MessageChunkTypes } from "@axiom-lattice/protocols";
3
+ import { aggregateLarkReply } from "./aggregator";
4
+
5
+ export async function runAgentAndCollectLarkReply(input: {
6
+ tenantId: string;
7
+ assistantId: string;
8
+ threadId: string;
9
+ text: string;
10
+ workspaceId?: string;
11
+ projectId?: string;
12
+ }): Promise<string> {
13
+ const agent = agentInstanceManager.getAgent({
14
+ tenant_id: input.tenantId,
15
+ assistant_id: input.assistantId,
16
+ thread_id: input.threadId,
17
+ workspace_id: input.workspaceId,
18
+ project_id: input.projectId,
19
+ });
20
+
21
+ const result = await agent.addMessage({
22
+ input: {
23
+ message: input.text,
24
+ },
25
+ });
26
+
27
+ const chunks = [];
28
+ const stream = agent.chunkStream(result.messageId, [
29
+ MessageChunkTypes.MESSAGE_COMPLETED,
30
+ ]);
31
+
32
+ for await (const chunk of stream) {
33
+ chunks.push(chunk);
34
+ }
35
+
36
+ return aggregateLarkReply(result.messageId, chunks);
37
+ }
@@ -0,0 +1,58 @@
1
+ interface LarkSenderConfig {
2
+ appId: string;
3
+ appSecret: string;
4
+ }
5
+
6
+ interface LarkSdkMessageClient {
7
+ im: {
8
+ v1: {
9
+ message: {
10
+ create(input: {
11
+ params: {
12
+ receive_id_type: "chat_id";
13
+ };
14
+ data: {
15
+ receive_id: string;
16
+ msg_type: "text";
17
+ content: string;
18
+ };
19
+ }): Promise<{ code?: number }>;
20
+ };
21
+ };
22
+ };
23
+ }
24
+
25
+ export function createLarkSender(
26
+ config: LarkSenderConfig,
27
+ client: LarkSdkMessageClient = createDefaultLarkClient(config),
28
+ ) {
29
+ return {
30
+ async sendTextReply(input: { chatId: string; text: string }): Promise<void> {
31
+ const response = await client.im.v1.message.create({
32
+ params: {
33
+ receive_id_type: "chat_id",
34
+ },
35
+ data: {
36
+ receive_id: input.chatId,
37
+ msg_type: "text",
38
+ content: JSON.stringify({ text: input.text }),
39
+ },
40
+ });
41
+
42
+ if (response.code && response.code !== 0) {
43
+ throw new Error("Failed to send Lark reply");
44
+ }
45
+ },
46
+ };
47
+ }
48
+
49
+ function createDefaultLarkClient(config: LarkSenderConfig): LarkSdkMessageClient {
50
+ const Lark = require("@larksuiteoapi/node-sdk") as {
51
+ Client: new (input: { appId: string; appSecret: string }) => unknown;
52
+ };
53
+
54
+ return new Lark.Client({
55
+ appId: config.appId,
56
+ appSecret: config.appSecret,
57
+ }) as LarkSdkMessageClient;
58
+ }
@@ -0,0 +1,33 @@
1
+ export interface ParsedLarkMessageEvent {
2
+ messageId: string;
3
+ openId: string;
4
+ chatId: string;
5
+ chatType: "direct" | "group";
6
+ text: string;
7
+ }
8
+
9
+ export interface LarkUrlVerificationPayload {
10
+ type?: string;
11
+ challenge?: string;
12
+ token?: string;
13
+ schema?: string;
14
+ header?: {
15
+ token?: string;
16
+ event_type?: string;
17
+ };
18
+ encrypt?: string;
19
+ }
20
+
21
+ export interface LarkIngressConfig {
22
+ enabled: boolean;
23
+ installationId?: string;
24
+ appId: string;
25
+ appSecret: string;
26
+ verificationToken?: string;
27
+ encryptKey?: string;
28
+ tenantId: string;
29
+ assistantId: string;
30
+ workspaceId?: string;
31
+ projectId?: string;
32
+ mappingMode: "user" | "group" | "hybrid";
33
+ }