@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,177 @@
|
|
|
1
|
+
const HELP_TEXT = [
|
|
2
|
+
'QQ 机器人已连接 DeepSeek Harness。',
|
|
3
|
+
'',
|
|
4
|
+
'直接发送文字即可继续当前会话。',
|
|
5
|
+
'/new 开启一个全新会话',
|
|
6
|
+
'/status 检查连接状态',
|
|
7
|
+
'/help 显示本帮助',
|
|
8
|
+
].join('\n');
|
|
9
|
+
|
|
10
|
+
function conversationKey(message) {
|
|
11
|
+
return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function safeText(message) {
|
|
15
|
+
return typeof message?.content === 'string' ? message.content.trim() : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createQqBridgeStatus() {
|
|
19
|
+
return {
|
|
20
|
+
messagesReceived: 0,
|
|
21
|
+
messagesReplied: 0,
|
|
22
|
+
messagesRejected: 0,
|
|
23
|
+
lastMessageAt: null,
|
|
24
|
+
lastReplyAt: null,
|
|
25
|
+
lastRejectedAt: null,
|
|
26
|
+
lastError: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class QqHarnessBridge {
|
|
31
|
+
#bot;
|
|
32
|
+
#ownerUserOpenid;
|
|
33
|
+
#harness;
|
|
34
|
+
#state;
|
|
35
|
+
#status;
|
|
36
|
+
#logger;
|
|
37
|
+
#replyTimeoutMs;
|
|
38
|
+
#queues = new Map();
|
|
39
|
+
|
|
40
|
+
constructor({
|
|
41
|
+
bot,
|
|
42
|
+
ownerUserOpenid,
|
|
43
|
+
harness,
|
|
44
|
+
state,
|
|
45
|
+
status = createQqBridgeStatus(),
|
|
46
|
+
logger = console,
|
|
47
|
+
replyTimeoutMs = 600_000,
|
|
48
|
+
}) {
|
|
49
|
+
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
|
|
50
|
+
if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
|
|
51
|
+
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
|
52
|
+
this.#bot = bot;
|
|
53
|
+
this.#ownerUserOpenid = ownerUserOpenid;
|
|
54
|
+
this.#harness = harness;
|
|
55
|
+
this.#state = state;
|
|
56
|
+
this.#status = status;
|
|
57
|
+
this.#logger = logger;
|
|
58
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get status() {
|
|
62
|
+
return structuredClone(this.#status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
accept(message) {
|
|
66
|
+
const key = conversationKey(message);
|
|
67
|
+
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
68
|
+
const current = previous
|
|
69
|
+
.catch(() => undefined)
|
|
70
|
+
.then(() => this.#process(message))
|
|
71
|
+
.finally(() => {
|
|
72
|
+
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
73
|
+
});
|
|
74
|
+
this.#queues.set(key, current);
|
|
75
|
+
return current;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async waitForIdle() {
|
|
79
|
+
await Promise.allSettled([...this.#queues.values()]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #process(message) {
|
|
83
|
+
const messageId = typeof message?.messageId === 'string' ? message.messageId : '';
|
|
84
|
+
const sender = typeof message?.senderId === 'string' ? message.senderId : '';
|
|
85
|
+
if (!messageId || !sender || message.senderIsBot === true) return;
|
|
86
|
+
if (!['c2c', 'group'].includes(message.kind) || this.#state.hasSeen(messageId)) return;
|
|
87
|
+
|
|
88
|
+
this.#status.messagesReceived += 1;
|
|
89
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
90
|
+
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
|
|
91
|
+
this.#status.messagesRejected += 1;
|
|
92
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
|
96
|
+
|
|
97
|
+
const target = message.replyTarget;
|
|
98
|
+
const text = safeText(message);
|
|
99
|
+
try {
|
|
100
|
+
if (!text) {
|
|
101
|
+
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
|
102
|
+
await this.#state.markSeen(messageId);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const command = text.toLowerCase();
|
|
106
|
+
if (command === '/help') {
|
|
107
|
+
await this.#bot.sendText(target, HELP_TEXT);
|
|
108
|
+
await this.#state.markSeen(messageId);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (command === '/status') {
|
|
112
|
+
await this.#harness.ensureRunning();
|
|
113
|
+
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
|
114
|
+
await this.#state.markSeen(messageId);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const key = conversationKey(message);
|
|
118
|
+
if (command === '/new') {
|
|
119
|
+
await this.#state.clearSession(key);
|
|
120
|
+
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
121
|
+
await this.#state.markSeen(messageId);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let sessionId = this.#state.sessionFor(key);
|
|
126
|
+
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
|
|
127
|
+
sessionId = await this.#harness.createSession();
|
|
128
|
+
await this.#state.setSession(key, sessionId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let stream = null;
|
|
132
|
+
let streamFinished = false;
|
|
133
|
+
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
|
134
|
+
try {
|
|
135
|
+
stream = this.#bot.openStream({ target });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const answer = await this.#harness.ask(sessionId, text, {
|
|
141
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
142
|
+
onUpdate: stream ? async (update) => {
|
|
143
|
+
const progress = update.type === 'text'
|
|
144
|
+
? update.text
|
|
145
|
+
: update.type === 'tool'
|
|
146
|
+
? `正在使用${update.name}…`
|
|
147
|
+
: update.text;
|
|
148
|
+
if (progress) await stream.update(progress);
|
|
149
|
+
} : undefined,
|
|
150
|
+
});
|
|
151
|
+
if (stream) {
|
|
152
|
+
try {
|
|
153
|
+
await stream.update(answer);
|
|
154
|
+
await stream.complete();
|
|
155
|
+
streamFinished = true;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
stream.cancel?.();
|
|
158
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!streamFinished) await this.#bot.sendText(target, answer);
|
|
162
|
+
await this.#state.markSeen(messageId);
|
|
163
|
+
this.#status.messagesReplied += 1;
|
|
164
|
+
this.#status.lastReplyAt = new Date().toISOString();
|
|
165
|
+
this.#status.lastError = null;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
168
|
+
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
|
169
|
+
try {
|
|
170
|
+
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
|
171
|
+
await this.#state.markSeen(messageId);
|
|
172
|
+
} catch (sendError) {
|
|
173
|
+
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
|
4
|
+
|
|
5
|
+
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
|
6
|
+
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
7
|
+
const QR_TTL_MS = 5 * 60_000;
|
|
8
|
+
|
|
9
|
+
function cleanString(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safeError(code, message) {
|
|
14
|
+
return Object.freeze({ code, message });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function publicAttempt(record) {
|
|
18
|
+
if (!record) return null;
|
|
19
|
+
return {
|
|
20
|
+
attemptId: record.id,
|
|
21
|
+
status: record.state,
|
|
22
|
+
qrRevision: record.qrRevision,
|
|
23
|
+
pollIntervalMs: 1_000,
|
|
24
|
+
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
|
25
|
+
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
26
|
+
...(record.botId ? { botId: record.botId } : {}),
|
|
27
|
+
...(record.error ? { error: structuredClone(record.error) } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class QqController {
|
|
32
|
+
#qrAuth;
|
|
33
|
+
#credentials;
|
|
34
|
+
#configStore;
|
|
35
|
+
#createRuntime;
|
|
36
|
+
#deleteState;
|
|
37
|
+
#logger;
|
|
38
|
+
#runtimes = new Map();
|
|
39
|
+
#errors = new Map();
|
|
40
|
+
#attempts = new Map();
|
|
41
|
+
#activeAttemptId = null;
|
|
42
|
+
#transitions = new Map();
|
|
43
|
+
#revision = 0;
|
|
44
|
+
#closed = false;
|
|
45
|
+
|
|
46
|
+
constructor({
|
|
47
|
+
qrAuth,
|
|
48
|
+
credentials,
|
|
49
|
+
configStore,
|
|
50
|
+
createRuntime,
|
|
51
|
+
deleteState = async () => {},
|
|
52
|
+
logger = console,
|
|
53
|
+
}) {
|
|
54
|
+
if (!qrAuth || typeof qrAuth.start !== 'function') throw new TypeError('QQ QR auth is required');
|
|
55
|
+
if (!credentials || typeof credentials.resolve !== 'function'
|
|
56
|
+
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
|
57
|
+
throw new TypeError('QqController requires the DSH credential provider');
|
|
58
|
+
}
|
|
59
|
+
if (!configStore || typeof configStore.list !== 'function'
|
|
60
|
+
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
61
|
+
throw new TypeError('QqController requires a config store');
|
|
62
|
+
}
|
|
63
|
+
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
64
|
+
this.#qrAuth = qrAuth;
|
|
65
|
+
this.#credentials = credentials;
|
|
66
|
+
this.#configStore = configStore;
|
|
67
|
+
this.#createRuntime = createRuntime;
|
|
68
|
+
this.#deleteState = deleteState;
|
|
69
|
+
this.#logger = logger;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async initialize() {
|
|
73
|
+
if (this.#closed) return this.status();
|
|
74
|
+
for (const config of this.#configStore.list()) {
|
|
75
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
76
|
+
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
|
77
|
+
const appSecret = await this.#resolveSecret(config.secretRef);
|
|
78
|
+
if (!appSecret) {
|
|
79
|
+
this.#errors.set(config.botId, safeError('missing-secret', 'QQ 机器人凭据缺失,请移除后重新扫码。'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await this.#startRuntime(config, appSecret);
|
|
84
|
+
this.#errors.delete(config.botId);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this.#errors.set(config.botId, safeError('connection-failed', 'QQ 连接未就绪,插件会自动重试。'));
|
|
87
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
|
|
88
|
+
} finally {
|
|
89
|
+
this.#touch();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return this.status();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async startProvisioning() {
|
|
97
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
98
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
99
|
+
let firstQrResolve;
|
|
100
|
+
let firstQrReject;
|
|
101
|
+
const firstQr = new Promise((resolve, reject) => {
|
|
102
|
+
firstQrResolve = resolve;
|
|
103
|
+
firstQrReject = reject;
|
|
104
|
+
});
|
|
105
|
+
const record = {
|
|
106
|
+
id: randomUUID(),
|
|
107
|
+
state: 'starting',
|
|
108
|
+
createdAt: Date.now(),
|
|
109
|
+
expiresAt: null,
|
|
110
|
+
qrRevision: 0,
|
|
111
|
+
verificationUrl: null,
|
|
112
|
+
controller: new AbortController(),
|
|
113
|
+
dispose: null,
|
|
114
|
+
task: null,
|
|
115
|
+
error: null,
|
|
116
|
+
botId: null,
|
|
117
|
+
};
|
|
118
|
+
this.#attempts.set(record.id, record);
|
|
119
|
+
this.#activeAttemptId = record.id;
|
|
120
|
+
this.#touch();
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
record.dispose = this.#qrAuth.start({
|
|
124
|
+
onQrDisplayed: (url) => {
|
|
125
|
+
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
126
|
+
const verificationUrl = cleanString(url);
|
|
127
|
+
if (!verificationUrl) return;
|
|
128
|
+
record.verificationUrl = verificationUrl;
|
|
129
|
+
record.qrRevision += 1;
|
|
130
|
+
record.expiresAt = Date.now() + QR_TTL_MS;
|
|
131
|
+
record.state = 'pending';
|
|
132
|
+
this.#touch();
|
|
133
|
+
firstQrResolve();
|
|
134
|
+
},
|
|
135
|
+
onQrExpired: () => {
|
|
136
|
+
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
137
|
+
record.state = 'refreshing';
|
|
138
|
+
record.verificationUrl = null;
|
|
139
|
+
record.expiresAt = null;
|
|
140
|
+
this.#touch();
|
|
141
|
+
},
|
|
142
|
+
onSuccess: (credentials) => {
|
|
143
|
+
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
144
|
+
record.task = this.#completeProvisioning(record, credentials);
|
|
145
|
+
},
|
|
146
|
+
onFailure: (error) => {
|
|
147
|
+
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
148
|
+
record.state = 'failed';
|
|
149
|
+
record.error = safeError('qr-connect-failed', 'QQ 扫码服务暂时不可用,请重新生成二维码。');
|
|
150
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
151
|
+
this.#touch();
|
|
152
|
+
firstQrReject(error);
|
|
153
|
+
},
|
|
154
|
+
}, { signal: record.controller.signal });
|
|
155
|
+
await firstQr;
|
|
156
|
+
return publicAttempt(record);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (record.controller.signal.aborted) {
|
|
159
|
+
record.state = 'cancelled';
|
|
160
|
+
record.error = safeError('cancelled', '扫码绑定已取消。');
|
|
161
|
+
} else if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
162
|
+
record.state = 'failed';
|
|
163
|
+
record.error = safeError('qr-start-failed', '无法生成 QQ 二维码,请稍后重试。');
|
|
164
|
+
}
|
|
165
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
166
|
+
this.#touch();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
registrationStatus(attemptId) {
|
|
172
|
+
return publicAttempt(this.#attempts.get(attemptId));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async bindCredentials({ appId, appSecret } = {}) {
|
|
176
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
177
|
+
const normalizedAppId = cleanString(appId);
|
|
178
|
+
const normalizedSecret = cleanString(appSecret);
|
|
179
|
+
if (!normalizedAppId || !normalizedSecret) {
|
|
180
|
+
throw new TypeError('QQ AppID and AppSecret are required');
|
|
181
|
+
}
|
|
182
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
183
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
184
|
+
const identity = deriveQqBotIdentity(normalizedAppId);
|
|
185
|
+
await this.#withBotTransition(identity.botId, async () => {
|
|
186
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
187
|
+
const previousConfig = this.#configStore.getByAppId(normalizedAppId);
|
|
188
|
+
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
189
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
190
|
+
const config = {
|
|
191
|
+
botId: identity.botId,
|
|
192
|
+
appId: normalizedAppId,
|
|
193
|
+
secretRef: identity.secretRef,
|
|
194
|
+
ownerUserOpenid: previousConfig?.ownerUserOpenid ?? '*',
|
|
195
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
196
|
+
connectedAt: new Date().toISOString(),
|
|
197
|
+
};
|
|
198
|
+
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
|
199
|
+
try {
|
|
200
|
+
await this.#configStore.save(config);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await this.#startRuntime(config, normalizedSecret);
|
|
207
|
+
this.#errors.delete(identity.botId);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。'));
|
|
210
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
|
|
211
|
+
}
|
|
212
|
+
this.#touch();
|
|
213
|
+
});
|
|
214
|
+
return this.status();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async cancelProvisioning(attemptId) {
|
|
218
|
+
const record = this.#attempts.get(attemptId);
|
|
219
|
+
if (!record) return null;
|
|
220
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
221
|
+
record.controller.abort();
|
|
222
|
+
record.dispose?.();
|
|
223
|
+
await record.task?.catch(() => undefined);
|
|
224
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
|
225
|
+
record.error ??= safeError('cancelled', '扫码绑定已取消。');
|
|
226
|
+
}
|
|
227
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
228
|
+
this.#touch();
|
|
229
|
+
return publicAttempt(record);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async reconnectBot(botId) {
|
|
233
|
+
const config = this.#configStore.get(botId);
|
|
234
|
+
if (!config) throw new Error('Unknown QQ bot');
|
|
235
|
+
await this.#withBotTransition(botId, async () => {
|
|
236
|
+
const secret = await this.#resolveSecret(config.secretRef);
|
|
237
|
+
if (!secret) throw new Error('QQ bot secret is missing');
|
|
238
|
+
try {
|
|
239
|
+
await this.#startRuntime(config, secret);
|
|
240
|
+
this.#errors.delete(botId);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
this.#errors.set(botId, safeError('connection-failed', 'QQ 连接仍未就绪,请稍后重试。'));
|
|
243
|
+
throw error;
|
|
244
|
+
} finally {
|
|
245
|
+
this.#touch();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return this.status();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async deleteBot(botId) {
|
|
252
|
+
const config = this.#configStore.get(botId);
|
|
253
|
+
if (!config) throw new Error('Unknown QQ bot');
|
|
254
|
+
await this.#withBotTransition(botId, async () => {
|
|
255
|
+
const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
|
|
256
|
+
await this.#stopRuntime(botId);
|
|
257
|
+
try {
|
|
258
|
+
await this.#credentials.unset(config.secretRef);
|
|
259
|
+
await this.#configStore.remove(botId);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (previous?.value) {
|
|
262
|
+
await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined);
|
|
263
|
+
await this.#startRuntime(config, previous.value).catch(() => undefined);
|
|
264
|
+
}
|
|
265
|
+
throw new Error('Unable to remove the QQ bot safely.', { cause: error });
|
|
266
|
+
}
|
|
267
|
+
await this.#deleteState({ botId, config }).catch((error) => {
|
|
268
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${botId} state cleanup failed:`, error);
|
|
269
|
+
});
|
|
270
|
+
this.#errors.delete(botId);
|
|
271
|
+
this.#touch();
|
|
272
|
+
});
|
|
273
|
+
return this.status();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
status() {
|
|
277
|
+
const bots = this.#configStore.list().map((config) => {
|
|
278
|
+
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
279
|
+
const connected = runtimeStatus?.ready === true
|
|
280
|
+
&& runtimeStatus.qqConnectionState === 'connected'
|
|
281
|
+
&& runtimeStatus.harnessReachable === true;
|
|
282
|
+
const state = connected ? 'connected'
|
|
283
|
+
: runtimeStatus?.qqConnectionState === 'connecting' ? 'connecting'
|
|
284
|
+
: this.#errors.has(config.botId) || runtimeStatus?.qqConnectionState === 'failed'
|
|
285
|
+
? 'error' : 'offline';
|
|
286
|
+
return {
|
|
287
|
+
botId: config.botId,
|
|
288
|
+
state,
|
|
289
|
+
connected,
|
|
290
|
+
configured: true,
|
|
291
|
+
bot: { name: 'QQ机器人', appIdMasked: maskQqAppId(config.appId) },
|
|
292
|
+
health: {
|
|
293
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
294
|
+
summary: connected ? 'QQ WebSocket 长连接运行正常'
|
|
295
|
+
: state === 'error' ? 'QQ 连接未就绪,插件会自动重试' : 'QQ 连接当前离线',
|
|
296
|
+
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
297
|
+
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
298
|
+
},
|
|
299
|
+
stats: {
|
|
300
|
+
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
301
|
+
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
302
|
+
},
|
|
303
|
+
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
307
|
+
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: 1,
|
|
310
|
+
revision: this.#revision,
|
|
311
|
+
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
|
|
312
|
+
: bots.length === 0 ? 'disconnected'
|
|
313
|
+
: connectedCount === bots.length ? 'connected'
|
|
314
|
+
: connectedCount > 0 ? 'degraded' : 'offline',
|
|
315
|
+
bots,
|
|
316
|
+
totals: { configured: bots.length, connected: connectedCount },
|
|
317
|
+
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
318
|
+
? { provisioning: publicAttempt(active) } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async close() {
|
|
323
|
+
if (this.#closed) return;
|
|
324
|
+
this.#closed = true;
|
|
325
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
326
|
+
await Promise.allSettled([...this.#transitions.values()]);
|
|
327
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async #completeProvisioning(record, credentials) {
|
|
331
|
+
try {
|
|
332
|
+
record.state = 'connecting';
|
|
333
|
+
record.verificationUrl = null;
|
|
334
|
+
record.expiresAt = null;
|
|
335
|
+
this.#touch();
|
|
336
|
+
const credential = Array.isArray(credentials) ? credentials[0] : null;
|
|
337
|
+
const appId = cleanString(credential?.appId);
|
|
338
|
+
const appSecret = cleanString(credential?.appSecret);
|
|
339
|
+
const ownerUserOpenid = cleanString(credential?.userOpenid);
|
|
340
|
+
if (!appId || !appSecret || !ownerUserOpenid) {
|
|
341
|
+
throw new Error('QQ authorization returned incomplete credentials');
|
|
342
|
+
}
|
|
343
|
+
record.botId = await this.#activateBot(record, { appId, appSecret, ownerUserOpenid });
|
|
344
|
+
record.state = 'connected';
|
|
345
|
+
record.error = null;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (record.controller.signal.aborted) {
|
|
348
|
+
record.state = 'cancelled';
|
|
349
|
+
record.error = safeError('cancelled', '扫码绑定已取消。');
|
|
350
|
+
} else {
|
|
351
|
+
record.state = 'failed';
|
|
352
|
+
record.error = safeError('activation-failed', 'QQ 已授权,但无法安全保存接入配置。');
|
|
353
|
+
this.#logger.error?.('[dsh-im:qq] provisioning failed:', error);
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
record.dispose?.();
|
|
357
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
358
|
+
this.#touch();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async #activateBot(record, { appId, appSecret, ownerUserOpenid }) {
|
|
363
|
+
const identity = deriveQqBotIdentity(appId);
|
|
364
|
+
const previousConfig = this.#configStore.getByAppId(appId);
|
|
365
|
+
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
366
|
+
const config = {
|
|
367
|
+
botId: identity.botId,
|
|
368
|
+
appId,
|
|
369
|
+
secretRef: identity.secretRef,
|
|
370
|
+
ownerUserOpenid,
|
|
371
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
372
|
+
connectedAt: new Date().toISOString(),
|
|
373
|
+
};
|
|
374
|
+
return this.#withBotTransition(identity.botId, async () => {
|
|
375
|
+
await this.#credentials.set(identity.secretRef, appSecret);
|
|
376
|
+
try {
|
|
377
|
+
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
|
378
|
+
await this.#configStore.save(config);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
|
385
|
+
await this.#startRuntime(config, appSecret);
|
|
386
|
+
this.#errors.delete(identity.botId);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (record.controller.signal.aborted) {
|
|
389
|
+
await this.#stopRuntime(identity.botId);
|
|
390
|
+
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
391
|
+
else await this.#configStore.remove(identity.botId).catch(() => undefined);
|
|
392
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。'));
|
|
396
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
|
|
397
|
+
}
|
|
398
|
+
this.#touch();
|
|
399
|
+
return identity.botId;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async #startRuntime(config, appSecret) {
|
|
404
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
405
|
+
await this.#stopRuntime(config.botId);
|
|
406
|
+
if (this.#closed) throw new Error('QQ controller is closed');
|
|
407
|
+
const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret });
|
|
408
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
409
|
+
throw new TypeError('createRuntime returned an invalid QQ runtime');
|
|
410
|
+
}
|
|
411
|
+
this.#runtimes.set(config.botId, runtime);
|
|
412
|
+
try {
|
|
413
|
+
await runtime.start();
|
|
414
|
+
} catch (error) {
|
|
415
|
+
await runtime.stop().catch(() => undefined);
|
|
416
|
+
this.#runtimes.delete(config.botId);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async #stopRuntime(botId) {
|
|
422
|
+
const runtime = this.#runtimes.get(botId);
|
|
423
|
+
this.#runtimes.delete(botId);
|
|
424
|
+
await runtime?.stop().catch((error) => {
|
|
425
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${botId} failed to stop cleanly:`, error);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async #resolveSecret(ref) {
|
|
430
|
+
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
|
431
|
+
return cleanString(result?.value);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async #restoreCredential(ref, previous) {
|
|
435
|
+
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
|
436
|
+
else await this.#credentials.unset(ref).catch(() => undefined);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#withBotTransition(botId, operation) {
|
|
440
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
441
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
442
|
+
const settled = current.finally(() => {
|
|
443
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
444
|
+
});
|
|
445
|
+
this.#transitions.set(botId, settled);
|
|
446
|
+
return settled;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#touch() {
|
|
450
|
+
this.#revision += 1;
|
|
451
|
+
}
|
|
452
|
+
}
|