@sidleo3/dsh-chat-weixin 0.0.4
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/client/index.js +605 -0
- package/cordis.patch.yml +5 -0
- package/host/config-store.mjs +115 -0
- package/host/controller.mjs +434 -0
- package/host/ilink-client.mjs +623 -0
- package/host/index.mjs +59 -0
- package/host/media.mjs +408 -0
- package/host/runtime.mjs +577 -0
- package/host/state-store.mjs +146 -0
- package/lib/client.js +631 -0
- package/lib/index.js +1939 -0
- package/package.json +51 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信账号配置存储。
|
|
3
|
+
*
|
|
4
|
+
* 沿用 dsh-im 的 `~/.dsh/integrations/dsh-weixin/config.json`(version 1)
|
|
5
|
+
* 与凭据引用(`tokenRef` 指向 DSH 凭据服务里的登录令牌),因此现有账号零重扫。
|
|
6
|
+
* 落盘纪律由 hub 提供的 `deps.createJsonStore` 统一负责(原子写/备份/串行队列)。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-chat-weixin/config-store
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ACCOUNT_ID = /^[A-Za-z0-9_@.:+-]{1,128}$/;
|
|
12
|
+
const TOKEN_REF = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
const FALLBACK_BASE_URL = 'https://ilinkai.weixin.qq.com/';
|
|
14
|
+
|
|
15
|
+
function cleanString(value) {
|
|
16
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 归一化一个账号条目。
|
|
21
|
+
*
|
|
22
|
+
* @param value - 磁盘上的账号。
|
|
23
|
+
* @returns 冻结的账号配置,或 null(信息不足)。
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeAccount(value) {
|
|
26
|
+
if (!value || typeof value !== 'object') return null;
|
|
27
|
+
const botId = cleanString(value.botId);
|
|
28
|
+
const accountId = cleanString(value.accountId);
|
|
29
|
+
const tokenRef = cleanString(value.tokenRef);
|
|
30
|
+
const ownerUserId = cleanString(value.ownerUserId);
|
|
31
|
+
if (!botId || !ACCOUNT_ID.test(botId)) return null;
|
|
32
|
+
if (!accountId || !ACCOUNT_ID.test(accountId)) return null;
|
|
33
|
+
if (!tokenRef || !TOKEN_REF.test(tokenRef)) return null;
|
|
34
|
+
if (!ownerUserId) return null;
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
botId,
|
|
37
|
+
accountId,
|
|
38
|
+
tokenRef,
|
|
39
|
+
ownerUserId,
|
|
40
|
+
baseUrl: cleanString(value.baseUrl) ?? FALLBACK_BASE_URL,
|
|
41
|
+
botName: cleanString(value.botName),
|
|
42
|
+
createdAt: cleanString(value.createdAt),
|
|
43
|
+
connectedAt: cleanString(value.connectedAt),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeDocument(value) {
|
|
48
|
+
const source = value && typeof value === 'object' && Array.isArray(value.accounts) ? value : null;
|
|
49
|
+
if (!source) return { version: 1, accounts: [] };
|
|
50
|
+
const accounts = source.accounts.map((account) => normalizeAccount(account));
|
|
51
|
+
if (accounts.some((account) => account === null)) {
|
|
52
|
+
throw new Error('dsh-weixin config.json 含无法识别的账号条目');
|
|
53
|
+
}
|
|
54
|
+
return { version: 1, accounts };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 创建账号配置存储。
|
|
59
|
+
*
|
|
60
|
+
* @param options - { path, createJsonStore }。
|
|
61
|
+
* @returns 存储 API。
|
|
62
|
+
*/
|
|
63
|
+
export function createWeixinConfigStore({ path, createJsonStore }) {
|
|
64
|
+
if (typeof createJsonStore !== 'function') {
|
|
65
|
+
throw new TypeError('微信配置存储需要 hub 提供的 createJsonStore。');
|
|
66
|
+
}
|
|
67
|
+
const store = createJsonStore({
|
|
68
|
+
path,
|
|
69
|
+
normalize: normalizeDocument,
|
|
70
|
+
empty: () => ({ version: 1, accounts: [] }),
|
|
71
|
+
label: '微信账号配置',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
path,
|
|
76
|
+
ready: () => store.ready(),
|
|
77
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
78
|
+
|
|
79
|
+
/** @returns 全部账号。 */
|
|
80
|
+
list() {
|
|
81
|
+
return Object.freeze([...(store.snapshot().accounts ?? [])]);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** @returns 指定账号,未配置时 undefined。 */
|
|
85
|
+
get(botId) {
|
|
86
|
+
return store.snapshot().accounts.find((account) => account.botId === botId);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** 追加或覆盖一个账号。 */
|
|
90
|
+
async saveAccount(account) {
|
|
91
|
+
const normalized = normalizeAccount(account);
|
|
92
|
+
if (!normalized) throw new TypeError('微信账号信息不完整(botId/accountId/tokenRef/ownerUserId 必填)。');
|
|
93
|
+
await store.update((current) => {
|
|
94
|
+
const accounts = [...current.accounts];
|
|
95
|
+
const index = accounts.findIndex((item) => item.botId === normalized.botId);
|
|
96
|
+
if (index >= 0) accounts[index] = normalized;
|
|
97
|
+
else accounts.push(normalized);
|
|
98
|
+
return { version: 1, accounts };
|
|
99
|
+
});
|
|
100
|
+
return normalized;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** 删除一个账号。 */
|
|
104
|
+
async removeAccount(botId) {
|
|
105
|
+
let removed = false;
|
|
106
|
+
await store.update((current) => {
|
|
107
|
+
const accounts = current.accounts.filter((account) => account.botId !== botId);
|
|
108
|
+
if (accounts.length === current.accounts.length) return null;
|
|
109
|
+
removed = true;
|
|
110
|
+
return { version: 1, accounts };
|
|
111
|
+
});
|
|
112
|
+
return removed;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信渠道控制器:账号生命周期(扫码登录、连接、状态)与渠道 RPC 端点。
|
|
3
|
+
*
|
|
4
|
+
* `botId` / `tokenRef` 用与上游相同的推导(sha256(accountId) 前 24 位十六进制),
|
|
5
|
+
* 因此现有账号零重扫:同一条凭据引用会被原样读到。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-chat-weixin/controller
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { createWeixinConfigStore } from './config-store.mjs';
|
|
14
|
+
import { createIlinkClient } from './ilink-client.mjs';
|
|
15
|
+
import { createWeixinRuntime } from './runtime.mjs';
|
|
16
|
+
import { createWeixinStateStore } from './state-store.mjs';
|
|
17
|
+
|
|
18
|
+
/** 扫码尝试的有效期。 */
|
|
19
|
+
const LOGIN_TTL_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 由 accountId 推导 botId 与凭据引用名(与上游一致,保证零重扫)。
|
|
23
|
+
*
|
|
24
|
+
* @param accountId - iLink 返回的 `ilink_bot_id`。
|
|
25
|
+
* @returns { botId, tokenRef }。
|
|
26
|
+
*/
|
|
27
|
+
export function deriveIdentity(accountId) {
|
|
28
|
+
const raw = typeof accountId === 'string' ? accountId.trim() : '';
|
|
29
|
+
if (!raw) throw new TypeError('deriveIdentity 需要 accountId。');
|
|
30
|
+
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
31
|
+
return { botId: `wx_${digest}`, tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest.toUpperCase()}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function maskAccountId(accountId) {
|
|
35
|
+
const raw = typeof accountId === 'string' ? accountId : '';
|
|
36
|
+
if (raw.length <= 8) return '****';
|
|
37
|
+
return `${raw.slice(0, 4)}****${raw.slice(-4)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 服务端可能把 API 基址重定向到别的微信域名;只接受微信自己的域名。 */
|
|
41
|
+
function apiBaseFromServer(value, fallback) {
|
|
42
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
43
|
+
if (!raw) return fallback;
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(raw);
|
|
46
|
+
if (url.protocol !== 'https:') return fallback;
|
|
47
|
+
const host = url.hostname.toLowerCase();
|
|
48
|
+
if (!host.endsWith('weixin.qq.com') && !host.endsWith('wechat.com')) return fallback;
|
|
49
|
+
return url.toString();
|
|
50
|
+
} catch {
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveToken(credentials, ref) {
|
|
56
|
+
if (typeof credentials?.resolve !== 'function') {
|
|
57
|
+
throw new Error('当前 Host 未提供凭据服务,无法读取微信登录令牌。');
|
|
58
|
+
}
|
|
59
|
+
const resolved = await credentials.resolve(ref);
|
|
60
|
+
if (!resolved?.value) {
|
|
61
|
+
const error = new Error('微信登录令牌缺失,请在设置页重新扫码。');
|
|
62
|
+
error.code = 'weixin/token-missing';
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
return resolved.value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 创建微信渠道控制器。
|
|
70
|
+
*
|
|
71
|
+
* @param options - { deps, logger, config, internals }。
|
|
72
|
+
* @returns 控制器。
|
|
73
|
+
*/
|
|
74
|
+
export function createWeixinController({ deps, logger = console, config = {}, internals = {} }) {
|
|
75
|
+
const dataDir = deps.dataDir;
|
|
76
|
+
if (typeof dataDir !== 'string' || !dataDir) throw new TypeError('微信控制器需要 deps.dataDir。');
|
|
77
|
+
if (typeof deps.sessions?.ask !== 'function' || typeof deps.contextEnhancement?.enhanceContent !== 'function') {
|
|
78
|
+
throw new TypeError('微信控制器需要 hub 的 sessions.ask 与 contextEnhancement(请确认 dsh-chat 已加载)。');
|
|
79
|
+
}
|
|
80
|
+
if (typeof deps.createJsonStore !== 'function') {
|
|
81
|
+
throw new TypeError('微信控制器需要 hub 的 createJsonStore。');
|
|
82
|
+
}
|
|
83
|
+
const clientFactory = internals.createClient ?? createIlinkClient;
|
|
84
|
+
|
|
85
|
+
const configStore = createWeixinConfigStore({
|
|
86
|
+
path: join(dataDir, 'config.json'),
|
|
87
|
+
createJsonStore: deps.createJsonStore,
|
|
88
|
+
});
|
|
89
|
+
/** @type {Map<string, object>} botId → 运行记录 */
|
|
90
|
+
const runtimes = new Map();
|
|
91
|
+
/** @type {Map<string, object>} attemptId → 扫码尝试 */
|
|
92
|
+
const attempts = new Map();
|
|
93
|
+
|
|
94
|
+
function newClient() {
|
|
95
|
+
return clientFactory({ fetchImpl: internals.fetchImpl });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function startAccount(account) {
|
|
99
|
+
const existing = runtimes.get(account.botId);
|
|
100
|
+
if (existing && ['starting', 'running', 'reconnecting'].includes(existing.phase)) return existing;
|
|
101
|
+
const record = {
|
|
102
|
+
account,
|
|
103
|
+
phase: 'starting',
|
|
104
|
+
error: null,
|
|
105
|
+
runtime: null,
|
|
106
|
+
controller: new AbortController(),
|
|
107
|
+
};
|
|
108
|
+
runtimes.set(account.botId, record);
|
|
109
|
+
try {
|
|
110
|
+
const token = await resolveToken(deps.credentials, account.tokenRef);
|
|
111
|
+
const client = newClient();
|
|
112
|
+
const state = createWeixinStateStore({
|
|
113
|
+
path: join(dataDir, 'accounts', account.botId, 'state.json'),
|
|
114
|
+
createJsonStore: deps.createJsonStore,
|
|
115
|
+
});
|
|
116
|
+
await state.ready();
|
|
117
|
+
if (deps.sessions?.bindings?.adopt) {
|
|
118
|
+
await deps.sessions.bindings.adopt(deps.channelId, account.botId, state.sessions());
|
|
119
|
+
}
|
|
120
|
+
const runtime = createWeixinRuntime({
|
|
121
|
+
account, token, deps, client, state, logger,
|
|
122
|
+
});
|
|
123
|
+
record.runtime = runtime;
|
|
124
|
+
record.state = state;
|
|
125
|
+
// 长轮询会一直跑在后台;失败在运行时内部处理(重试/令牌失效)。
|
|
126
|
+
void runtime.start({ signal: record.controller.signal }).catch((error) => {
|
|
127
|
+
record.phase = 'failed';
|
|
128
|
+
record.error = error?.code ?? 'weixin/runtime-failed';
|
|
129
|
+
record.errorMessage = error?.message ?? String(error);
|
|
130
|
+
logger.error?.(`[dsh-chat-weixin] ${account.botId} 运行失败:${record.errorMessage}`);
|
|
131
|
+
});
|
|
132
|
+
record.phase = 'running';
|
|
133
|
+
record.error = null;
|
|
134
|
+
logger.info?.(`[dsh-chat-weixin] ${account.botName ?? maskAccountId(account.accountId)} 长轮询已启动`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
record.phase = 'failed';
|
|
137
|
+
record.error = typeof error?.code === 'string' ? error.code : 'weixin/start-failed';
|
|
138
|
+
record.errorMessage = error?.message ?? String(error);
|
|
139
|
+
logger.error?.(`[dsh-chat-weixin] ${account.botId} 启动失败:${record.errorMessage}`);
|
|
140
|
+
}
|
|
141
|
+
return record;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function stopAccount(botId) {
|
|
145
|
+
const record = runtimes.get(botId);
|
|
146
|
+
if (!record) return;
|
|
147
|
+
record.controller?.abort?.();
|
|
148
|
+
try {
|
|
149
|
+
await record.runtime?.stop?.(record.controller.signal);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
logger.warn?.(`[dsh-chat-weixin] ${botId} 停止时报错:${error?.message ?? error}`);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await record.state?.flush?.();
|
|
155
|
+
} catch {
|
|
156
|
+
// 状态落盘失败不影响停机。
|
|
157
|
+
}
|
|
158
|
+
record.runtime = null;
|
|
159
|
+
if (record.phase !== 'failed') record.phase = 'stopped';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function accountStatus(record) {
|
|
163
|
+
const runtime = record.runtime?.status?.() ?? {};
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
botId: record.account.botId,
|
|
166
|
+
accountIdMasked: maskAccountId(record.account.accountId),
|
|
167
|
+
botName: record.account.botName ?? null,
|
|
168
|
+
// 规范化字段(契约要求):hub 的机器人列表按这几个键渲染。
|
|
169
|
+
name: record.account.botName ?? null,
|
|
170
|
+
state: runtime.phase ?? record.phase,
|
|
171
|
+
error: record.error ?? null,
|
|
172
|
+
errorMessage: record.errorMessage ?? runtime.error ?? null,
|
|
173
|
+
handled: runtime.handled ?? 0,
|
|
174
|
+
lastHandledAt: runtime.lastHandledAt ?? null,
|
|
175
|
+
lastMessageAt: runtime.lastMessageAt ?? null,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function pruneAttempts() {
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
for (const [id, attempt] of attempts) {
|
|
182
|
+
if (now - attempt.createdAt > LOGIN_TTL_MS) attempts.delete(id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function status() {
|
|
187
|
+
await configStore.ready();
|
|
188
|
+
const accounts = Object.freeze(configStore.list().map((account) => accountStatus(
|
|
189
|
+
runtimes.get(account.botId) ?? { account, phase: 'stopped', runtime: null },
|
|
190
|
+
)));
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
channel: deps.channelId,
|
|
193
|
+
dataDir,
|
|
194
|
+
// `bots` 是契约里的规范化名单(hub 的机器人列表按它渲染);`accounts` 保留给老代码。
|
|
195
|
+
bots: accounts,
|
|
196
|
+
accounts,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function startAll() {
|
|
201
|
+
await configStore.ready();
|
|
202
|
+
const accounts = configStore.list();
|
|
203
|
+
logger.info?.(`[dsh-chat-weixin] 发现 ${accounts.length} 个已绑定账号`);
|
|
204
|
+
await Promise.all(accounts.map((account) => startAccount(account)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 会话键 → 可投递目标(本渠道仅私聊,`group:` 一律不认)。
|
|
209
|
+
*
|
|
210
|
+
* 同一份翻译两处用:`discover`(运行时状态)与 `targetFromKey`(hub 的**持久**会话绑定表)。
|
|
211
|
+
* 只做前者的话,重启后运行时是空的,设置页就一个可添加的候选都没有。
|
|
212
|
+
*/
|
|
213
|
+
function targetFromKey(key) {
|
|
214
|
+
const value = String(key ?? '');
|
|
215
|
+
if (!value.startsWith('p2p:')) return null;
|
|
216
|
+
const userId = value.slice(4);
|
|
217
|
+
if (!userId) return null;
|
|
218
|
+
const short = userId.length > 12 ? `${userId.slice(0, 6)}…${userId.slice(-4)}` : userId;
|
|
219
|
+
return {
|
|
220
|
+
id: value.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64),
|
|
221
|
+
name: `私聊 · ${short}`,
|
|
222
|
+
kind: 'direct',
|
|
223
|
+
route: { userId },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const delivery = Object.freeze({
|
|
228
|
+
/** 主动发文本:私聊对端就是 `from_user_id`,回复要带该用户最近一次的 context_token。 */
|
|
229
|
+
async send({ botId, target, text }) {
|
|
230
|
+
const record = runtimes.get(botId);
|
|
231
|
+
if (!record?.runtime || record.phase !== 'running') {
|
|
232
|
+
const error = new Error(`账号 ${botId} 当前不在线,无法投递。`);
|
|
233
|
+
error.code = 'weixin/account-offline';
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const userId = target.route?.userId;
|
|
237
|
+
if (!userId) {
|
|
238
|
+
const error = new Error('投递目标的 route 缺少 userId。');
|
|
239
|
+
error.code = 'chat/bad-target';
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
return record.runtime.sendProactive({ userId, text });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 主动发一个文件或图片(`delivery.sendFile`)。
|
|
247
|
+
*
|
|
248
|
+
* 与文本同一条安全边界:只能发给**已保存**的目标(hub 已校验),这里只确认账号在线、
|
|
249
|
+
* 目标带得上 userId,然后把"路径 + 显示名 + kind"交给运行时去读字节并发送。
|
|
250
|
+
*/
|
|
251
|
+
async sendFile({ botId, target, file }) {
|
|
252
|
+
const record = runtimes.get(botId);
|
|
253
|
+
if (!record?.runtime || record.phase !== 'running') {
|
|
254
|
+
const error = new Error(`账号 ${botId} 当前不在线,无法投递。`);
|
|
255
|
+
error.code = 'weixin/account-offline';
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
const userId = target.route?.userId;
|
|
259
|
+
if (!userId) {
|
|
260
|
+
const error = new Error('投递目标的 route 缺少 userId。');
|
|
261
|
+
error.code = 'chat/bad-target';
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
return record.runtime.sendFileProactive({
|
|
265
|
+
userId,
|
|
266
|
+
path: file.path,
|
|
267
|
+
name: file.name,
|
|
268
|
+
kind: file.kind,
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
/** 从该账号的会话记录里发现候选目标。 */
|
|
273
|
+
async discover({ botId }) {
|
|
274
|
+
const record = runtimes.get(botId);
|
|
275
|
+
if (!record?.state) return [];
|
|
276
|
+
return Object.keys(record.state.sessions?.() ?? {})
|
|
277
|
+
.map((key) => targetFromKey(key))
|
|
278
|
+
.filter(Boolean);
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/** 把 hub 持久会话绑定表里的会话键翻成目标(重启后仍有候选)。 */
|
|
282
|
+
targetFromKey,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
start: startAll,
|
|
287
|
+
delivery,
|
|
288
|
+
async stop() {
|
|
289
|
+
await Promise.all([...runtimes.keys()].map((botId) => stopAccount(botId)));
|
|
290
|
+
},
|
|
291
|
+
status,
|
|
292
|
+
|
|
293
|
+
endpoints: Object.freeze({
|
|
294
|
+
'connection.status': async () => ({ ok: true, value: await status() }),
|
|
295
|
+
|
|
296
|
+
/** 申请登录二维码。 */
|
|
297
|
+
'login.begin': async () => {
|
|
298
|
+
try {
|
|
299
|
+
const client = newClient();
|
|
300
|
+
const known = configStore.list().map((account) => account.accountId);
|
|
301
|
+
const { qrcode, qrcodeUrl } = await client.beginLogin({ localTokens: known });
|
|
302
|
+
pruneAttempts();
|
|
303
|
+
const attemptId = randomUUID();
|
|
304
|
+
attempts.set(attemptId, {
|
|
305
|
+
qrcode, createdAt: Date.now(), baseUrl: config.connectBaseUrl,
|
|
306
|
+
});
|
|
307
|
+
return { ok: true, value: { attemptId, qrcodeUrl, expiresInMs: LOGIN_TTL_MS } };
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: {
|
|
312
|
+
code: error?.code ?? 'weixin/qr-failed',
|
|
313
|
+
message: error?.message ?? '申请二维码失败。',
|
|
314
|
+
details: {},
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 轮询扫码状态;确认后落盘账号并启动长轮询。
|
|
322
|
+
*
|
|
323
|
+
* @param payload - { attemptId, verifyCode? }。
|
|
324
|
+
*/
|
|
325
|
+
'login.poll': async (payload) => {
|
|
326
|
+
const attempt = attempts.get(payload?.attemptId);
|
|
327
|
+
if (!attempt) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
error: { code: 'weixin/unknown-attempt', message: '登录尝试已失效,请重新生成二维码。', details: {} },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const client = newClient();
|
|
335
|
+
const response = await client.pollLogin({
|
|
336
|
+
qrcode: attempt.qrcode,
|
|
337
|
+
baseUrl: attempt.baseUrl,
|
|
338
|
+
verifyCode: payload?.verifyCode,
|
|
339
|
+
});
|
|
340
|
+
const statusValue = response.status;
|
|
341
|
+
if (statusValue === 'scaned_but_redirect') {
|
|
342
|
+
attempt.baseUrl = apiBaseFromServer(response.redirect_host, attempt.baseUrl);
|
|
343
|
+
}
|
|
344
|
+
if (statusValue !== 'confirmed') {
|
|
345
|
+
if (statusValue === 'expired' || statusValue === 'verify_code_blocked') {
|
|
346
|
+
attempts.delete(payload.attemptId);
|
|
347
|
+
}
|
|
348
|
+
return { ok: true, value: { status: statusValue } };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const accountId = typeof response.ilink_bot_id === 'string' ? response.ilink_bot_id.trim() : '';
|
|
352
|
+
const ownerUserId = typeof response.ilink_user_id === 'string' ? response.ilink_user_id.trim() : '';
|
|
353
|
+
const token = typeof response.bot_token === 'string' ? response.bot_token.trim() : '';
|
|
354
|
+
if (!accountId || !ownerUserId || !token) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: { code: 'weixin/incomplete-login', message: '微信授权成功但返回的凭据不完整。', details: {} },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const identity = deriveIdentity(accountId);
|
|
361
|
+
await deps.credentials.set(identity.tokenRef, token);
|
|
362
|
+
const account = await configStore.saveAccount({
|
|
363
|
+
...identity,
|
|
364
|
+
accountId,
|
|
365
|
+
ownerUserId,
|
|
366
|
+
baseUrl: apiBaseFromServer(response.baseurl, attempt.baseUrl),
|
|
367
|
+
botName: typeof response.nickname === 'string' ? response.nickname : null,
|
|
368
|
+
createdAt: new Date().toISOString(),
|
|
369
|
+
connectedAt: new Date().toISOString(),
|
|
370
|
+
});
|
|
371
|
+
attempts.delete(payload.attemptId);
|
|
372
|
+
await startAccount(account);
|
|
373
|
+
return { ok: true, value: { status: 'connected', botId: account.botId } };
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
error: {
|
|
378
|
+
code: error?.code ?? 'weixin/login-poll-failed',
|
|
379
|
+
message: error?.message ?? '查询扫码状态失败。',
|
|
380
|
+
details: {},
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
/** 取消扫码。 */
|
|
387
|
+
'login.cancel': async (payload) => {
|
|
388
|
+
const existed = attempts.delete(payload?.attemptId);
|
|
389
|
+
return { ok: true, value: { cancelled: existed } };
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/** 重连某个账号。 */
|
|
393
|
+
'account.reconnect': async (payload) => {
|
|
394
|
+
if (typeof payload?.botId !== 'string' || !payload.botId) {
|
|
395
|
+
return { ok: false, error: { code: 'chat/bad-request', message: '需要 botId。', details: {} } };
|
|
396
|
+
}
|
|
397
|
+
await configStore.ready();
|
|
398
|
+
const account = configStore.get(payload.botId);
|
|
399
|
+
if (!account) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
error: { code: 'weixin/unknown-account', message: `未找到账号 ${payload.botId}。`, details: {} },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
await stopAccount(account.botId);
|
|
406
|
+
const record = await startAccount(account);
|
|
407
|
+
return { ok: true, value: accountStatus(record) };
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
/** 移除账号(配置与运行态;凭据一并清除)。 */
|
|
411
|
+
'account.delete': async (payload) => {
|
|
412
|
+
if (typeof payload?.botId !== 'string' || payload.confirm !== true) {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
error: { code: 'chat/bad-request', message: '删除需要 botId 与 confirm=true。', details: {} },
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
await configStore.ready();
|
|
419
|
+
const account = configStore.get(payload.botId);
|
|
420
|
+
await stopAccount(payload.botId);
|
|
421
|
+
runtimes.delete(payload.botId);
|
|
422
|
+
if (account) {
|
|
423
|
+
await configStore.removeAccount(payload.botId);
|
|
424
|
+
try {
|
|
425
|
+
await deps.credentials.unset(account.tokenRef);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
logger.warn?.(`[dsh-chat-weixin] 清除凭据失败:${error?.message ?? error}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { ok: true, value: { removed: Boolean(account) } };
|
|
431
|
+
},
|
|
432
|
+
}),
|
|
433
|
+
});
|
|
434
|
+
}
|