@nextclaw/channel-extension-feishu 0.1.1

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,214 @@
1
+ import { FEISHU_CHANNEL_ID, buildRegisteredFeishuChannelConfig, normalizeFeishuChannelConfig } from "../utils/feishu-config.utils.js";
2
+ import { FileFeishuAccountStore } from "../stores/feishu-account.store.js";
3
+ import { randomUUID } from "node:crypto";
4
+ //#region src/services/feishu-registration.service.ts
5
+ const REGISTRATION_PATH = "/oauth/v1/app/registration";
6
+ const FEISHU_ACCOUNTS_BASE_URLS = {
7
+ feishu: "https://accounts.feishu.cn",
8
+ lark: "https://accounts.larksuite.com"
9
+ };
10
+ const FEISHU_OPEN_BASE_URLS = {
11
+ feishu: "https://open.feishu.cn",
12
+ lark: "https://open.larksuite.com"
13
+ };
14
+ const FEISHU_AUTH_POLL_INTERVAL_MS = 5e3;
15
+ const FEISHU_REGISTRATION_TIMEOUT_MS = 10 * 6e4;
16
+ function readString(value) {
17
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
18
+ }
19
+ function readDomain(value) {
20
+ return value === "feishu" || value === "lark" ? value : void 0;
21
+ }
22
+ function appendHermesQrHints(url) {
23
+ if (!url.trim()) return url;
24
+ return `${url}${url.includes("?") ? "&" : "?"}from=nextclaw&tp=nextclaw`;
25
+ }
26
+ var FeishuRegistrationService = class {
27
+ store;
28
+ fetchImpl;
29
+ sessions = /* @__PURE__ */ new Map();
30
+ constructor(deps = {}) {
31
+ this.store = deps.store ?? new FileFeishuAccountStore();
32
+ this.fetchImpl = deps.fetchImpl ?? fetch;
33
+ }
34
+ start = async (params) => {
35
+ this.cleanupExpiredSessions();
36
+ const currentConfig = normalizeFeishuChannelConfig(params.pluginConfig);
37
+ const domain = params.domain ?? currentConfig.domain ?? "feishu";
38
+ await this.assertRegistrationSupported(domain);
39
+ const begin = await this.beginRegistration(domain);
40
+ const sessionId = randomUUID();
41
+ const expiresAtMs = Date.now() + Math.min(begin.expiresInMs, FEISHU_REGISTRATION_TIMEOUT_MS);
42
+ this.sessions.set(sessionId, {
43
+ currentConfig,
44
+ requestedAccountId: params.requestedAccountId,
45
+ deviceCode: begin.deviceCode,
46
+ domain,
47
+ intervalMs: begin.intervalMs,
48
+ expiresAtMs
49
+ });
50
+ return {
51
+ channel: FEISHU_CHANNEL_ID,
52
+ kind: "qr_code",
53
+ sessionId,
54
+ qrCode: begin.qrUrl,
55
+ qrCodeUrl: begin.qrUrl,
56
+ expiresAt: new Date(expiresAtMs).toISOString(),
57
+ intervalMs: begin.intervalMs,
58
+ note: "请使用飞书或 Lark 扫码授权,NextClaw 会自动创建机器人应用并保存连接信息。"
59
+ };
60
+ };
61
+ poll = async ({ sessionId }) => {
62
+ this.cleanupExpiredSessions();
63
+ const session = this.sessions.get(sessionId);
64
+ if (!session) return null;
65
+ if (session.expiresAtMs <= Date.now()) {
66
+ this.sessions.delete(sessionId);
67
+ return {
68
+ channel: FEISHU_CHANNEL_ID,
69
+ status: "expired",
70
+ message: "飞书扫码授权已过期,请重新开始。"
71
+ };
72
+ }
73
+ try {
74
+ const response = await this.postRegistration(session.domain, {
75
+ action: "poll",
76
+ device_code: session.deviceCode,
77
+ tp: "ob_app"
78
+ });
79
+ const userInfo = this.readRecord(response.user_info) ?? {};
80
+ const switchedDomain = readDomain(userInfo.tenant_brand) ?? session.domain;
81
+ if (switchedDomain !== session.domain) session.domain = switchedDomain;
82
+ const clientId = readString(response.client_id);
83
+ const clientSecret = readString(response.client_secret);
84
+ if (clientId && clientSecret) {
85
+ const result = await this.confirmRegistration({
86
+ session,
87
+ appId: clientId,
88
+ appSecret: clientSecret,
89
+ ownerOpenId: readString(userInfo.open_id)
90
+ });
91
+ this.sessions.delete(sessionId);
92
+ return {
93
+ channel: FEISHU_CHANNEL_ID,
94
+ status: "authorized",
95
+ message: "飞书已连接。",
96
+ nextPollMs: 0,
97
+ accountId: result.accountId,
98
+ notes: result.notes,
99
+ pluginConfig: result.pluginConfig
100
+ };
101
+ }
102
+ const error = readString(response.error);
103
+ if (error === "access_denied" || error === "expired_token") {
104
+ this.sessions.delete(sessionId);
105
+ return {
106
+ channel: FEISHU_CHANNEL_ID,
107
+ status: "expired",
108
+ message: error === "access_denied" ? "飞书授权已取消。" : "飞书扫码授权已过期。"
109
+ };
110
+ }
111
+ return {
112
+ channel: FEISHU_CHANNEL_ID,
113
+ status: "pending",
114
+ nextPollMs: session.intervalMs
115
+ };
116
+ } catch (error) {
117
+ return {
118
+ channel: FEISHU_CHANNEL_ID,
119
+ status: "error",
120
+ message: error instanceof Error ? error.message : String(error)
121
+ };
122
+ }
123
+ };
124
+ assertRegistrationSupported = async (domain) => {
125
+ const response = await this.postRegistration(domain, { action: "init" });
126
+ const methods = Array.isArray(response.supported_auth_methods) ? response.supported_auth_methods : [];
127
+ if (!methods.includes("client_secret")) throw new Error(`Feishu registration does not support client_secret auth. Supported: ${methods.join(", ")}`);
128
+ };
129
+ beginRegistration = async (domain) => {
130
+ const response = await this.postRegistration(domain, {
131
+ action: "begin",
132
+ archetype: "PersonalAgent",
133
+ auth_method: "client_secret",
134
+ request_user_info: "open_id"
135
+ });
136
+ const deviceCode = readString(response.device_code);
137
+ const qrUrl = readString(response.verification_uri_complete);
138
+ if (!deviceCode || !qrUrl) throw new Error("Feishu registration did not return a device code and QR URL.");
139
+ return {
140
+ deviceCode,
141
+ qrUrl: appendHermesQrHints(qrUrl),
142
+ intervalMs: Math.max(1, Number(response.interval ?? 5)) * 1e3 || FEISHU_AUTH_POLL_INTERVAL_MS,
143
+ expiresInMs: Math.max(60, Number(response.expire_in ?? 600)) * 1e3
144
+ };
145
+ };
146
+ postRegistration = async (domain, body) => {
147
+ const response = await this.fetchImpl(`${FEISHU_ACCOUNTS_BASE_URLS[domain]}${REGISTRATION_PATH}`, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
150
+ body: new URLSearchParams(body).toString()
151
+ });
152
+ const data = await response.json();
153
+ if (!response.ok && !data.error) throw new Error(`Feishu registration failed: HTTP ${response.status}`);
154
+ return data;
155
+ };
156
+ confirmRegistration = async ({ appId, appSecret, ownerOpenId, session }) => {
157
+ const botInfo = await this.probeBot({
158
+ appId,
159
+ appSecret,
160
+ domain: session.domain
161
+ });
162
+ const accountId = session.requestedAccountId?.trim() || appId;
163
+ this.store.saveAccount({
164
+ accountId,
165
+ appId,
166
+ appSecret,
167
+ domain: session.domain,
168
+ botName: botInfo.botName,
169
+ botOpenId: botInfo.botOpenId,
170
+ ownerOpenId,
171
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
172
+ });
173
+ return {
174
+ accountId,
175
+ notes: [...botInfo.botName ? [`Connected bot: ${botInfo.botName}`] : [], ...ownerOpenId ? [`Authorized initial user: ${ownerOpenId}`] : []],
176
+ pluginConfig: buildRegisteredFeishuChannelConfig({
177
+ config: session.currentConfig,
178
+ accountId,
179
+ domain: session.domain,
180
+ botName: botInfo.botName,
181
+ allowOpenId: ownerOpenId
182
+ })
183
+ };
184
+ };
185
+ probeBot = async ({ appId, appSecret, domain }) => {
186
+ const accessToken = readString((await (await this.fetchImpl(`${FEISHU_OPEN_BASE_URLS[domain]}/open-apis/auth/v3/tenant_access_token/internal`, {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({
190
+ app_id: appId,
191
+ app_secret: appSecret
192
+ })
193
+ })).json()).tenant_access_token);
194
+ if (!accessToken) return {};
195
+ const botData = await (await this.fetchImpl(`${FEISHU_OPEN_BASE_URLS[domain]}/open-apis/bot/v3/info`, { headers: {
196
+ Authorization: `Bearer ${accessToken}`,
197
+ "Content-Type": "application/json"
198
+ } })).json();
199
+ const bot = this.readRecord(botData.bot) ?? this.readRecord(this.readRecord(botData.data)?.bot);
200
+ return {
201
+ botName: readString(bot?.app_name) ?? readString(bot?.bot_name),
202
+ botOpenId: readString(bot?.open_id)
203
+ };
204
+ };
205
+ readRecord = (value) => {
206
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
207
+ return value;
208
+ };
209
+ cleanupExpiredSessions = (now = Date.now()) => {
210
+ for (const [sessionId, session] of this.sessions.entries()) if (session.expiresAtMs <= now) this.sessions.delete(sessionId);
211
+ };
212
+ };
213
+ //#endregion
214
+ export { FeishuRegistrationService };
@@ -0,0 +1,92 @@
1
+ import { NcpEventType } from "@nextclaw/ncp";
2
+ import "@nextclaw/ncp-toolkit";
3
+ //#region src/services/feishu-reply-chat.service.ts
4
+ const TERMINAL_NCP_EVENT_TYPES = new Set([
5
+ NcpEventType.MessageCompleted,
6
+ NcpEventType.MessageFailed,
7
+ NcpEventType.RunFinished,
8
+ NcpEventType.RunError
9
+ ]);
10
+ var NcpEventQueue = class {
11
+ events = [];
12
+ waiting = null;
13
+ closed = false;
14
+ push = (event) => {
15
+ if (this.closed) return;
16
+ const waiting = this.waiting;
17
+ if (waiting) {
18
+ this.waiting = null;
19
+ waiting({
20
+ value: event,
21
+ done: false
22
+ });
23
+ return;
24
+ }
25
+ this.events.push(event);
26
+ };
27
+ close = () => {
28
+ this.closed = true;
29
+ const waiting = this.waiting;
30
+ if (waiting) {
31
+ this.waiting = null;
32
+ waiting({
33
+ value: void 0,
34
+ done: true
35
+ });
36
+ }
37
+ };
38
+ [Symbol.asyncIterator] = () => ({ next: async () => {
39
+ const event = this.events.shift();
40
+ if (event) return {
41
+ value: event,
42
+ done: false
43
+ };
44
+ if (this.closed) return {
45
+ value: void 0,
46
+ done: true
47
+ };
48
+ return await new Promise((resolve) => {
49
+ this.waiting = resolve;
50
+ });
51
+ } });
52
+ };
53
+ function renderPartText(part) {
54
+ switch (part.type) {
55
+ case "text":
56
+ case "rich-text": return part.text;
57
+ case "source": return [
58
+ part.title ?? "",
59
+ part.url ?? "",
60
+ part.snippet ?? ""
61
+ ].filter(Boolean).join("\n");
62
+ case "card": return (typeof part.payload.title === "string" ? part.payload.title.trim() : "") || JSON.stringify(part.payload);
63
+ case "action": return part.label.trim();
64
+ case "step-start": return part.title?.trim() ?? "";
65
+ case "file": return part.name ? `[file] ${part.name}` : "[file]";
66
+ default: return "";
67
+ }
68
+ }
69
+ var FeishuReplyChat = class {
70
+ constructor(deps) {
71
+ this.deps = deps;
72
+ }
73
+ startTyping = async (_target) => {};
74
+ stopTyping = async (_target) => {};
75
+ sendError = async (target, message) => {
76
+ if (message.trim()) await this.sendText(target, message);
77
+ };
78
+ sendPart = async (target, part) => {
79
+ const text = renderPartText(part);
80
+ if (text.trim()) await this.sendText(target, text);
81
+ };
82
+ sendText = async (target, text) => {
83
+ const account = this.deps.resolveAccount(target);
84
+ await this.deps.sendText({
85
+ account,
86
+ conversationId: target.conversationId,
87
+ text
88
+ });
89
+ };
90
+ };
91
+ //#endregion
92
+ export { FeishuReplyChat, NcpEventQueue, TERMINAL_NCP_EVENT_TYPES };
@@ -0,0 +1,75 @@
1
+ import { FeishuRuntimeAccount } from "../types/feishu-extension.types.js";
2
+ import * as Lark from "@larksuiteoapi/node-sdk";
3
+
4
+ //#region src/services/feishu-sdk.service.d.ts
5
+ type FeishuMessageClient = {
6
+ im: {
7
+ message: {
8
+ create: (params: {
9
+ params: {
10
+ receive_id_type: "chat_id";
11
+ };
12
+ data: {
13
+ receive_id: string;
14
+ content: string;
15
+ msg_type: "text";
16
+ };
17
+ }) => Promise<{
18
+ code?: number;
19
+ msg?: string;
20
+ data?: {
21
+ message_id?: string;
22
+ };
23
+ }>;
24
+ };
25
+ messageReaction: {
26
+ create: (params: {
27
+ path: {
28
+ message_id: string;
29
+ };
30
+ data: {
31
+ reaction_type: {
32
+ emoji_type: string;
33
+ };
34
+ };
35
+ }) => Promise<{
36
+ code?: number;
37
+ msg?: string;
38
+ data?: {
39
+ reaction_id?: string;
40
+ };
41
+ }>;
42
+ delete: (params: {
43
+ path: {
44
+ message_id: string;
45
+ reaction_id: string;
46
+ };
47
+ }) => Promise<{
48
+ code?: number;
49
+ msg?: string;
50
+ }>;
51
+ };
52
+ };
53
+ };
54
+ declare class FeishuSdkService {
55
+ readonly createClient: (account: FeishuRuntimeAccount) => FeishuMessageClient;
56
+ readonly createEventDispatcher: () => Lark.EventDispatcher;
57
+ readonly createWsClient: (account: FeishuRuntimeAccount) => Lark.WSClient;
58
+ readonly sendText: (params: {
59
+ account: FeishuRuntimeAccount;
60
+ chatId: string;
61
+ text: string;
62
+ }) => Promise<void>;
63
+ readonly addReaction: (params: {
64
+ account: FeishuRuntimeAccount;
65
+ messageId: string;
66
+ emojiType: string;
67
+ }) => Promise<string | null>;
68
+ readonly deleteReaction: (params: {
69
+ account: FeishuRuntimeAccount;
70
+ messageId: string;
71
+ reactionId: string;
72
+ }) => Promise<void>;
73
+ }
74
+ //#endregion
75
+ export { FeishuSdkService };
@@ -0,0 +1,50 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ //#region src/services/feishu-sdk.service.ts
3
+ function resolveDomain(domain) {
4
+ return domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
5
+ }
6
+ function assertFeishuSuccess(response, fallback) {
7
+ if (response.code === 0 || response.code === void 0) return;
8
+ throw new Error(`${fallback}: ${response.msg ?? `code ${response.code}`}`);
9
+ }
10
+ var FeishuSdkService = class {
11
+ createClient = (account) => new Lark.Client({
12
+ appId: account.appId,
13
+ appSecret: account.appSecret,
14
+ appType: Lark.AppType.SelfBuild,
15
+ domain: resolveDomain(account.domain)
16
+ });
17
+ createEventDispatcher = () => new Lark.EventDispatcher({});
18
+ createWsClient = (account) => new Lark.WSClient({
19
+ appId: account.appId,
20
+ appSecret: account.appSecret,
21
+ domain: resolveDomain(account.domain),
22
+ loggerLevel: Lark.LoggerLevel.info
23
+ });
24
+ sendText = async (params) => {
25
+ assertFeishuSuccess(await this.createClient(params.account).im.message.create({
26
+ params: { receive_id_type: "chat_id" },
27
+ data: {
28
+ receive_id: params.chatId,
29
+ content: JSON.stringify({ text: params.text }),
30
+ msg_type: "text"
31
+ }
32
+ }), "Feishu send failed");
33
+ };
34
+ addReaction = async (params) => {
35
+ const response = await this.createClient(params.account).im.messageReaction.create({
36
+ path: { message_id: params.messageId },
37
+ data: { reaction_type: { emoji_type: params.emojiType } }
38
+ });
39
+ assertFeishuSuccess(response, "Feishu reaction failed");
40
+ return response.data?.reaction_id ?? null;
41
+ };
42
+ deleteReaction = async (params) => {
43
+ assertFeishuSuccess(await this.createClient(params.account).im.messageReaction.delete({ path: {
44
+ message_id: params.messageId,
45
+ reaction_id: params.reactionId
46
+ } }), "Feishu reaction delete failed");
47
+ };
48
+ };
49
+ //#endregion
50
+ export { FeishuSdkService };
@@ -0,0 +1,21 @@
1
+ import { FeishuDomain } from "../types/feishu-extension.types.js";
2
+
3
+ //#region src/stores/feishu-account.store.d.ts
4
+ type StoredFeishuAccount = {
5
+ accountId: string;
6
+ appId: string;
7
+ appSecret: string;
8
+ domain: FeishuDomain;
9
+ botName?: string;
10
+ botOpenId?: string;
11
+ ownerOpenId?: string;
12
+ savedAt?: string;
13
+ };
14
+ type FeishuAccountStore = {
15
+ listAccountIds: () => string[];
16
+ loadAccount: (accountId: string) => StoredFeishuAccount | null;
17
+ saveAccount: (account: StoredFeishuAccount) => void;
18
+ deleteAccount: (accountId: string) => void;
19
+ };
20
+ //#endregion
21
+ export { FeishuAccountStore };
@@ -0,0 +1,40 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ //#region src/stores/feishu-account.store.ts
5
+ function resolveNextclawHome() {
6
+ const override = process.env.NEXTCLAW_HOME?.trim();
7
+ return resolve(override || join(homedir(), ".nextclaw"));
8
+ }
9
+ function resolveAccountsDir() {
10
+ return join(resolveNextclawHome(), "channels", "feishu", "accounts");
11
+ }
12
+ function toFileName(accountId) {
13
+ return `${encodeURIComponent(accountId)}.json`;
14
+ }
15
+ function readJsonFile(filePath) {
16
+ if (!existsSync(filePath)) return null;
17
+ try {
18
+ return JSON.parse(readFileSync(filePath, "utf-8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ var FileFeishuAccountStore = class {
24
+ listAccountIds = () => {
25
+ if (!existsSync(resolveAccountsDir())) return [];
26
+ return readdirSync(resolveAccountsDir()).filter((entry) => entry.endsWith(".json")).map((entry) => decodeURIComponent(entry.slice(0, -5))).filter(Boolean);
27
+ };
28
+ loadAccount = (accountId) => {
29
+ return readJsonFile(join(resolveAccountsDir(), toFileName(accountId)));
30
+ };
31
+ saveAccount = (account) => {
32
+ mkdirSync(resolveAccountsDir(), { recursive: true });
33
+ writeFileSync(join(resolveAccountsDir(), toFileName(account.accountId)), JSON.stringify(account, null, 2));
34
+ };
35
+ deleteAccount = (accountId) => {
36
+ rmSync(join(resolveAccountsDir(), toFileName(accountId)), { force: true });
37
+ };
38
+ };
39
+ //#endregion
40
+ export { FileFeishuAccountStore };
@@ -0,0 +1,51 @@
1
+ import { NcpEndpointEvent } from "@nextclaw/ncp";
2
+
3
+ //#region src/types/feishu-extension.types.d.ts
4
+ type FeishuDomain = "feishu" | "lark";
5
+ type FeishuAccountConfig = {
6
+ enabled?: boolean;
7
+ name?: string;
8
+ domain?: FeishuDomain;
9
+ allowFrom?: string[];
10
+ groupPolicy?: "open" | "allowlist" | "disabled";
11
+ requireMention?: boolean;
12
+ };
13
+ type FeishuChannelConfig = {
14
+ enabled?: boolean;
15
+ defaultAccountId?: string;
16
+ domain?: FeishuDomain;
17
+ allowFrom?: string[];
18
+ groupPolicy?: "open" | "allowlist" | "disabled";
19
+ requireMention?: boolean;
20
+ accounts?: Record<string, FeishuAccountConfig>;
21
+ };
22
+ type FeishuInboundMessage = {
23
+ conversationId: string;
24
+ senderId: string;
25
+ text: string;
26
+ accountId: string;
27
+ peerKind: "direct" | "group";
28
+ messageId?: string;
29
+ raw?: unknown;
30
+ };
31
+ type FeishuRuntimeAccount = {
32
+ accountId: string;
33
+ appId: string;
34
+ appSecret: string;
35
+ domain: FeishuDomain;
36
+ enabled: boolean;
37
+ name?: string;
38
+ botOpenId?: string;
39
+ allowFrom: string[];
40
+ groupPolicy: "open" | "allowlist" | "disabled";
41
+ requireMention: boolean;
42
+ };
43
+ type FeishuChannelAdapterContract = {
44
+ configure: (config: FeishuChannelConfig) => Promise<void>;
45
+ start: () => Promise<void>;
46
+ stop: () => Promise<void>;
47
+ onMessage: (handler: (message: FeishuInboundMessage) => void | Promise<void>) => () => void;
48
+ sendNcpEvent: (event: NcpEndpointEvent) => Promise<void>;
49
+ };
50
+ //#endregion
51
+ export { FeishuAccountConfig, FeishuChannelAdapterContract, FeishuChannelConfig, FeishuDomain, FeishuInboundMessage, FeishuRuntimeAccount };
@@ -0,0 +1,79 @@
1
+ //#region src/utils/feishu-config.utils.ts
2
+ const FEISHU_CHANNEL_ID = "feishu";
3
+ const DEFAULT_FEISHU_DOMAIN = "feishu";
4
+ function toRecord(value) {
5
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
6
+ return value;
7
+ }
8
+ function readString(value) {
9
+ if (typeof value !== "string") return;
10
+ return value.trim() || void 0;
11
+ }
12
+ function readStringArray(value) {
13
+ if (!Array.isArray(value)) return;
14
+ const values = value.map((entry) => readString(entry)).filter((entry) => Boolean(entry));
15
+ return values.length > 0 ? values : void 0;
16
+ }
17
+ function readDomain(value) {
18
+ return value === "lark" || value === "feishu" ? value : void 0;
19
+ }
20
+ function readGroupPolicy(value) {
21
+ return value === "open" || value === "allowlist" || value === "disabled" ? value : void 0;
22
+ }
23
+ function normalizeAccountConfig(value) {
24
+ const record = toRecord(value);
25
+ if (!record) return;
26
+ return {
27
+ enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
28
+ name: readString(record.name),
29
+ domain: readDomain(record.domain),
30
+ allowFrom: readStringArray(record.allowFrom),
31
+ groupPolicy: readGroupPolicy(record.groupPolicy),
32
+ requireMention: typeof record.requireMention === "boolean" ? record.requireMention : void 0
33
+ };
34
+ }
35
+ function normalizeFeishuChannelConfig(value) {
36
+ const record = toRecord(value);
37
+ if (!record) return {};
38
+ const accounts = {};
39
+ for (const [accountId, rawAccountConfig] of Object.entries(toRecord(record.accounts) ?? {})) {
40
+ const normalized = normalizeAccountConfig(rawAccountConfig);
41
+ if (normalized) accounts[accountId] = normalized;
42
+ }
43
+ return {
44
+ enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
45
+ defaultAccountId: readString(record.defaultAccountId),
46
+ domain: readDomain(record.domain),
47
+ allowFrom: readStringArray(record.allowFrom),
48
+ groupPolicy: readGroupPolicy(record.groupPolicy),
49
+ requireMention: typeof record.requireMention === "boolean" ? record.requireMention : void 0,
50
+ accounts: Object.keys(accounts).length > 0 ? accounts : void 0
51
+ };
52
+ }
53
+ function buildRegisteredFeishuChannelConfig({ accountId, allowOpenId, botName, config, domain }) {
54
+ const current = normalizeFeishuChannelConfig(config);
55
+ const currentAccount = current.accounts?.[accountId] ?? {};
56
+ const allowFrom = new Set([
57
+ ...current.allowFrom ?? [],
58
+ ...currentAccount.allowFrom ?? [],
59
+ ...allowOpenId ? [allowOpenId] : []
60
+ ]);
61
+ return {
62
+ ...current,
63
+ enabled: true,
64
+ defaultAccountId: accountId,
65
+ domain: current.domain ?? domain,
66
+ accounts: {
67
+ ...current.accounts ?? {},
68
+ [accountId]: {
69
+ ...currentAccount,
70
+ enabled: true,
71
+ domain,
72
+ name: currentAccount.name ?? botName,
73
+ allowFrom: allowFrom.size > 0 ? [...allowFrom] : void 0
74
+ }
75
+ }
76
+ };
77
+ }
78
+ //#endregion
79
+ export { DEFAULT_FEISHU_DOMAIN, FEISHU_CHANNEL_ID, buildRegisteredFeishuChannelConfig, normalizeFeishuChannelConfig };
@@ -0,0 +1,44 @@
1
+ //#region src/utils/feishu-message.utils.ts
2
+ function readRecord(value) {
3
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
4
+ return value;
5
+ }
6
+ function readString(value) {
7
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
8
+ }
9
+ function parseTextContent(raw) {
10
+ if (!raw) return "";
11
+ try {
12
+ return readString(JSON.parse(raw).text) ?? "";
13
+ } catch {
14
+ return raw;
15
+ }
16
+ }
17
+ function readMentionOpenIds(value) {
18
+ if (!Array.isArray(value)) return [];
19
+ return value.map((entry) => readString(readRecord(readRecord(entry)?.id)?.open_id)).filter((entry) => Boolean(entry));
20
+ }
21
+ function parseFeishuInboundMessage(value) {
22
+ const event = value;
23
+ const chatId = readString(event.message?.chat_id);
24
+ const senderOpenId = readString(event.sender?.sender_id?.open_id);
25
+ if (!chatId || !senderOpenId || event.sender?.sender_type === "app") return null;
26
+ const text = parseTextContent(event.message?.content);
27
+ if (!text.trim()) return null;
28
+ return {
29
+ chatId,
30
+ senderOpenId,
31
+ text,
32
+ messageId: readString(event.message?.message_id),
33
+ chatType: readString(event.message?.chat_type) ?? "p2p",
34
+ mentionedOpenIds: readMentionOpenIds(event.message?.mentions)
35
+ };
36
+ }
37
+ function isFeishuGroupChat(chatType) {
38
+ return chatType !== "p2p";
39
+ }
40
+ function isFeishuBotMentioned(params) {
41
+ return Boolean(params.botOpenId && params.mentionedOpenIds.includes(params.botOpenId));
42
+ }
43
+ //#endregion
44
+ export { isFeishuBotMentioned, isFeishuGroupChat, parseFeishuInboundMessage };