@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +12 -0
- package/dist/services/feishu-auth-capability.service.d.ts +20 -0
- package/dist/services/feishu-auth-capability.service.js +32 -0
- package/dist/services/feishu-channel-adapter.service.d.ts +46 -0
- package/dist/services/feishu-channel-adapter.service.js +251 -0
- package/dist/services/feishu-extension-runtime.service.d.ts +19 -0
- package/dist/services/feishu-extension-runtime.service.js +66 -0
- package/dist/services/feishu-registration.service.d.ts +54 -0
- package/dist/services/feishu-registration.service.js +214 -0
- package/dist/services/feishu-reply-chat.service.js +92 -0
- package/dist/services/feishu-sdk.service.d.ts +75 -0
- package/dist/services/feishu-sdk.service.js +50 -0
- package/dist/stores/feishu-account.store.d.ts +21 -0
- package/dist/stores/feishu-account.store.js +40 -0
- package/dist/types/feishu-extension.types.d.ts +51 -0
- package/dist/utils/feishu-config.utils.js +79 -0
- package/dist/utils/feishu-message.utils.js +44 -0
- package/dist/utils/feishu-session-route.utils.js +38 -0
- package/nextclaw.extension.json +69 -0
- package/package.json +37 -0
|
@@ -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 };
|