@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NextClaw contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @nextclaw/channel-extension-feishu
|
|
2
|
+
|
|
3
|
+
Lightweight Feishu/Lark channel extension for NextClaw.
|
|
4
|
+
|
|
5
|
+
This extension uses the new NextClaw extension process model and supports QR
|
|
6
|
+
scan-to-create onboarding inspired by Hermes Agent:
|
|
7
|
+
|
|
8
|
+
1. Start channel auth from the UI or service API.
|
|
9
|
+
2. Scan the Feishu/Lark QR code.
|
|
10
|
+
3. The platform creates a bot application and returns app credentials.
|
|
11
|
+
4. Credentials are stored under `NEXTCLAW_HOME/channels/feishu`.
|
|
12
|
+
5. The extension connects through the Lark WebSocket client.
|
|
13
|
+
|
|
14
|
+
This extension replaces the removed legacy Feishu channel plugin. It contributes
|
|
15
|
+
the built-in `feishu` channel id through the extension mechanism, so QR
|
|
16
|
+
onboarding, WebSocket inbound messages, NCP replies, and Feishu processing
|
|
17
|
+
reactions now live in one lightweight channel owner.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { FeishuAccountConfig, FeishuChannelConfig, FeishuDomain, FeishuInboundMessage, FeishuRuntimeAccount } from "./types/feishu-extension.types.js";
|
|
2
|
+
import { FeishuRegistrationService } from "./services/feishu-registration.service.js";
|
|
3
|
+
import { FeishuAuthCapability } from "./services/feishu-auth-capability.service.js";
|
|
4
|
+
import { FeishuChannelAdapter } from "./services/feishu-channel-adapter.service.js";
|
|
5
|
+
import { FeishuExtensionRuntime } from "./services/feishu-extension-runtime.service.js";
|
|
6
|
+
export { type FeishuAccountConfig, FeishuAuthCapability, FeishuChannelAdapter, type FeishuChannelConfig, type FeishuDomain, FeishuExtensionRuntime, type FeishuInboundMessage, FeishuRegistrationService, type FeishuRuntimeAccount };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { FeishuRegistrationService } from "./services/feishu-registration.service.js";
|
|
2
|
+
import { FeishuAuthCapability } from "./services/feishu-auth-capability.service.js";
|
|
3
|
+
import { FeishuChannelAdapter } from "./services/feishu-channel-adapter.service.js";
|
|
4
|
+
import { FeishuExtensionRuntime } from "./services/feishu-extension-runtime.service.js";
|
|
5
|
+
export { FeishuAuthCapability, FeishuChannelAdapter, FeishuExtensionRuntime, FeishuRegistrationService };
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FeishuAuthCapability } from "./services/feishu-auth-capability.service.js";
|
|
2
|
+
import { FeishuChannelAdapter } from "./services/feishu-channel-adapter.service.js";
|
|
3
|
+
import { FeishuExtensionRuntime } from "./services/feishu-extension-runtime.service.js";
|
|
4
|
+
import { NextClawExtension } from "@nextclaw/extension-sdk";
|
|
5
|
+
//#region src/main.ts
|
|
6
|
+
const extension = new NextClawExtension();
|
|
7
|
+
const feishu = extension.channels.use("feishu");
|
|
8
|
+
const runtime = new FeishuExtensionRuntime(feishu, new FeishuChannelAdapter());
|
|
9
|
+
extension.capabilities.provide("channel.auth", new FeishuAuthCapability({ channel: feishu }));
|
|
10
|
+
await runtime.start();
|
|
11
|
+
//#endregion
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FeishuRegistrationService } from "./feishu-registration.service.js";
|
|
2
|
+
import { ExtensionCapabilityPayload, ExtensionChannel } from "@nextclaw/extension-sdk";
|
|
3
|
+
|
|
4
|
+
//#region src/services/feishu-auth-capability.service.d.ts
|
|
5
|
+
type FeishuAuthCapabilityDeps = {
|
|
6
|
+
channel: ExtensionChannel;
|
|
7
|
+
registrationService?: Pick<FeishuRegistrationService, "start" | "poll">;
|
|
8
|
+
};
|
|
9
|
+
declare class FeishuAuthCapability {
|
|
10
|
+
private readonly channel;
|
|
11
|
+
private readonly registrationService;
|
|
12
|
+
constructor(deps: FeishuAuthCapabilityDeps);
|
|
13
|
+
readonly start: (request: ExtensionCapabilityPayload) => Promise<unknown>;
|
|
14
|
+
readonly poll: (request: ExtensionCapabilityPayload) => Promise<unknown>;
|
|
15
|
+
private readonly readCurrentConfig;
|
|
16
|
+
private readonly readString;
|
|
17
|
+
private readonly readDomain;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { FeishuAuthCapability };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FeishuRegistrationService } from "./feishu-registration.service.js";
|
|
2
|
+
//#region src/services/feishu-auth-capability.service.ts
|
|
3
|
+
var FeishuAuthCapability = class {
|
|
4
|
+
channel;
|
|
5
|
+
registrationService;
|
|
6
|
+
constructor(deps) {
|
|
7
|
+
this.channel = deps.channel;
|
|
8
|
+
this.registrationService = deps.registrationService ?? new FeishuRegistrationService();
|
|
9
|
+
}
|
|
10
|
+
start = async (request) => await this.registrationService.start({
|
|
11
|
+
pluginConfig: await this.readCurrentConfig(),
|
|
12
|
+
requestedAccountId: this.readString(request.accountId),
|
|
13
|
+
domain: this.readDomain(request.domain),
|
|
14
|
+
verbose: request.verbose === true
|
|
15
|
+
});
|
|
16
|
+
poll = async (request) => {
|
|
17
|
+
const sessionId = this.readString(request.sessionId);
|
|
18
|
+
if (!sessionId) throw new Error("sessionId is required");
|
|
19
|
+
return await this.registrationService.poll({ sessionId });
|
|
20
|
+
};
|
|
21
|
+
readCurrentConfig = async () => {
|
|
22
|
+
const config = await this.channel.config.get();
|
|
23
|
+
return config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
|
24
|
+
};
|
|
25
|
+
readString = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
26
|
+
readDomain = (value) => {
|
|
27
|
+
const text = this.readString(value);
|
|
28
|
+
return text === "feishu" || text === "lark" ? text : null;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { FeishuAuthCapability };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { FeishuChannelAdapterContract, FeishuChannelConfig, FeishuInboundMessage } from "../types/feishu-extension.types.js";
|
|
2
|
+
import { FeishuAccountStore } from "../stores/feishu-account.store.js";
|
|
3
|
+
import { FeishuSdkService } from "./feishu-sdk.service.js";
|
|
4
|
+
import { NcpEndpointEvent } from "@nextclaw/ncp";
|
|
5
|
+
|
|
6
|
+
//#region src/services/feishu-channel-adapter.service.d.ts
|
|
7
|
+
type FeishuAdapterDeps = {
|
|
8
|
+
sdk?: FeishuSdkService;
|
|
9
|
+
store?: FeishuAccountStore;
|
|
10
|
+
logger?: Pick<typeof console, "warn" | "log">;
|
|
11
|
+
};
|
|
12
|
+
declare class FeishuChannelAdapter implements FeishuChannelAdapterContract {
|
|
13
|
+
private messageHandler;
|
|
14
|
+
private readonly sdk;
|
|
15
|
+
private readonly store;
|
|
16
|
+
private readonly logger;
|
|
17
|
+
private readonly replyConsumer;
|
|
18
|
+
private readonly wsClients;
|
|
19
|
+
private readonly replySessions;
|
|
20
|
+
private readonly canonicalRoutes;
|
|
21
|
+
private readonly processingReactions;
|
|
22
|
+
private running;
|
|
23
|
+
private config;
|
|
24
|
+
constructor(deps?: FeishuAdapterDeps);
|
|
25
|
+
readonly configure: (config: FeishuChannelConfig) => Promise<void>;
|
|
26
|
+
readonly start: () => Promise<void>;
|
|
27
|
+
readonly stop: () => Promise<void>;
|
|
28
|
+
readonly onMessage: (handler: (message: FeishuInboundMessage) => void | Promise<void>) => (() => void);
|
|
29
|
+
readonly sendNcpEvent: (event: NcpEndpointEvent) => Promise<void>;
|
|
30
|
+
readonly emitMessageForTest: (message: FeishuInboundMessage) => Promise<void>;
|
|
31
|
+
private readonly startAccount;
|
|
32
|
+
private readonly listAvailableAccountIds;
|
|
33
|
+
private readonly resolveRuntimeAccount;
|
|
34
|
+
private readonly resolveSelectedAccountId;
|
|
35
|
+
private readonly resolveSendAccount;
|
|
36
|
+
private readonly resolveReplySession;
|
|
37
|
+
private readonly resolveCanonicalRoute;
|
|
38
|
+
private readonly handleInboundEvent;
|
|
39
|
+
private readonly startProcessingReaction;
|
|
40
|
+
private readonly finalizeProcessingReaction;
|
|
41
|
+
private readonly deleteProcessingReaction;
|
|
42
|
+
private readonly addFailedReaction;
|
|
43
|
+
private readonly sendText;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { FeishuChannelAdapter };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { FileFeishuAccountStore } from "../stores/feishu-account.store.js";
|
|
2
|
+
import { FeishuReplyChat, NcpEventQueue, TERMINAL_NCP_EVENT_TYPES } from "./feishu-reply-chat.service.js";
|
|
3
|
+
import { FeishuSdkService } from "./feishu-sdk.service.js";
|
|
4
|
+
import { isFeishuBotMentioned, isFeishuGroupChat, parseFeishuInboundMessage } from "../utils/feishu-message.utils.js";
|
|
5
|
+
import { readFeishuEventSessionId, resolveFeishuSessionRoute } from "../utils/feishu-session-route.utils.js";
|
|
6
|
+
import { NcpEventType } from "@nextclaw/ncp";
|
|
7
|
+
import { NcpReplyConsumer } from "@nextclaw/ncp-toolkit";
|
|
8
|
+
//#region src/services/feishu-channel-adapter.service.ts
|
|
9
|
+
const FEISHU_PROCESSING_REACTION = "Typing";
|
|
10
|
+
const FEISHU_FAILED_REACTION = "CrossMark";
|
|
11
|
+
const FAILED_NCP_EVENT_TYPES = new Set([NcpEventType.MessageFailed, NcpEventType.RunError]);
|
|
12
|
+
function readStringArray(value) {
|
|
13
|
+
if (!Array.isArray(value)) return [];
|
|
14
|
+
return value.map((entry) => typeof entry === "string" ? entry.trim() : "").filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
function normalizeRouteKey(conversationId) {
|
|
17
|
+
return conversationId.toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
function isAllowedSender(allowFrom, senderId) {
|
|
20
|
+
return allowFrom.length === 0 || allowFrom.includes(senderId);
|
|
21
|
+
}
|
|
22
|
+
var FeishuChannelAdapter = class {
|
|
23
|
+
messageHandler = null;
|
|
24
|
+
sdk;
|
|
25
|
+
store;
|
|
26
|
+
logger;
|
|
27
|
+
replyConsumer;
|
|
28
|
+
wsClients = /* @__PURE__ */ new Map();
|
|
29
|
+
replySessions = /* @__PURE__ */ new Map();
|
|
30
|
+
canonicalRoutes = /* @__PURE__ */ new Map();
|
|
31
|
+
processingReactions = /* @__PURE__ */ new Map();
|
|
32
|
+
running = false;
|
|
33
|
+
config = {};
|
|
34
|
+
constructor(deps = {}) {
|
|
35
|
+
this.sdk = deps.sdk ?? new FeishuSdkService();
|
|
36
|
+
this.store = deps.store ?? new FileFeishuAccountStore();
|
|
37
|
+
this.logger = deps.logger ?? console;
|
|
38
|
+
this.replyConsumer = new NcpReplyConsumer(new FeishuReplyChat({
|
|
39
|
+
resolveAccount: this.resolveSendAccount,
|
|
40
|
+
sendText: this.sendText
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
configure = async (config) => {
|
|
44
|
+
this.config = config;
|
|
45
|
+
if (!this.running) return;
|
|
46
|
+
await this.stop();
|
|
47
|
+
await this.start();
|
|
48
|
+
};
|
|
49
|
+
start = async () => {
|
|
50
|
+
if (this.running) return;
|
|
51
|
+
this.running = true;
|
|
52
|
+
for (const accountId of this.listAvailableAccountIds()) {
|
|
53
|
+
const account = this.resolveRuntimeAccount(accountId);
|
|
54
|
+
if (account?.enabled) this.startAccount(account);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
stop = async () => {
|
|
58
|
+
if (!this.running) return;
|
|
59
|
+
this.running = false;
|
|
60
|
+
for (const client of this.wsClients.values()) {
|
|
61
|
+
client.close?.();
|
|
62
|
+
client.stop?.();
|
|
63
|
+
}
|
|
64
|
+
this.wsClients.clear();
|
|
65
|
+
for (const session of this.replySessions.values()) session.queue.close();
|
|
66
|
+
this.replySessions.clear();
|
|
67
|
+
this.processingReactions.clear();
|
|
68
|
+
};
|
|
69
|
+
onMessage = (handler) => {
|
|
70
|
+
this.messageHandler = handler;
|
|
71
|
+
return () => {
|
|
72
|
+
if (this.messageHandler === handler) this.messageHandler = null;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
sendNcpEvent = async (event) => {
|
|
76
|
+
if (!this.running) return;
|
|
77
|
+
const route = resolveFeishuSessionRoute(event);
|
|
78
|
+
if (!route) return;
|
|
79
|
+
const sessionId = readFeishuEventSessionId(event);
|
|
80
|
+
if (!sessionId) return;
|
|
81
|
+
const canonicalRoute = this.resolveCanonicalRoute(route);
|
|
82
|
+
const session = this.resolveReplySession(sessionId, canonicalRoute);
|
|
83
|
+
session.queue.push(event);
|
|
84
|
+
if (TERMINAL_NCP_EVENT_TYPES.has(event.type)) {
|
|
85
|
+
session.queue.close();
|
|
86
|
+
this.replySessions.delete(sessionId);
|
|
87
|
+
await session.consuming;
|
|
88
|
+
await this.finalizeProcessingReaction(canonicalRoute, FAILED_NCP_EVENT_TYPES.has(event.type));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
emitMessageForTest = async (message) => {
|
|
92
|
+
await this.messageHandler?.(message);
|
|
93
|
+
};
|
|
94
|
+
startAccount = (account) => {
|
|
95
|
+
const dispatcher = this.sdk.createEventDispatcher();
|
|
96
|
+
dispatcher.register({ "im.message.receive_v1": async (data) => {
|
|
97
|
+
await this.handleInboundEvent(account, data);
|
|
98
|
+
} });
|
|
99
|
+
const wsClient = this.sdk.createWsClient(account);
|
|
100
|
+
wsClient.start({ eventDispatcher: dispatcher });
|
|
101
|
+
this.wsClients.set(account.accountId, wsClient);
|
|
102
|
+
this.logger.log?.(`[feishu] websocket started for ${account.accountId}`);
|
|
103
|
+
};
|
|
104
|
+
listAvailableAccountIds = () => {
|
|
105
|
+
return Array.from(new Set([
|
|
106
|
+
...this.config.defaultAccountId ? [this.config.defaultAccountId] : [],
|
|
107
|
+
...Object.keys(this.config.accounts ?? {}),
|
|
108
|
+
...this.store.listAccountIds()
|
|
109
|
+
]));
|
|
110
|
+
};
|
|
111
|
+
resolveRuntimeAccount = (accountId) => {
|
|
112
|
+
const stored = this.store.loadAccount(accountId);
|
|
113
|
+
if (!stored?.appId || !stored.appSecret) return null;
|
|
114
|
+
const accountConfig = this.config.accounts?.[accountId] ?? {};
|
|
115
|
+
return {
|
|
116
|
+
accountId,
|
|
117
|
+
appId: stored.appId,
|
|
118
|
+
appSecret: stored.appSecret,
|
|
119
|
+
domain: accountConfig.domain ?? stored.domain ?? this.config.domain ?? "feishu",
|
|
120
|
+
enabled: accountConfig.enabled !== false && this.config.enabled !== false,
|
|
121
|
+
name: accountConfig.name ?? stored.botName,
|
|
122
|
+
botOpenId: stored.botOpenId,
|
|
123
|
+
allowFrom: Array.from(new Set([...readStringArray(this.config.allowFrom), ...readStringArray(accountConfig.allowFrom)])),
|
|
124
|
+
groupPolicy: accountConfig.groupPolicy ?? this.config.groupPolicy ?? "open",
|
|
125
|
+
requireMention: accountConfig.requireMention ?? this.config.requireMention ?? true
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
resolveSelectedAccountId = (requestedAccountId) => {
|
|
129
|
+
if (requestedAccountId) return requestedAccountId;
|
|
130
|
+
if (this.config.defaultAccountId) return this.config.defaultAccountId;
|
|
131
|
+
const accountIds = this.listAvailableAccountIds();
|
|
132
|
+
if (accountIds.length === 1 && accountIds[0]) return accountIds[0];
|
|
133
|
+
throw new Error("feishu send failed: accountId is required when multiple accounts are configured");
|
|
134
|
+
};
|
|
135
|
+
resolveSendAccount = (target) => {
|
|
136
|
+
const account = this.resolveRuntimeAccount(this.resolveSelectedAccountId(target.accountId));
|
|
137
|
+
if (!account?.enabled) throw new Error(`feishu send failed: account "${target.accountId ?? this.config.defaultAccountId ?? ""}" is not connected`);
|
|
138
|
+
return account;
|
|
139
|
+
};
|
|
140
|
+
resolveReplySession = (sessionId, route) => {
|
|
141
|
+
const existing = this.replySessions.get(sessionId);
|
|
142
|
+
if (existing) return existing;
|
|
143
|
+
const queue = new NcpEventQueue();
|
|
144
|
+
const session = {
|
|
145
|
+
queue,
|
|
146
|
+
consuming: this.replyConsumer.consume({
|
|
147
|
+
target: {
|
|
148
|
+
conversationId: route.conversationId,
|
|
149
|
+
...route.accountId ? { accountId: route.accountId } : {}
|
|
150
|
+
},
|
|
151
|
+
eventStream: queue
|
|
152
|
+
})
|
|
153
|
+
};
|
|
154
|
+
this.replySessions.set(sessionId, session);
|
|
155
|
+
return session;
|
|
156
|
+
};
|
|
157
|
+
resolveCanonicalRoute = (route) => {
|
|
158
|
+
const remembered = this.canonicalRoutes.get(normalizeRouteKey(route.conversationId));
|
|
159
|
+
if (remembered) return {
|
|
160
|
+
...remembered,
|
|
161
|
+
...route.accountId ? { accountId: route.accountId } : {}
|
|
162
|
+
};
|
|
163
|
+
return route;
|
|
164
|
+
};
|
|
165
|
+
handleInboundEvent = async (account, rawEvent) => {
|
|
166
|
+
const parsed = parseFeishuInboundMessage(rawEvent);
|
|
167
|
+
if (!parsed || !isAllowedSender(account.allowFrom, parsed.senderOpenId)) return;
|
|
168
|
+
if (isFeishuGroupChat(parsed.chatType)) {
|
|
169
|
+
if (account.groupPolicy === "disabled") return;
|
|
170
|
+
if (account.groupPolicy === "allowlist" && !account.allowFrom.includes(parsed.chatId)) return;
|
|
171
|
+
if (account.requireMention && !isFeishuBotMentioned({
|
|
172
|
+
botOpenId: account.botOpenId,
|
|
173
|
+
mentionedOpenIds: parsed.mentionedOpenIds
|
|
174
|
+
})) return;
|
|
175
|
+
}
|
|
176
|
+
this.canonicalRoutes.set(normalizeRouteKey(parsed.chatId), {
|
|
177
|
+
accountId: account.accountId,
|
|
178
|
+
conversationId: parsed.chatId
|
|
179
|
+
});
|
|
180
|
+
await this.startProcessingReaction(account, parsed.messageId, parsed.chatId);
|
|
181
|
+
await this.messageHandler?.({
|
|
182
|
+
conversationId: parsed.chatId,
|
|
183
|
+
senderId: parsed.senderOpenId,
|
|
184
|
+
text: parsed.text,
|
|
185
|
+
accountId: account.accountId,
|
|
186
|
+
peerKind: isFeishuGroupChat(parsed.chatType) ? "group" : "direct",
|
|
187
|
+
messageId: parsed.messageId,
|
|
188
|
+
raw: rawEvent
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
startProcessingReaction = async (account, messageId, conversationId) => {
|
|
192
|
+
if (!messageId) return;
|
|
193
|
+
const routeKey = normalizeRouteKey(conversationId);
|
|
194
|
+
try {
|
|
195
|
+
const reactionId = await this.sdk.addReaction({
|
|
196
|
+
account,
|
|
197
|
+
messageId,
|
|
198
|
+
emojiType: FEISHU_PROCESSING_REACTION
|
|
199
|
+
});
|
|
200
|
+
this.processingReactions.set(routeKey, {
|
|
201
|
+
accountId: account.accountId,
|
|
202
|
+
messageId,
|
|
203
|
+
reactionId
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
this.logger.warn?.(`[feishu] failed to add processing reaction for ${messageId}: ${String(error)}`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
finalizeProcessingReaction = async (route, failed) => {
|
|
210
|
+
const routeKey = normalizeRouteKey(route.conversationId);
|
|
211
|
+
const state = this.processingReactions.get(routeKey);
|
|
212
|
+
if (!state) return;
|
|
213
|
+
this.processingReactions.delete(routeKey);
|
|
214
|
+
const account = this.resolveRuntimeAccount(state.accountId);
|
|
215
|
+
if (!account?.enabled) return;
|
|
216
|
+
await this.deleteProcessingReaction(account, state);
|
|
217
|
+
if (failed) await this.addFailedReaction(account, state.messageId);
|
|
218
|
+
};
|
|
219
|
+
deleteProcessingReaction = async (account, state) => {
|
|
220
|
+
if (!state.reactionId) return;
|
|
221
|
+
try {
|
|
222
|
+
await this.sdk.deleteReaction({
|
|
223
|
+
account,
|
|
224
|
+
messageId: state.messageId,
|
|
225
|
+
reactionId: state.reactionId
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.logger.warn?.(`[feishu] failed to remove processing reaction for ${state.messageId}: ${String(error)}`);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
addFailedReaction = async (account, messageId) => {
|
|
232
|
+
try {
|
|
233
|
+
await this.sdk.addReaction({
|
|
234
|
+
account,
|
|
235
|
+
messageId,
|
|
236
|
+
emojiType: FEISHU_FAILED_REACTION
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
this.logger.warn?.(`[feishu] failed to add failure reaction for ${messageId}: ${String(error)}`);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
sendText = async (params) => {
|
|
243
|
+
await this.sdk.sendText({
|
|
244
|
+
account: params.account,
|
|
245
|
+
chatId: params.conversationId,
|
|
246
|
+
text: params.text
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
//#endregion
|
|
251
|
+
export { FeishuChannelAdapter };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FeishuChannelAdapterContract } from "../types/feishu-extension.types.js";
|
|
2
|
+
import { ExtensionChannel } from "@nextclaw/extension-sdk";
|
|
3
|
+
|
|
4
|
+
//#region src/services/feishu-extension-runtime.service.d.ts
|
|
5
|
+
declare class FeishuExtensionRuntime {
|
|
6
|
+
private readonly channel;
|
|
7
|
+
private readonly adapter;
|
|
8
|
+
private unsubscribeConfig;
|
|
9
|
+
private unsubscribeMessages;
|
|
10
|
+
private unsubscribeNcpEvents;
|
|
11
|
+
constructor(channel: ExtensionChannel, adapter: FeishuChannelAdapterContract);
|
|
12
|
+
readonly start: () => Promise<void>;
|
|
13
|
+
readonly stop: () => Promise<void>;
|
|
14
|
+
private readonly applyConfig;
|
|
15
|
+
private readonly submitMessage;
|
|
16
|
+
private readonly sendNcpEvent;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { FeishuExtensionRuntime };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//#region src/services/feishu-extension-runtime.service.ts
|
|
2
|
+
var FeishuExtensionRuntime = class {
|
|
3
|
+
unsubscribeConfig = null;
|
|
4
|
+
unsubscribeMessages = null;
|
|
5
|
+
unsubscribeNcpEvents = null;
|
|
6
|
+
constructor(channel, adapter) {
|
|
7
|
+
this.channel = channel;
|
|
8
|
+
this.adapter = adapter;
|
|
9
|
+
}
|
|
10
|
+
start = async () => {
|
|
11
|
+
this.unsubscribeMessages = this.adapter.onMessage(this.submitMessage);
|
|
12
|
+
this.unsubscribeNcpEvents = this.channel.onNcpEvent(this.sendNcpEvent);
|
|
13
|
+
this.unsubscribeConfig = this.channel.config.onChange(async () => {
|
|
14
|
+
await this.applyConfig();
|
|
15
|
+
});
|
|
16
|
+
await this.applyConfig();
|
|
17
|
+
};
|
|
18
|
+
stop = async () => {
|
|
19
|
+
this.unsubscribeConfig?.();
|
|
20
|
+
this.unsubscribeMessages?.();
|
|
21
|
+
this.unsubscribeNcpEvents?.();
|
|
22
|
+
this.unsubscribeConfig = null;
|
|
23
|
+
this.unsubscribeMessages = null;
|
|
24
|
+
this.unsubscribeNcpEvents = null;
|
|
25
|
+
await this.adapter.stop();
|
|
26
|
+
};
|
|
27
|
+
applyConfig = async () => {
|
|
28
|
+
const config = await this.channel.config.get();
|
|
29
|
+
await this.adapter.configure(config);
|
|
30
|
+
if (config.enabled === false) {
|
|
31
|
+
await this.adapter.stop();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await this.adapter.start();
|
|
35
|
+
};
|
|
36
|
+
submitMessage = async (message) => {
|
|
37
|
+
await this.channel.submitMessage({
|
|
38
|
+
conversationId: message.conversationId,
|
|
39
|
+
senderId: message.senderId,
|
|
40
|
+
content: {
|
|
41
|
+
type: "text",
|
|
42
|
+
text: message.text
|
|
43
|
+
},
|
|
44
|
+
metadata: {
|
|
45
|
+
accountId: message.accountId,
|
|
46
|
+
account_id: message.accountId,
|
|
47
|
+
peerId: message.conversationId,
|
|
48
|
+
peer_id: message.conversationId,
|
|
49
|
+
peerKind: message.peerKind,
|
|
50
|
+
peer_kind: message.peerKind,
|
|
51
|
+
...message.messageId ? { message_id: message.messageId } : {},
|
|
52
|
+
...message.raw === void 0 ? {} : { raw: message.raw }
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
sendNcpEvent = async (event) => {
|
|
57
|
+
try {
|
|
58
|
+
await this.adapter.sendNcpEvent(event);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
61
|
+
console.warn(`[feishu] failed to send NCP event: ${message}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
export { FeishuExtensionRuntime };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { FeishuDomain } from "../types/feishu-extension.types.js";
|
|
2
|
+
import { FeishuAccountStore } from "../stores/feishu-account.store.js";
|
|
3
|
+
|
|
4
|
+
//#region src/services/feishu-registration.service.d.ts
|
|
5
|
+
type FeishuRegistrationStartParams = {
|
|
6
|
+
pluginConfig?: Record<string, unknown>;
|
|
7
|
+
requestedAccountId?: string | null;
|
|
8
|
+
domain?: FeishuDomain | null;
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type FeishuAuthStartResult = {
|
|
12
|
+
channel: string;
|
|
13
|
+
kind: "qr_code";
|
|
14
|
+
sessionId: string;
|
|
15
|
+
qrCode: string;
|
|
16
|
+
qrCodeUrl: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
intervalMs: number;
|
|
19
|
+
note?: string;
|
|
20
|
+
};
|
|
21
|
+
type FeishuAuthPollResult = {
|
|
22
|
+
channel: string;
|
|
23
|
+
status: "pending" | "scanned" | "authorized" | "expired" | "error";
|
|
24
|
+
message?: string;
|
|
25
|
+
nextPollMs?: number;
|
|
26
|
+
accountId?: string | null;
|
|
27
|
+
notes?: string[];
|
|
28
|
+
pluginConfig?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
type FeishuRegistrationServiceDeps = {
|
|
31
|
+
store?: FeishuAccountStore;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
};
|
|
34
|
+
declare class FeishuRegistrationService {
|
|
35
|
+
private readonly store;
|
|
36
|
+
private readonly fetchImpl;
|
|
37
|
+
private readonly sessions;
|
|
38
|
+
constructor(deps?: FeishuRegistrationServiceDeps);
|
|
39
|
+
readonly start: (params: FeishuRegistrationStartParams) => Promise<FeishuAuthStartResult>;
|
|
40
|
+
readonly poll: ({
|
|
41
|
+
sessionId
|
|
42
|
+
}: {
|
|
43
|
+
sessionId: string;
|
|
44
|
+
}) => Promise<FeishuAuthPollResult | null>;
|
|
45
|
+
private readonly assertRegistrationSupported;
|
|
46
|
+
private readonly beginRegistration;
|
|
47
|
+
private readonly postRegistration;
|
|
48
|
+
private readonly confirmRegistration;
|
|
49
|
+
private readonly probeBot;
|
|
50
|
+
private readonly readRecord;
|
|
51
|
+
private readonly cleanupExpiredSessions;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { FeishuRegistrationService };
|