@xmanrui/dsh-im 0.1.0
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 +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- package/src/channels/weixin/weixin-runtime.mjs +204 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
function required(name, value) {
|
|
5
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
6
|
+
throw new Error(`Missing required configuration: ${name}`);
|
|
7
|
+
}
|
|
8
|
+
return value.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readSecret(appId) {
|
|
12
|
+
if (process.env.FEISHU_APP_SECRET?.trim()) return process.env.FEISHU_APP_SECRET.trim();
|
|
13
|
+
|
|
14
|
+
const service = process.env.FEISHU_SECRET_SERVICE?.trim();
|
|
15
|
+
if (!service) throw new Error('Missing FEISHU_APP_SECRET or FEISHU_SECRET_SERVICE');
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync('/usr/bin/security', [
|
|
19
|
+
'find-generic-password',
|
|
20
|
+
'-a',
|
|
21
|
+
appId,
|
|
22
|
+
'-s',
|
|
23
|
+
service,
|
|
24
|
+
'-w',
|
|
25
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`Unable to read Feishu app secret from macOS Keychain service ${service}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bool(name, fallback) {
|
|
32
|
+
const raw = process.env[name];
|
|
33
|
+
if (raw === undefined) return fallback;
|
|
34
|
+
return !['0', 'false', 'no', 'off'].includes(raw.trim().toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function csvSet(value) {
|
|
38
|
+
return new Set((value ?? '')
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((item) => item.trim())
|
|
41
|
+
.filter(Boolean));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requiredCsvSet(name, value) {
|
|
45
|
+
const items = csvSet(value);
|
|
46
|
+
if (items.size === 0) throw new Error(`Missing required configuration: ${name}`);
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadConfig() {
|
|
51
|
+
const appId = required('FEISHU_APP_ID', process.env.FEISHU_APP_ID);
|
|
52
|
+
const appSecret = required('FEISHU_APP_SECRET', readSecret(appId));
|
|
53
|
+
const workspace = required('HARNESS_WORKSPACE', process.env.HARNESS_WORKSPACE);
|
|
54
|
+
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
appId,
|
|
57
|
+
appSecret,
|
|
58
|
+
harnessBaseUrl: new URL(process.env.HARNESS_BASE_URL ?? 'http://127.0.0.1:3080'),
|
|
59
|
+
harnessWorkspace: resolve(workspace),
|
|
60
|
+
harnessAgentPreset: process.env.HARNESS_AGENT_PRESET?.trim() || 'standard',
|
|
61
|
+
harnessAutostart: bool('HARNESS_AUTOSTART', true),
|
|
62
|
+
dshBin: process.env.DSH_BIN?.trim() || 'dsh',
|
|
63
|
+
healthPort: Number.parseInt(process.env.BRIDGE_HEALTH_PORT ?? '3091', 10),
|
|
64
|
+
statePath: resolve(process.env.BRIDGE_STATE_PATH ?? './data/state.json'),
|
|
65
|
+
replyTimeoutMs: Number.parseInt(process.env.HARNESS_REPLY_TIMEOUT_MS ?? '600000', 10),
|
|
66
|
+
allowedSenderOpenIds: requiredCsvSet(
|
|
67
|
+
'FEISHU_ALLOWED_OPEN_IDS',
|
|
68
|
+
process.env.FEISHU_ALLOWED_OPEN_IDS,
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function endpointFor(domain, path) {
|
|
2
|
+
const origin = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
|
|
3
|
+
return new URL(path, origin);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
async function jsonResponse(response, operation) {
|
|
7
|
+
let body;
|
|
8
|
+
try {
|
|
9
|
+
body = await response.json();
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`${operation} returned a non-JSON response`);
|
|
12
|
+
}
|
|
13
|
+
if (!response.ok || body?.code !== 0) {
|
|
14
|
+
throw new Error(`${operation} failed: ${body?.msg || `HTTP ${response.status}`}`);
|
|
15
|
+
}
|
|
16
|
+
return body;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validate freshly provisioned credentials and read the bot identity. */
|
|
20
|
+
export async function verifyFeishuApp({
|
|
21
|
+
appId,
|
|
22
|
+
appSecret,
|
|
23
|
+
domain = 'feishu',
|
|
24
|
+
fetchImpl = fetch,
|
|
25
|
+
timeoutMs = 15000,
|
|
26
|
+
}) {
|
|
27
|
+
if (!appId || !appSecret) throw new Error('Feishu credentials are incomplete');
|
|
28
|
+
const tokenResponse = await fetchImpl(endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal'), {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
31
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
32
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
+
});
|
|
34
|
+
const tokenBody = await jsonResponse(tokenResponse, 'Feishu authentication');
|
|
35
|
+
if (!tokenBody.tenant_access_token) {
|
|
36
|
+
throw new Error('Feishu authentication returned no tenant access token');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const botResponse = await fetchImpl(endpointFor(domain, '/open-apis/bot/v3/info/'), {
|
|
40
|
+
headers: { authorization: `Bearer ${tokenBody.tenant_access_token}` },
|
|
41
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
42
|
+
});
|
|
43
|
+
const botBody = await jsonResponse(botResponse, 'Feishu bot verification');
|
|
44
|
+
const bot = botBody.bot ?? {};
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
appId,
|
|
47
|
+
name: bot.app_name ?? bot.bot_name ?? null,
|
|
48
|
+
openId: bot.open_id ?? null,
|
|
49
|
+
activated: bot.activate_status ?? null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const STREAM_ELEMENT_ID = 'stream_md';
|
|
2
|
+
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
|
3
|
+
const MAX_STREAM_CHARS = 28000;
|
|
4
|
+
|
|
5
|
+
function assertApiSuccess(operation, response) {
|
|
6
|
+
if (response?.code && response.code !== 0) {
|
|
7
|
+
throw new Error(`${operation} failed: ${response.msg || response.code}`);
|
|
8
|
+
}
|
|
9
|
+
return response;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function summaryOf(text) {
|
|
13
|
+
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
14
|
+
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function streamingCard(initialText) {
|
|
18
|
+
return {
|
|
19
|
+
schema: '2.0',
|
|
20
|
+
config: {
|
|
21
|
+
streaming_mode: true,
|
|
22
|
+
summary: { content: '正在生成…' },
|
|
23
|
+
streaming_config: {
|
|
24
|
+
print_frequency_ms: { default: 70 },
|
|
25
|
+
print_step: { default: 1 },
|
|
26
|
+
print_strategy: 'fast',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
body: {
|
|
30
|
+
elements: [{
|
|
31
|
+
tag: 'markdown',
|
|
32
|
+
element_id: STREAM_ELEMENT_ID,
|
|
33
|
+
content: initialText,
|
|
34
|
+
}],
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class VerifiedFeishuChannel {
|
|
40
|
+
#client;
|
|
41
|
+
#initialText;
|
|
42
|
+
|
|
43
|
+
constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) {
|
|
44
|
+
this.#client = client;
|
|
45
|
+
this.#initialText = initialText;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stream(chatId, input, options = {}) {
|
|
49
|
+
if (typeof input?.markdown !== 'function') {
|
|
50
|
+
throw new Error('Feishu stream requires a markdown producer');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let messageId = null;
|
|
54
|
+
const cardResponse = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
|
|
55
|
+
data: {
|
|
56
|
+
type: 'card_json',
|
|
57
|
+
data: JSON.stringify(streamingCard(this.#initialText)),
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
const cardId = cardResponse?.data?.card_id;
|
|
61
|
+
if (!cardId) throw new Error('Feishu card.create returned no card_id');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
messageId = await this.#sendCard(chatId, cardId, options.replyTo);
|
|
65
|
+
let sequence = 0;
|
|
66
|
+
let lastContent = this.#initialText;
|
|
67
|
+
const controller = {
|
|
68
|
+
messageId,
|
|
69
|
+
setContent: async (content) => {
|
|
70
|
+
const next = String(content ?? '') || '…';
|
|
71
|
+
if (next === lastContent) return;
|
|
72
|
+
if (next.length > MAX_STREAM_CHARS) {
|
|
73
|
+
throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`);
|
|
74
|
+
}
|
|
75
|
+
const response = await this.#client.cardkit.v1.cardElement.content({
|
|
76
|
+
path: { card_id: cardId, element_id: STREAM_ELEMENT_ID },
|
|
77
|
+
data: {
|
|
78
|
+
content: next,
|
|
79
|
+
sequence: ++sequence,
|
|
80
|
+
uuid: `content_${cardId}_${sequence}`,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
assertApiSuccess('Feishu cardElement.content', response);
|
|
84
|
+
lastContent = next;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await input.markdown(controller);
|
|
89
|
+
const finishResponse = await this.#client.cardkit.v1.card.settings({
|
|
90
|
+
path: { card_id: cardId },
|
|
91
|
+
data: {
|
|
92
|
+
settings: JSON.stringify({
|
|
93
|
+
config: {
|
|
94
|
+
streaming_mode: false,
|
|
95
|
+
summary: { content: summaryOf(lastContent) || '回答完成' },
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
sequence: ++sequence,
|
|
99
|
+
uuid: `settings_${cardId}_${sequence}`,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
assertApiSuccess('Feishu card.settings', finishResponse);
|
|
103
|
+
return { messageId };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (messageId) await this.#recall(messageId);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async #sendCard(chatId, cardId, replyTo) {
|
|
111
|
+
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
|
112
|
+
const response = replyTo
|
|
113
|
+
? await this.#client.im.v1.message.reply({
|
|
114
|
+
path: { message_id: replyTo },
|
|
115
|
+
data: { msg_type: 'interactive', content },
|
|
116
|
+
})
|
|
117
|
+
: await this.#client.im.v1.message.create({
|
|
118
|
+
params: { receive_id_type: 'chat_id' },
|
|
119
|
+
data: { receive_id: chatId, msg_type: 'interactive', content },
|
|
120
|
+
});
|
|
121
|
+
assertApiSuccess('Feishu message send', response);
|
|
122
|
+
const messageId = response?.data?.message_id;
|
|
123
|
+
if (!messageId) throw new Error('Feishu message send returned no message_id');
|
|
124
|
+
return messageId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #recall(messageId) {
|
|
128
|
+
try {
|
|
129
|
+
const response = await this.#client.im.v1.message.delete({
|
|
130
|
+
path: { message_id: messageId },
|
|
131
|
+
});
|
|
132
|
+
assertApiSuccess('Feishu message delete', response);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.warn('[bridge] unable to recall a failed streaming card:', error.message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async addReaction(messageId, emojiType) {
|
|
139
|
+
const response = assertApiSuccess('Feishu reaction.create', await this.#client.im.v1.messageReaction.create({
|
|
140
|
+
path: { message_id: messageId },
|
|
141
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
142
|
+
}));
|
|
143
|
+
const reactionId = response?.data?.reaction_id;
|
|
144
|
+
if (!reactionId) throw new Error('Feishu reaction.create returned no reaction_id');
|
|
145
|
+
return reactionId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async removeReaction(messageId, reactionId) {
|
|
149
|
+
assertApiSuccess('Feishu reaction.delete', await this.#client.im.v1.messageReaction.delete({
|
|
150
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
2
|
+
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
3
|
+
|
|
4
|
+
export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|
5
|
+
return {
|
|
6
|
+
startedAt: null,
|
|
7
|
+
ready: false,
|
|
8
|
+
feishuLongConnectionState: 'idle',
|
|
9
|
+
harnessReachable: false,
|
|
10
|
+
messagesReceived: 0,
|
|
11
|
+
messagesReplied: 0,
|
|
12
|
+
messagesRejected: 0,
|
|
13
|
+
reactionsAdded: 0,
|
|
14
|
+
reactionsRemoved: 0,
|
|
15
|
+
reactionErrors: 0,
|
|
16
|
+
streamResponses: 0,
|
|
17
|
+
streamUpdates: 0,
|
|
18
|
+
streamFallbacks: 0,
|
|
19
|
+
streamErrors: 0,
|
|
20
|
+
lastMessageAt: null,
|
|
21
|
+
lastReplyAt: null,
|
|
22
|
+
lastRejectedAt: null,
|
|
23
|
+
lastError: null,
|
|
24
|
+
agentPreset: 'standard',
|
|
25
|
+
authorizationMode: 'sender-open-id-allowlist',
|
|
26
|
+
allowedSenderCount,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Owns one live Feishu long connection and the already-tested bridge stack.
|
|
32
|
+
* The class intentionally receives the SDK and Harness dependencies so the
|
|
33
|
+
* plugin can run it in-process while tests exercise the lifecycle without a
|
|
34
|
+
* real Feishu tenant.
|
|
35
|
+
*/
|
|
36
|
+
export class FeishuRuntime {
|
|
37
|
+
#lark;
|
|
38
|
+
#appId;
|
|
39
|
+
#appSecret;
|
|
40
|
+
#domain;
|
|
41
|
+
#ownerOpenIds;
|
|
42
|
+
#harness;
|
|
43
|
+
#state;
|
|
44
|
+
#replyTimeoutMs;
|
|
45
|
+
#connectTimeoutMs;
|
|
46
|
+
#logger;
|
|
47
|
+
#client = null;
|
|
48
|
+
#bridge = null;
|
|
49
|
+
#wsClient = null;
|
|
50
|
+
#starting = null;
|
|
51
|
+
#status;
|
|
52
|
+
|
|
53
|
+
constructor({
|
|
54
|
+
lark,
|
|
55
|
+
appId,
|
|
56
|
+
appSecret,
|
|
57
|
+
domain = 'feishu',
|
|
58
|
+
ownerOpenId,
|
|
59
|
+
ownerOpenIds,
|
|
60
|
+
harness,
|
|
61
|
+
state,
|
|
62
|
+
replyTimeoutMs = 600000,
|
|
63
|
+
connectTimeoutMs = 15000,
|
|
64
|
+
logger = console,
|
|
65
|
+
}) {
|
|
66
|
+
if (!lark) throw new Error('FeishuRuntime requires the Feishu SDK');
|
|
67
|
+
if (!appId || !appSecret) throw new Error('FeishuRuntime requires app credentials');
|
|
68
|
+
const allowedOwners = Array.isArray(ownerOpenIds) ? ownerOpenIds : [ownerOpenId];
|
|
69
|
+
const normalizedOwners = [...new Set(allowedOwners.filter((value) => typeof value === 'string' && value))];
|
|
70
|
+
if (normalizedOwners.length === 0) throw new Error('FeishuRuntime requires at least one owner open_id');
|
|
71
|
+
if (!harness) throw new Error('FeishuRuntime requires a Harness client');
|
|
72
|
+
if (!state) throw new Error('FeishuRuntime requires a state store');
|
|
73
|
+
|
|
74
|
+
this.#lark = lark;
|
|
75
|
+
this.#appId = appId;
|
|
76
|
+
this.#appSecret = appSecret;
|
|
77
|
+
this.#domain = domain;
|
|
78
|
+
this.#ownerOpenIds = normalizedOwners;
|
|
79
|
+
this.#harness = harness;
|
|
80
|
+
this.#state = state;
|
|
81
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
82
|
+
this.#connectTimeoutMs = connectTimeoutMs;
|
|
83
|
+
this.#logger = logger;
|
|
84
|
+
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get status() {
|
|
88
|
+
return structuredClone(this.#status);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async start() {
|
|
92
|
+
if (this.#wsClient && this.#status.ready) return this.status;
|
|
93
|
+
if (this.#starting) return this.#starting;
|
|
94
|
+
|
|
95
|
+
this.#starting = this.#start().finally(() => {
|
|
96
|
+
this.#starting = null;
|
|
97
|
+
});
|
|
98
|
+
return this.#starting;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async #start() {
|
|
102
|
+
this.#status.startedAt = new Date().toISOString();
|
|
103
|
+
this.#status.feishuLongConnectionState = 'connecting';
|
|
104
|
+
this.#status.lastError = null;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await this.#harness.ensureRunning();
|
|
108
|
+
this.#status.harnessReachable = true;
|
|
109
|
+
|
|
110
|
+
const sdkDomain = this.#domain === 'lark'
|
|
111
|
+
? this.#lark.Domain.Lark
|
|
112
|
+
: this.#lark.Domain.Feishu;
|
|
113
|
+
const larkConfig = {
|
|
114
|
+
appId: this.#appId,
|
|
115
|
+
appSecret: this.#appSecret,
|
|
116
|
+
domain: sdkDomain,
|
|
117
|
+
};
|
|
118
|
+
this.#client = new this.#lark.Client(larkConfig);
|
|
119
|
+
const channel = new VerifiedFeishuChannel({
|
|
120
|
+
client: this.#client,
|
|
121
|
+
initialText: '已连接 DeepSeek Harness,正在思考…',
|
|
122
|
+
});
|
|
123
|
+
this.#bridge = new FeishuHarnessBridge({
|
|
124
|
+
client: this.#client,
|
|
125
|
+
channel,
|
|
126
|
+
harness: this.#harness,
|
|
127
|
+
state: this.#state,
|
|
128
|
+
status: this.#status,
|
|
129
|
+
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
|
130
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const dispatcher = new this.#lark.EventDispatcher({}).register({
|
|
134
|
+
'im.message.receive_v1': (event) => {
|
|
135
|
+
this.#bridge.accept(event);
|
|
136
|
+
return {};
|
|
137
|
+
},
|
|
138
|
+
'im.message.reaction.created_v1': () => ({}),
|
|
139
|
+
'im.message.reaction.deleted_v1': () => ({}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
let settleReady;
|
|
143
|
+
let settleError;
|
|
144
|
+
const ready = new Promise((resolve, reject) => {
|
|
145
|
+
let settled = false;
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
if (settled) return;
|
|
148
|
+
settled = true;
|
|
149
|
+
reject(new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`));
|
|
150
|
+
}, this.#connectTimeoutMs);
|
|
151
|
+
settleReady = () => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolve();
|
|
156
|
+
};
|
|
157
|
+
settleError = (error) => {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
reject(error);
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.#wsClient = new this.#lark.WSClient({
|
|
166
|
+
...larkConfig,
|
|
167
|
+
loggerLevel: this.#lark.LoggerLevel.info,
|
|
168
|
+
handshakeTimeoutMs: 15000,
|
|
169
|
+
onReady: () => {
|
|
170
|
+
this.#status.feishuLongConnectionState = 'connected';
|
|
171
|
+
this.#status.ready = true;
|
|
172
|
+
this.#status.lastError = null;
|
|
173
|
+
settleReady();
|
|
174
|
+
},
|
|
175
|
+
onError: (error) => {
|
|
176
|
+
this.#status.feishuLongConnectionState = 'failed';
|
|
177
|
+
this.#status.ready = false;
|
|
178
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
179
|
+
this.#logger.error('[dsh-feishu] Feishu long connection failed:', this.#status.lastError);
|
|
180
|
+
settleError(error);
|
|
181
|
+
},
|
|
182
|
+
onReconnecting: () => {
|
|
183
|
+
this.#status.feishuLongConnectionState = 'reconnecting';
|
|
184
|
+
this.#status.ready = false;
|
|
185
|
+
},
|
|
186
|
+
onReconnected: () => {
|
|
187
|
+
this.#status.feishuLongConnectionState = 'connected';
|
|
188
|
+
this.#status.ready = true;
|
|
189
|
+
this.#status.lastError = null;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await this.#wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
|
|
193
|
+
settleError(error);
|
|
194
|
+
});
|
|
195
|
+
await ready;
|
|
196
|
+
return this.status;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.#status.ready = false;
|
|
199
|
+
this.#status.feishuLongConnectionState = 'failed';
|
|
200
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
201
|
+
await this.stop({ preserveError: true });
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async stop({ preserveError = false } = {}) {
|
|
207
|
+
const error = preserveError ? this.#status.lastError : null;
|
|
208
|
+
this.#status.ready = false;
|
|
209
|
+
if (this.#wsClient) {
|
|
210
|
+
this.#wsClient.close({ force: true });
|
|
211
|
+
this.#wsClient = null;
|
|
212
|
+
}
|
|
213
|
+
if (this.#bridge) {
|
|
214
|
+
await this.#bridge.waitForIdle();
|
|
215
|
+
this.#bridge = null;
|
|
216
|
+
}
|
|
217
|
+
this.#client = null;
|
|
218
|
+
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
|
219
|
+
this.#status.lastError = error;
|
|
220
|
+
return this.status;
|
|
221
|
+
}
|
|
222
|
+
}
|