cc-viewer 1.6.288 → 1.6.289
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/dist/assets/App-5qf4V-IT.css +1 -0
- package/dist/assets/App-zw1VXo0m.js +1 -0
- package/dist/assets/{MdxEditorPanel-B7qeMjuJ.js → MdxEditorPanel-D-yZyfRx.js} +1 -1
- package/dist/assets/{Mobile-fEx_3vW1.js → Mobile-CH0mudvq.js} +1 -1
- package/dist/assets/index-CwWxkILC.js +2 -0
- package/dist/assets/seqResourceLoaders-CoDjpIz4.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +9 -2
- package/server/i18n.js +36 -0
- package/server/lib/adapters/dingtalk-adapter.js +129 -0
- package/server/lib/adapters/discord-adapter.js +140 -0
- package/server/lib/adapters/feishu-adapter.js +133 -0
- package/server/lib/adapters/wecom-adapter.js +146 -0
- package/server/lib/dingtalk-bridge.js +28 -474
- package/server/lib/dingtalk-config.js +11 -153
- package/server/lib/im-bridge-core.js +530 -0
- package/server/lib/im-config.js +246 -0
- package/server/routes/im.js +133 -0
- package/server/routes/preferences.js +13 -6
- package/server/server.js +31 -18
- package/dist/assets/App-BRgb-Ukj.css +0 -1
- package/dist/assets/App-D73sTzGX.js +0 -1
- package/dist/assets/index-C_SecKTB.js +0 -2
- package/dist/assets/seqResourceLoaders-wAP4dArl.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
+
<script type="module" crossorigin src="/assets/index-CwWxkILC.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-Dx8tRqWW.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CYU_jFNR.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-9MfaZIbX.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.289",
|
|
4
4
|
"description": "Claude Code Logger visualization management tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -102,16 +102,23 @@
|
|
|
102
102
|
"vite": "^6.3.5"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
+
"@larksuiteoapi/node-sdk": "1.66.0",
|
|
106
|
+
"@wecom/aibot-node-sdk": "1.0.7",
|
|
105
107
|
"adm-zip": "^0.5.17",
|
|
106
108
|
"dingtalk-stream": "2.1.5",
|
|
109
|
+
"discord.js": "14.26.4",
|
|
107
110
|
"dompurify": "^3.3.3",
|
|
108
111
|
"node-pty": "^1.1.0",
|
|
109
112
|
"undici": "^7.22.0",
|
|
110
|
-
"ws": "^8.
|
|
113
|
+
"ws": "^8.21.0"
|
|
111
114
|
},
|
|
112
115
|
"optionalDependencies": {
|
|
113
116
|
"@anthropic-ai/claude-agent-sdk": "^0.2.91"
|
|
114
117
|
},
|
|
118
|
+
"overrides": {
|
|
119
|
+
"axios": "^1.16.1",
|
|
120
|
+
"qs": "^6.15.2"
|
|
121
|
+
},
|
|
115
122
|
"c8": {
|
|
116
123
|
"include": [
|
|
117
124
|
"server/**/*.js",
|
package/server/i18n.js
CHANGED
|
@@ -639,6 +639,42 @@ const i18nData = {
|
|
|
639
639
|
"server.dingtalk.skipPermBlocked": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
640
640
|
"server.dingtalk.injectFailed": { "zh": "注入失败(会话已结束或不可用),请确认 Claude 会话仍在运行。", "en": "Injection failed (session ended or unavailable) — check the Claude session is still running." },
|
|
641
641
|
"server.dingtalk.queueFull": { "zh": "消息队列已满,已丢弃该消息,请稍后再发。", "en": "Message queue is full; this message was dropped. Try again later." },
|
|
642
|
+
"server.feishu.replyChunkTitle": { "zh": "Claude", "en": "Claude" },
|
|
643
|
+
"server.feishu.truncated": { "zh": "(回复过长已截断,完整内容请见 Web 界面)", "en": "(reply truncated — see the web UI for the full content)" },
|
|
644
|
+
"server.feishu.noTextReply": { "zh": "(本轮无文本回复)", "en": "(no text reply this turn)" },
|
|
645
|
+
"server.feishu.interrupted": { "zh": "已中断当前回合。", "en": "Interrupted the current turn." },
|
|
646
|
+
"server.feishu.noSession": { "zh": "当前没有活跃的 Claude 会话,无法处理。", "en": "No active Claude session — cannot process." },
|
|
647
|
+
"server.feishu.notBound": { "zh": "该会话未绑定到本机,已忽略。", "en": "This conversation is not bound; ignored." },
|
|
648
|
+
"server.feishu.notAuthorized": { "zh": "你不在允许的发送人白名单内。", "en": "You are not in the allowed sender list." },
|
|
649
|
+
"server.feishu.busyQueued": { "zh": "Claude 正在忙,你的消息已排队。", "en": "Claude is busy; your message is queued." },
|
|
650
|
+
"server.feishu.skipPermWarning": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
651
|
+
"server.feishu.skipPermBlocked": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
652
|
+
"server.feishu.injectFailed": { "zh": "注入失败(会话已结束或不可用),请确认 Claude 会话仍在运行。", "en": "Injection failed (session ended or unavailable) — check the Claude session is still running." },
|
|
653
|
+
"server.feishu.queueFull": { "zh": "消息队列已满,已丢弃该消息,请稍后再发。", "en": "Message queue is full; this message was dropped. Try again later." },
|
|
654
|
+
"server.wecom.replyChunkTitle": { "zh": "Claude", "en": "Claude" },
|
|
655
|
+
"server.wecom.truncated": { "zh": "(回复过长已截断,完整内容请见 Web 界面)", "en": "(reply truncated — see the web UI for the full content)" },
|
|
656
|
+
"server.wecom.noTextReply": { "zh": "(本轮无文本回复)", "en": "(no text reply this turn)" },
|
|
657
|
+
"server.wecom.interrupted": { "zh": "已中断当前回合。", "en": "Interrupted the current turn." },
|
|
658
|
+
"server.wecom.noSession": { "zh": "当前没有活跃的 Claude 会话,无法处理。", "en": "No active Claude session — cannot process." },
|
|
659
|
+
"server.wecom.notBound": { "zh": "该会话未绑定到本机,已忽略。", "en": "This conversation is not bound; ignored." },
|
|
660
|
+
"server.wecom.notAuthorized": { "zh": "你不在允许的发送人白名单内。", "en": "You are not in the allowed sender list." },
|
|
661
|
+
"server.wecom.busyQueued": { "zh": "Claude 正在忙,你的消息已排队。", "en": "Claude is busy; your message is queued." },
|
|
662
|
+
"server.wecom.skipPermWarning": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
663
|
+
"server.wecom.skipPermBlocked": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
664
|
+
"server.wecom.injectFailed": { "zh": "注入失败(会话已结束或不可用),请确认 Claude 会话仍在运行。", "en": "Injection failed (session ended or unavailable) — check the Claude session is still running." },
|
|
665
|
+
"server.wecom.queueFull": { "zh": "消息队列已满,已丢弃该消息,请稍后再发。", "en": "Message queue is full; this message was dropped. Try again later." },
|
|
666
|
+
"server.discord.replyChunkTitle": { "zh": "Claude", "en": "Claude" },
|
|
667
|
+
"server.discord.truncated": { "zh": "(回复过长已截断,完整内容请见 Web 界面)", "en": "(reply truncated — see the web UI for the full content)" },
|
|
668
|
+
"server.discord.noTextReply": { "zh": "(本轮无文本回复)", "en": "(no text reply this turn)" },
|
|
669
|
+
"server.discord.interrupted": { "zh": "已中断当前回合。", "en": "Interrupted the current turn." },
|
|
670
|
+
"server.discord.noSession": { "zh": "当前没有活跃的 Claude 会话,无法处理。", "en": "No active Claude session — cannot process." },
|
|
671
|
+
"server.discord.notBound": { "zh": "该会话未绑定到本机,已忽略。", "en": "This conversation is not bound; ignored." },
|
|
672
|
+
"server.discord.notAuthorized": { "zh": "你不在允许的发送人白名单内。", "en": "You are not in the allowed sender list." },
|
|
673
|
+
"server.discord.busyQueued": { "zh": "Claude 正在忙,你的消息已排队。", "en": "Claude is busy; your message is queued." },
|
|
674
|
+
"server.discord.skipPermWarning": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
675
|
+
"server.discord.skipPermBlocked": { "zh": "收到,正在思考", "en": "Got it, thinking…" },
|
|
676
|
+
"server.discord.injectFailed": { "zh": "注入失败(会话已结束或不可用),请确认 Claude 会话仍在运行。", "en": "Injection failed (session ended or unavailable) — check the Claude session is still running." },
|
|
677
|
+
"server.discord.queueFull": { "zh": "消息队列已满,已丢弃该消息,请稍后再发。", "en": "Message queue is full; this message was dropped. Try again later." },
|
|
642
678
|
"update.updating": {
|
|
643
679
|
"zh": "正在更新到 v{version}...",
|
|
644
680
|
"en": "Updating to v{version}...",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// DingTalk adapter — the platform-specific half of the DingTalk bridge. The generic
|
|
2
|
+
// orchestration (dedup, access control, queue, inject, chunk, turn-end reply) lives in
|
|
3
|
+
// server/lib/im-bridge-core.js; this module only knows DingTalk's Stream client, ACK protocol,
|
|
4
|
+
// inbound payload shape, access-token fetch, and proactive App API send endpoints.
|
|
5
|
+
import { registerAdapter } from '../im-bridge-core.js';
|
|
6
|
+
import { t } from '../../i18n.js';
|
|
7
|
+
|
|
8
|
+
// ─── test seam: a fake Stream client factory (zero dingtalk-stream / network) ───
|
|
9
|
+
let clientFactory = null;
|
|
10
|
+
export function __setClientFactory(fn) { clientFactory = fn; }
|
|
11
|
+
|
|
12
|
+
// ─── DingTalk App API (proactive send; the inbound sessionWebhook is expired by reply time) ───
|
|
13
|
+
const TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/accessToken';
|
|
14
|
+
const GROUP_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
|
|
15
|
+
const OTO_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
|
|
16
|
+
|
|
17
|
+
async function getAccessToken(cfg, ctx) {
|
|
18
|
+
const tc = ctx.store.tokenCache;
|
|
19
|
+
if (tc && tc.appKey === cfg.appKey && tc.expiresAt > Date.now() + 300_000) {
|
|
20
|
+
return tc.accessToken;
|
|
21
|
+
}
|
|
22
|
+
const r = await ctx.fetch(TOKEN_URL, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ appKey: cfg.appKey, appSecret: cfg.appSecret }),
|
|
26
|
+
});
|
|
27
|
+
const j = await r.json().catch(() => ({}));
|
|
28
|
+
if (!r.ok || !j.accessToken) throw new Error(`token ${r.status}: ${j.message || j.code || 'failed'}`);
|
|
29
|
+
ctx.store.tokenCache = { appKey: cfg.appKey, accessToken: j.accessToken, expiresAt: Date.now() + (j.expireIn || 7200) * 1000 };
|
|
30
|
+
return j.accessToken;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalize a DingTalk TOPIC_ROBOT callback into the core's inbound shape. Returns null for
|
|
34
|
+
* an unparseable payload (the core skips it). */
|
|
35
|
+
function normalizeInbound(res) {
|
|
36
|
+
let msg;
|
|
37
|
+
try { msg = JSON.parse(res?.data ?? '{}'); } catch { return null; }
|
|
38
|
+
const conversationId = msg.conversationId;
|
|
39
|
+
const conversationType = msg.conversationType;
|
|
40
|
+
const senderStaffId = msg.senderStaffId;
|
|
41
|
+
const robotCode = msg.robotCode || msg.chatbotUserId;
|
|
42
|
+
return {
|
|
43
|
+
text: msg.text?.content ?? '',
|
|
44
|
+
conversationId,
|
|
45
|
+
isGroup: String(conversationType) === '2',
|
|
46
|
+
senderId: senderStaffId,
|
|
47
|
+
msgId: res?.headers?.messageId,
|
|
48
|
+
// opaque platform extras carried back to sendOne
|
|
49
|
+
target: { conversationId, conversationType, robotCode, senderStaffId },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const dingtalkAdapter = {
|
|
54
|
+
id: 'dingtalk',
|
|
55
|
+
i18nNs: 'server.dingtalk',
|
|
56
|
+
allowListField: 'allowStaffIds',
|
|
57
|
+
capabilities: { inboundAck: true, sdkManagesToken: false },
|
|
58
|
+
rateLimit: { max: 18, windowMs: 60_000 }, // stay under DingTalk's 20/min
|
|
59
|
+
|
|
60
|
+
hasCreds(cfg) { return !!(cfg.appKey && cfg.appSecret); },
|
|
61
|
+
statusFields(cfg) { return { appKeyTail: cfg?.appKey?.slice(-4) || '' }; },
|
|
62
|
+
|
|
63
|
+
async connect(cfg, hooks) {
|
|
64
|
+
let client;
|
|
65
|
+
if (clientFactory) {
|
|
66
|
+
client = clientFactory({ clientId: cfg.appKey, clientSecret: cfg.appSecret });
|
|
67
|
+
if (typeof client.registerCallbackListener === 'function') {
|
|
68
|
+
client.registerCallbackListener('__test__', (res) => hooks.onInbound(normalizeInbound(res), res));
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const mod = await import('dingtalk-stream');
|
|
72
|
+
const { DWClient, TOPIC_ROBOT } = mod;
|
|
73
|
+
client = new DWClient({ clientId: cfg.appKey, clientSecret: cfg.appSecret });
|
|
74
|
+
client.registerCallbackListener(TOPIC_ROBOT, (res) => hooks.onInbound(normalizeInbound(res), res));
|
|
75
|
+
}
|
|
76
|
+
await client.connect?.();
|
|
77
|
+
return client;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async disconnect(client) {
|
|
81
|
+
try { await client?.disconnect?.(); } catch { /* best-effort */ }
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// ackCtx is the raw `res`; `client` is the live Stream client (passed by the core).
|
|
85
|
+
ack(res, client) {
|
|
86
|
+
try {
|
|
87
|
+
const id = res?.headers?.messageId;
|
|
88
|
+
if (!id || !client) return;
|
|
89
|
+
if (typeof client.socketCallBackResponse === 'function') {
|
|
90
|
+
client.socketCallBackResponse(id, { success: true });
|
|
91
|
+
} else if (typeof client.send === 'function') {
|
|
92
|
+
client.send(id, JSON.stringify({ status: 'SUCCESS', message: 'OK' }));
|
|
93
|
+
}
|
|
94
|
+
} catch { /* best-effort; ack failure only risks a redelivery, caught by dedup */ }
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async sendOne(cfg, target, content, ctx) {
|
|
98
|
+
const token = await getAccessToken(cfg, ctx);
|
|
99
|
+
const msgParam = JSON.stringify({ title: t('server.dingtalk.replyChunkTitle'), text: content });
|
|
100
|
+
const isGroup = String(target.conversationType) === '2';
|
|
101
|
+
const url = isGroup ? GROUP_SEND_URL : OTO_SEND_URL;
|
|
102
|
+
const body = isGroup
|
|
103
|
+
? { robotCode: target.robotCode, openConversationId: target.conversationId, msgKey: 'sampleMarkdown', msgParam }
|
|
104
|
+
: { robotCode: target.robotCode, userIds: [target.senderStaffId].filter(Boolean), msgKey: 'sampleMarkdown', msgParam };
|
|
105
|
+
const r = await ctx.fetch(url, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
if (!r.ok) {
|
|
111
|
+
const j = await r.json().catch(() => ({}));
|
|
112
|
+
throw new Error(`send ${r.status}: ${j.message || j.code || 'failed'}`);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async testConnection(cfg, ctx) {
|
|
117
|
+
try {
|
|
118
|
+
ctx.store.tokenCache = null; // force a fresh fetch
|
|
119
|
+
await getAccessToken(cfg, ctx);
|
|
120
|
+
return { ok: true };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { ok: false, detail: String(e?.message || e) };
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
registerAdapter(dingtalkAdapter);
|
|
128
|
+
|
|
129
|
+
export default dingtalkAdapter;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Discord adapter — the platform-specific half of the Discord bridge. The generic orchestration
|
|
2
|
+
// (dedup, access control, queue, inject, chunk, turn-end reply) lives in server/lib/im-bridge-core.js;
|
|
3
|
+
// this module only knows discord.js's Gateway client, inbound message shape, and outbound send.
|
|
4
|
+
//
|
|
5
|
+
// Uses discord.js v14: the bot dials OUT to Discord's Gateway WebSocket (no public URL). Credential is
|
|
6
|
+
// a single bot token. Per the multi-IM model it responds to ALL messages in the bound conversation
|
|
7
|
+
// (a guild channel or a DM); only the bot/self loop-guard applies. Reply lands ~10s after the turn.
|
|
8
|
+
//
|
|
9
|
+
// Distilled from LangBot's Discord adapter: enable the privileged MESSAGE_CONTENT intent; loop-guard
|
|
10
|
+
// `author.bot || author.id === self`; DM vs guild by `inGuild()`, reply target = channel.
|
|
11
|
+
//
|
|
12
|
+
// Console prerequisite (no code equivalent — surfaced in the UI help): in the Discord Developer Portal,
|
|
13
|
+
// create an app + bot, ENABLE the Message Content Intent (else message.content is silently empty), copy
|
|
14
|
+
// the bot token, and invite the bot with the `bot`+`applications.commands` scopes and View/Send perms.
|
|
15
|
+
import { registerAdapter } from '../im-bridge-core.js';
|
|
16
|
+
|
|
17
|
+
// ─── test seam: a fake discord.js factory (zero real gateway / socket) ───
|
|
18
|
+
let sdkFactory = null;
|
|
19
|
+
export function __setClientFactory(fn) { sdkFactory = fn; }
|
|
20
|
+
|
|
21
|
+
async function loadSdk() {
|
|
22
|
+
if (sdkFactory) return sdkFactory();
|
|
23
|
+
return import('discord.js');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const CONNECT_PROBE_MS = 12_000; // internal connect guard (< core CONNECT_TIMEOUT_MS)
|
|
27
|
+
const DISCORD_MAX = 2_000; // hard per-message limit
|
|
28
|
+
|
|
29
|
+
/** Normalize a discord.js Message into the core's inbound shape. The bot/self loop-guard runs in the
|
|
30
|
+
* event handler (before this). */
|
|
31
|
+
function normalizeInbound(message) {
|
|
32
|
+
const isGroup = typeof message.inGuild === 'function' ? message.inGuild() : !!message.guild;
|
|
33
|
+
const senderId = message.author?.id || '';
|
|
34
|
+
const channelId = message.channelId;
|
|
35
|
+
// Strip a leading bot mention (<@123> / <@!123>) so "@bot do X" injects as "do X".
|
|
36
|
+
const text = String(message.content ?? '').replace(/^<@!?\d+>\s*/, '');
|
|
37
|
+
return {
|
|
38
|
+
text,
|
|
39
|
+
conversationId: channelId,
|
|
40
|
+
isGroup,
|
|
41
|
+
senderId,
|
|
42
|
+
msgId: message.id,
|
|
43
|
+
// For a DM, channels.fetch(channelId) is broken on a partial DM channel (CHANNEL_RECIPIENT_REQUIRED),
|
|
44
|
+
// so carry the user id and reply via users.fetch().createDM(). Guild channels fetch reliably by id.
|
|
45
|
+
target: { conversationId: channelId, channelId, userId: isGroup ? null : senderId },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const discordAdapter = {
|
|
50
|
+
id: 'discord',
|
|
51
|
+
i18nNs: 'server.discord',
|
|
52
|
+
allowListField: 'allowUserIds',
|
|
53
|
+
capabilities: { inboundAck: false },
|
|
54
|
+
// Discord allows ~5 messages / 5s per channel; cap conservatively.
|
|
55
|
+
rateLimit: { max: 5, windowMs: 5_000 },
|
|
56
|
+
|
|
57
|
+
hasCreds(cfg) { return !!cfg.botToken; },
|
|
58
|
+
// Discord's only credential is the token (a secret) — exposing any tail would leak secret material.
|
|
59
|
+
statusFields() { return {}; },
|
|
60
|
+
|
|
61
|
+
async connect(cfg, hooks, ctx) {
|
|
62
|
+
const mod = await loadSdk();
|
|
63
|
+
const { Client, GatewayIntentBits, Partials, Events } = mod;
|
|
64
|
+
const client = new Client({
|
|
65
|
+
intents: [
|
|
66
|
+
GatewayIntentBits.Guilds,
|
|
67
|
+
GatewayIntentBits.GuildMessages,
|
|
68
|
+
GatewayIntentBits.MessageContent, // privileged — must also be enabled in the Dev Portal
|
|
69
|
+
GatewayIntentBits.DirectMessages,
|
|
70
|
+
],
|
|
71
|
+
partials: [Partials.Channel], // REQUIRED to receive DMs (DM channels are uncached)
|
|
72
|
+
});
|
|
73
|
+
ctx.store.client = client;
|
|
74
|
+
// Persistent error listener for the life of the client: a Node EventEmitter that emits 'error'
|
|
75
|
+
// with NO listener throws and crashes the process. The connect-window once('error') below is
|
|
76
|
+
// consumed on resolve, so without this a later gateway 'error' would be fatal. Swallow (the
|
|
77
|
+
// gateway auto-reconnects; connection state surfaces via the bridge status).
|
|
78
|
+
client.on('error', () => { /* swallowed — see comment */ });
|
|
79
|
+
client.on(Events.MessageCreate, (message) => {
|
|
80
|
+
// Loop-guard: ignore the bot's own messages (Discord redelivers our send as a new event) and
|
|
81
|
+
// all other bots. author.bot covers self; the id check is belt-and-suspenders.
|
|
82
|
+
if (message.author?.bot || message.author?.id === client.user?.id) return;
|
|
83
|
+
hooks.onInbound(normalizeInbound(message), null);
|
|
84
|
+
});
|
|
85
|
+
// login() resolves only the handshake start and REJECTS (as a promise) on a bad token — route that
|
|
86
|
+
// into the same promise, else a bad token hangs to the timeout. ClientReady signals fully-ready.
|
|
87
|
+
return await new Promise((resolve, reject) => {
|
|
88
|
+
let settled = false;
|
|
89
|
+
const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); fn(arg); };
|
|
90
|
+
const timer = setTimeout(() => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error('connect timeout')); }, CONNECT_PROBE_MS);
|
|
91
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
92
|
+
client.once(Events.ClientReady, () => finish(resolve, client));
|
|
93
|
+
client.once('error', (e) => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error(String(e?.message || e))); });
|
|
94
|
+
client.login(cfg.botToken).catch((e) => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error(String(e?.message || e))); });
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async disconnect(client, ctx) {
|
|
99
|
+
try { client?.removeAllListeners?.(); } catch { /* best-effort */ }
|
|
100
|
+
try { await client?.destroy?.(); } catch { /* best-effort */ }
|
|
101
|
+
if (ctx?.store) ctx.store.client = null;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
ack() { /* no-op (Discord has no inbound ack; loop-guard + msgId dedup handle redelivery) */ },
|
|
105
|
+
|
|
106
|
+
async sendOne(cfg, target, content, ctx) {
|
|
107
|
+
const client = ctx.store.client;
|
|
108
|
+
if (!client) throw new Error('discord client not connected');
|
|
109
|
+
let channel;
|
|
110
|
+
if (target.userId) {
|
|
111
|
+
// DM: reconstruct via the user (channels.fetch on a partial DM channel throws #9624).
|
|
112
|
+
const user = await client.users.fetch(target.userId);
|
|
113
|
+
channel = await user.createDM();
|
|
114
|
+
} else {
|
|
115
|
+
channel = await client.channels.fetch(target.channelId);
|
|
116
|
+
}
|
|
117
|
+
if (!channel || typeof channel.send !== 'function') throw new Error(`channel not sendable: ${target.channelId}`);
|
|
118
|
+
// Hard-split at Discord's 2000-char limit (defense: maxChunkChars clamps up to 5000, > the limit).
|
|
119
|
+
for (let i = 0; i < content.length; i += DISCORD_MAX) {
|
|
120
|
+
await channel.send(content.slice(i, i + DISCORD_MAX));
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async testConnection(cfg, ctx) {
|
|
125
|
+
// Validate the token via REST without opening a gateway (and without the privileged intent).
|
|
126
|
+
try {
|
|
127
|
+
const r = await ctx.fetch('https://discord.com/api/v10/users/@me', {
|
|
128
|
+
headers: { Authorization: `Bot ${cfg.botToken}` },
|
|
129
|
+
});
|
|
130
|
+
if (r.ok) return { ok: true };
|
|
131
|
+
return { ok: false, detail: `HTTP ${r.status}` };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { ok: false, detail: String(e?.message || e) };
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
registerAdapter(discordAdapter);
|
|
139
|
+
|
|
140
|
+
export default discordAdapter;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Feishu/Lark adapter — the platform-specific half of the Feishu bridge. The generic
|
|
2
|
+
// orchestration (dedup, access control, queue, inject, chunk, turn-end reply) lives in
|
|
3
|
+
// server/lib/im-bridge-core.js; this module only knows Feishu's WebSocket long-connection event
|
|
4
|
+
// client, inbound payload shape, and outbound send.
|
|
5
|
+
//
|
|
6
|
+
// Inbound uses the official SDK's WSClient long-connection (NOT a webhook) so it works on
|
|
7
|
+
// loopback with no public URL — mirroring how DingTalk uses dingtalk-stream. The SDK's
|
|
8
|
+
// Lark.Client auto-acquires & caches the tenant_access_token for outbound, so (unlike DingTalk)
|
|
9
|
+
// we never hand-roll an outbound token; testConnection is the only place that fetches one by hand.
|
|
10
|
+
//
|
|
11
|
+
// Console prerequisite (no code equivalent — surfaced to the user in the UI/README): the custom
|
|
12
|
+
// app must set Event Subscription to "long connection", subscribe `im.message.receive_v1`, grant
|
|
13
|
+
// the `im:message` send scope, and be published.
|
|
14
|
+
import { registerAdapter } from '../im-bridge-core.js';
|
|
15
|
+
|
|
16
|
+
// ─── test seam: a fake SDK factory ({ Client, WSClient, EventDispatcher, Domain, LoggerLevel })
|
|
17
|
+
// so unit tests never import the real SDK or open a socket. ───
|
|
18
|
+
let sdkFactory = null;
|
|
19
|
+
export function __setClientFactory(fn) { sdkFactory = fn; }
|
|
20
|
+
|
|
21
|
+
const TOKEN_PATH = '/open-apis/auth/v3/tenant_access_token/internal';
|
|
22
|
+
function tokenHost(region) {
|
|
23
|
+
return region === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadSdk() {
|
|
27
|
+
if (sdkFactory) return sdkFactory();
|
|
28
|
+
return import('@larksuiteoapi/node-sdk');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalize an `im.message.receive_v1` event into the core's inbound shape. Returns null when
|
|
32
|
+
* the payload is unusable (the core skips it). */
|
|
33
|
+
function normalizeInbound(data) {
|
|
34
|
+
const msg = data?.message;
|
|
35
|
+
if (!msg) return null;
|
|
36
|
+
let text = '';
|
|
37
|
+
if (msg.message_type === 'text') {
|
|
38
|
+
let content;
|
|
39
|
+
try { content = JSON.parse(msg.content || '{}'); } catch { content = {}; }
|
|
40
|
+
// Strip Feishu @-mention placeholders (`@_user_1`) the platform injects into group text.
|
|
41
|
+
text = String(content.text || '').replace(/@_user_\d+/g, '').trim();
|
|
42
|
+
}
|
|
43
|
+
const isGroup = msg.chat_type === 'group';
|
|
44
|
+
const sid = data.sender?.sender_id || {};
|
|
45
|
+
const senderId = sid.open_id || sid.user_id || sid.union_id || '';
|
|
46
|
+
return {
|
|
47
|
+
text,
|
|
48
|
+
conversationId: msg.chat_id,
|
|
49
|
+
isGroup,
|
|
50
|
+
senderId,
|
|
51
|
+
msgId: msg.message_id,
|
|
52
|
+
// In a group, reply to the chat; in a p2p, reply to the sender. receive_id_type tells the
|
|
53
|
+
// send API which id space `receiveId` is in.
|
|
54
|
+
target: isGroup
|
|
55
|
+
? { conversationId: msg.chat_id, receiveId: msg.chat_id, receiveIdType: 'chat_id' }
|
|
56
|
+
: { conversationId: msg.chat_id, receiveId: senderId, receiveIdType: 'open_id' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const feishuAdapter = {
|
|
61
|
+
id: 'feishu',
|
|
62
|
+
i18nNs: 'server.feishu',
|
|
63
|
+
allowListField: 'allowUserIds',
|
|
64
|
+
capabilities: { inboundAck: false, sdkManagesToken: true },
|
|
65
|
+
// Feishu IM send limits are generous (≈1000/min/app); cap conservatively.
|
|
66
|
+
rateLimit: { max: 50, windowMs: 60_000 },
|
|
67
|
+
|
|
68
|
+
hasCreds(cfg) { return !!(cfg.appId && cfg.appSecret); },
|
|
69
|
+
statusFields(cfg) { return { appKeyTail: cfg?.appId?.slice(-4) || '' }; },
|
|
70
|
+
|
|
71
|
+
async connect(cfg, hooks, ctx) {
|
|
72
|
+
const Lark = await loadSdk();
|
|
73
|
+
const domain = cfg.region === 'lark' ? Lark.Domain.Lark : Lark.Domain.Feishu;
|
|
74
|
+
const base = { appId: cfg.appId, appSecret: cfg.appSecret, domain };
|
|
75
|
+
// Outbound client (auto-manages the tenant_access_token cache).
|
|
76
|
+
ctx.store.sendClient = new Lark.Client(base);
|
|
77
|
+
// Inbound long-connection event client. wsConfig is REQUIRED — omitting PingInterval/PingTimeout
|
|
78
|
+
// makes start() throw or hang on some SDK builds.
|
|
79
|
+
const ws = new Lark.WSClient({
|
|
80
|
+
...base,
|
|
81
|
+
loggerLevel: Lark.LoggerLevel ? Lark.LoggerLevel.warn : undefined, // warn: avoid noisy info-level stdout
|
|
82
|
+
wsConfig: { PingInterval: 30, PingTimeout: 5 },
|
|
83
|
+
});
|
|
84
|
+
const dispatcher = new Lark.EventDispatcher({}).register({
|
|
85
|
+
'im.message.receive_v1': async (data) => hooks.onInbound(normalizeInbound(data), null),
|
|
86
|
+
});
|
|
87
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
88
|
+
return ws;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async disconnect(ws) {
|
|
92
|
+
// Branch on method presence, not return value — a void-returning stop() must not also call close().
|
|
93
|
+
try {
|
|
94
|
+
if (typeof ws?.stop === 'function') await ws.stop();
|
|
95
|
+
else if (typeof ws?.close === 'function') await ws.close();
|
|
96
|
+
} catch { /* best-effort */ }
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Feishu long-connection has no app-level inbound ACK (the SDK handles transport acking).
|
|
100
|
+
ack() { /* no-op */ },
|
|
101
|
+
|
|
102
|
+
async sendOne(cfg, target, content, ctx) {
|
|
103
|
+
const client = ctx.store.sendClient;
|
|
104
|
+
if (!client) throw new Error('feishu send client not initialized');
|
|
105
|
+
const r = await client.im.v1.message.create({
|
|
106
|
+
params: { receive_id_type: target.receiveIdType },
|
|
107
|
+
data: { receive_id: target.receiveId, msg_type: 'text', content: JSON.stringify({ text: content }) },
|
|
108
|
+
});
|
|
109
|
+
if (r && typeof r.code === 'number' && r.code !== 0) {
|
|
110
|
+
throw new Error(`send ${r.code}: ${r.msg || 'failed'}`);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async testConnection(cfg, ctx) {
|
|
115
|
+
try {
|
|
116
|
+
const r = await ctx.fetch(tokenHost(cfg.region) + TOKEN_PATH, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
119
|
+
body: JSON.stringify({ app_id: cfg.appId, app_secret: cfg.appSecret }),
|
|
120
|
+
});
|
|
121
|
+
const j = await r.json().catch(() => ({}));
|
|
122
|
+
// Feishu returns HTTP 200 with a non-zero `code` on bad creds — check the body code.
|
|
123
|
+
if (!r.ok || j.code !== 0) return { ok: false, detail: j.msg || `token ${r.status}: ${j.code ?? 'failed'}` };
|
|
124
|
+
return { ok: true };
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { ok: false, detail: String(e?.message || e) };
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
registerAdapter(feishuAdapter);
|
|
132
|
+
|
|
133
|
+
export default feishuAdapter;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// 企业微信 (WeCom) adapter — the platform-specific half of the WeCom bridge. The generic
|
|
2
|
+
// orchestration (dedup, access control, queue, inject, chunk, turn-end reply) lives in
|
|
3
|
+
// server/lib/im-bridge-core.js; this module only knows WeCom's smart-robot long-connection client,
|
|
4
|
+
// inbound frame shape, and outbound send.
|
|
5
|
+
//
|
|
6
|
+
// Uses the 企业微信智能机器人长连接 (Smart Robot long-connection) mode via @wecom/aibot-node-sdk:
|
|
7
|
+
// an OUTBOUND WebSocket (wss://openws.work.weixin.qq.com) authed with botId + secret — so it works
|
|
8
|
+
// on loopback with no public URL, no trusted-IP whitelist, and no WXBizMsgCrypt in our code (the
|
|
9
|
+
// SDK handles auth/heartbeat/reconnect). Replies use the proactive sendMessage (our reply lands
|
|
10
|
+
// ~10s after the turn, well past the 5s synchronous reply window; the 24h proactive window covers it).
|
|
11
|
+
//
|
|
12
|
+
// Console prerequisite (no code equivalent — surfaced in the UI help/README): create a 智能机器人,
|
|
13
|
+
// set its API 接收模式 to 长连接, copy the botId + secret, and add the bot to a chat.
|
|
14
|
+
import { registerAdapter } from '../im-bridge-core.js';
|
|
15
|
+
|
|
16
|
+
// ─── test seam: a fake SDK factory (zero real SDK / socket) ───
|
|
17
|
+
let sdkFactory = null;
|
|
18
|
+
export function __setClientFactory(fn) { sdkFactory = fn; }
|
|
19
|
+
|
|
20
|
+
async function loadSdk() {
|
|
21
|
+
if (sdkFactory) return sdkFactory();
|
|
22
|
+
return import('@wecom/aibot-node-sdk');
|
|
23
|
+
}
|
|
24
|
+
function resolveWSClient(mod) {
|
|
25
|
+
const ns = mod.default ?? mod;
|
|
26
|
+
return ns.WSClient ?? mod.WSClient;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CONNECT_PROBE_MS = 12_000; // internal connect guard (< core CONNECT_TIMEOUT_MS) to avoid a leaked reconnect loop
|
|
30
|
+
const TEST_PROBE_MS = 8_000;
|
|
31
|
+
|
|
32
|
+
/** Normalize a WeCom `message.text` WsFrame into the core's inbound shape. Returns null for an
|
|
33
|
+
* unusable frame (the core skips it). */
|
|
34
|
+
function normalizeInbound(frame) {
|
|
35
|
+
const b = frame?.body;
|
|
36
|
+
if (!b) return null;
|
|
37
|
+
const isGroup = b.chattype === 'group';
|
|
38
|
+
const senderId = b.from?.userid || '';
|
|
39
|
+
const receiveId = isGroup ? b.chatid : senderId; // group → chatid, single → sender userid
|
|
40
|
+
// A group frame missing chatid (chatid is optional in the SDK types) has no reply target — drop
|
|
41
|
+
// it rather than bind/send to undefined.
|
|
42
|
+
if (!receiveId) return null;
|
|
43
|
+
return {
|
|
44
|
+
text: b.text?.content ?? '',
|
|
45
|
+
conversationId: receiveId,
|
|
46
|
+
isGroup,
|
|
47
|
+
senderId,
|
|
48
|
+
msgId: b.msgid,
|
|
49
|
+
target: { conversationId: receiveId, receiveId },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const wecomAdapter = {
|
|
54
|
+
id: 'wecom',
|
|
55
|
+
i18nNs: 'server.wecom',
|
|
56
|
+
allowListField: 'allowUserIds',
|
|
57
|
+
capabilities: { inboundAck: false },
|
|
58
|
+
// WeCom proactive-push limit is 30/min·1000/hr per conversation; cap conservatively.
|
|
59
|
+
rateLimit: { max: 30, windowMs: 60_000 },
|
|
60
|
+
|
|
61
|
+
hasCreds(cfg) { return !!(cfg.botId && cfg.secret); },
|
|
62
|
+
statusFields(cfg) { return { botIdTail: cfg?.botId?.slice(-4) || '' }; },
|
|
63
|
+
|
|
64
|
+
async connect(cfg, hooks, ctx) {
|
|
65
|
+
const mod = await loadSdk();
|
|
66
|
+
const WSClient = resolveWSClient(mod);
|
|
67
|
+
const client = new WSClient({ botId: cfg.botId, secret: cfg.secret });
|
|
68
|
+
ctx.store.client = client; // single client for inbound + outbound (sendOne reads it)
|
|
69
|
+
client.on('message.text', (frame) => hooks.onInbound(normalizeInbound(frame), null));
|
|
70
|
+
// connect() is synchronous; resolve once the WS handshake authenticates so the core's
|
|
71
|
+
// `connected` flag and CONNECT_TIMEOUT_MS race are meaningful (bad creds → lastError, not a
|
|
72
|
+
// false "connected"). Reject (and tear down) on error or an internal timeout.
|
|
73
|
+
return await new Promise((resolve, reject) => {
|
|
74
|
+
let settled = false;
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
try { client.disconnect(); } catch { /* best-effort */ }
|
|
79
|
+
reject(new Error('connect timeout'));
|
|
80
|
+
}, CONNECT_PROBE_MS);
|
|
81
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
82
|
+
client.on('authenticated', () => { if (settled) return; settled = true; clearTimeout(timer); resolve(client); });
|
|
83
|
+
client.on('error', (e) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true; clearTimeout(timer);
|
|
86
|
+
// Tear down before rejecting — else the SDK keeps auto-reconnecting/retrying auth in the
|
|
87
|
+
// background even though the bridge reports not-running.
|
|
88
|
+
try { client.disconnect(); } catch { /* best-effort */ }
|
|
89
|
+
reject(new Error(String(e?.message || e)));
|
|
90
|
+
});
|
|
91
|
+
client.connect();
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async disconnect(client, ctx) {
|
|
96
|
+
try { client?.removeAllListeners?.(); } catch { /* best-effort */ }
|
|
97
|
+
try { client?.disconnect?.(); } catch { /* best-effort */ }
|
|
98
|
+
if (ctx?.store) ctx.store.client = null;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// Long-connection has no app-level inbound ACK (the SDK handles transport); rely on msgid dedup.
|
|
102
|
+
ack() { /* no-op */ },
|
|
103
|
+
|
|
104
|
+
async sendOne(cfg, target, content, ctx) {
|
|
105
|
+
const client = ctx.store.client;
|
|
106
|
+
if (!client) throw new Error('wecom client not connected');
|
|
107
|
+
const res = await client.sendMessage(target.receiveId, { msgtype: 'markdown', markdown: { content } });
|
|
108
|
+
if (res && typeof res.errcode === 'number' && res.errcode !== 0) {
|
|
109
|
+
throw new Error(`send ${res.errcode}: ${res.errmsg || 'failed'}`);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async testConnection(cfg) {
|
|
114
|
+
// No REST validate endpoint exists for the smart-robot mode — auth is in the WS handshake. Open
|
|
115
|
+
// a throwaway probe and wait for 'authenticated'. NOTE: WeCom allows one connection per bot, so
|
|
116
|
+
// testing while the live bridge is connected to the same bot briefly kicks it (the bridge then
|
|
117
|
+
// auto-reconnects). maxReconnectAttempts:0 keeps the probe from spinning a backoff loop.
|
|
118
|
+
let client;
|
|
119
|
+
try {
|
|
120
|
+
const mod = await loadSdk();
|
|
121
|
+
const WSClient = resolveWSClient(mod);
|
|
122
|
+
client = new WSClient({ botId: cfg.botId, secret: cfg.secret, maxReconnectAttempts: 0 });
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { ok: false, detail: String(e?.message || e) };
|
|
125
|
+
}
|
|
126
|
+
return await new Promise((resolve) => {
|
|
127
|
+
let settled = false;
|
|
128
|
+
const done = (res) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
try { client.removeAllListeners?.(); } catch { /* best-effort */ }
|
|
132
|
+
try { client.disconnect?.(); } catch { /* best-effort */ }
|
|
133
|
+
resolve(res);
|
|
134
|
+
};
|
|
135
|
+
const timer = setTimeout(() => done({ ok: false, detail: 'timeout' }), TEST_PROBE_MS);
|
|
136
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
137
|
+
client.on('authenticated', () => { clearTimeout(timer); done({ ok: true }); });
|
|
138
|
+
client.on('error', (e) => { clearTimeout(timer); done({ ok: false, detail: String(e?.message || e) }); });
|
|
139
|
+
try { client.connect(); } catch (e) { clearTimeout(timer); done({ ok: false, detail: String(e?.message || e) }); }
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
registerAdapter(wecomAdapter);
|
|
145
|
+
|
|
146
|
+
export default wecomAdapter;
|