@wanghaopeng1148/deskpet 2.0.0 → 2.0.2
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/README.md +70 -16
- package/bin/deskpet.mjs +70 -2
- package/dist/node/server/db/database.js +113 -0
- package/dist/node/server/db/migrate-legacy.js +88 -0
- package/dist/node/server/db/task-repository.js +374 -0
- package/dist/node/server/http/http-server.js +493 -0
- package/dist/node/server/http/ws-hub.js +59 -0
- package/dist/node/server/main.js +291 -0
- package/dist/node/server/plugins/actions/builtin.js +67 -0
- package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
- package/dist/node/server/plugins/actions/http-request.js +41 -0
- package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
- package/dist/node/server/plugins/actions/open-app.js +41 -0
- package/dist/node/server/plugins/actions/python-script.js +180 -0
- package/dist/node/server/plugins/actions/screenshot.js +38 -0
- package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
- package/dist/node/server/plugins/actions/show-reminder.js +7 -0
- package/dist/node/server/plugins/actions/ssh-command.js +123 -0
- package/dist/node/server/plugins/actions/task-chain.js +24 -0
- package/dist/node/server/plugins/actions/volume-control.js +31 -0
- package/dist/node/server/plugins/index.js +35 -0
- package/dist/node/server/plugins/registry.js +23 -0
- package/dist/node/server/services/clipboard-watcher.js +112 -0
- package/dist/node/server/services/config-store.js +141 -0
- package/dist/node/server/services/idle-monitor.js +131 -0
- package/dist/node/server/services/notifier.js +36 -0
- package/dist/node/server/services/quick-actions-store.js +52 -0
- package/dist/node/server/services/remote-connector.js +67 -0
- package/dist/node/server/services/scanner-reader.js +217 -0
- package/dist/node/server/services/script-runner.js +228 -0
- package/dist/node/server/services/snapshot-service.js +135 -0
- package/dist/node/server/services/task-scheduler.js +813 -0
- package/dist/node/server/services/wechat-bot.js +635 -0
- package/dist/node/server/services/wechat-command-types.js +1 -0
- package/dist/node/server/services/wechat-commands.js +330 -0
- package/dist/node/server/suppress-warnings.js +12 -0
- package/dist/node/server/utils/asset-url.js +26 -0
- package/dist/node/server/utils/auto-start.js +186 -0
- package/dist/node/server/utils/clipboard.js +50 -0
- package/dist/node/server/utils/dashboard-url.js +8 -0
- package/dist/node/server/utils/instance-guard.js +165 -0
- package/dist/node/server/utils/native-notify.js +53 -0
- package/dist/node/server/utils/open.js +37 -0
- package/dist/node/server/utils/paths.js +95 -0
- package/dist/node/server/utils/python-interpreter.js +129 -0
- package/dist/node/shared/animation-engine.js +349 -0
- package/dist/node/shared/chain-condition.js +39 -0
- package/dist/node/shared/cron-weekly.js +124 -0
- package/dist/node/shared/py-task-params.js +335 -0
- package/dist/node/shared/types.js +69 -0
- package/package.json +6 -2
- package/server/http/http-server.ts +4 -1
- package/server/utils/auto-start.ts +158 -49
- package/server/utils/paths.ts +28 -1
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信 Bot — iLink Bot API 移植(对齐旧版 wechat_bot.py 44KB)
|
|
3
|
+
*
|
|
4
|
+
* 能力: 扫码登录(二维码自动刷新) / sendmessage 文本推送(自动拆条) /
|
|
5
|
+
* getupdates 长轮询(收消息+维持 context_token) / 会话过期自动重连 / 看门狗
|
|
6
|
+
*
|
|
7
|
+
* 协议要点(实测经验,勿改):
|
|
8
|
+
* - 文本项不渲染 \n,用垂直制表符 \u000b 作为换行符
|
|
9
|
+
* - ret/errcode == -14 表示会话过期,需重连
|
|
10
|
+
* - context_token 只能靠用户主动发消息刷新,机器人无法主动续期
|
|
11
|
+
*/
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
// ── 协议常量 ────────────────────────────────────────────────
|
|
15
|
+
const ILINK_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
|
16
|
+
const CHANNEL_VERSION = '2.2.0';
|
|
17
|
+
const ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0; // 131584
|
|
18
|
+
const LONG_POLL_TIMEOUT_MS = 40_000;
|
|
19
|
+
const API_TIMEOUT_MS = 15_000;
|
|
20
|
+
const QR_FETCH_TIMEOUT_MS = 30_000;
|
|
21
|
+
const QR_POLL_INTERVAL_MS = 1_000;
|
|
22
|
+
const QR_EXPIRE_MAX = 3;
|
|
23
|
+
const QR_LOGIN_TIMEOUT_MS = 300_000;
|
|
24
|
+
const MSG_MAX_CHARS = 1800;
|
|
25
|
+
const HEARTBEAT_CHECK_INTERVAL_MS = 60_000;
|
|
26
|
+
const HEARTBEAT_STALE_THRESHOLD_MS = 120_000;
|
|
27
|
+
const MAX_RECONNECT_ATTEMPTS = 3;
|
|
28
|
+
const RECONNECT_DELAY_MS = 5_000;
|
|
29
|
+
export const LOGIN_STATUS_TEXT = {
|
|
30
|
+
idle: '未连接',
|
|
31
|
+
fetching: '正在获取二维码…',
|
|
32
|
+
waiting: '请用微信扫描二维码',
|
|
33
|
+
scanned: '已扫码,请在手机上确认',
|
|
34
|
+
refreshed: '二维码已过期,已自动刷新',
|
|
35
|
+
confirmed: '已连接微信',
|
|
36
|
+
failed: '连接失败',
|
|
37
|
+
cancelled: '已取消',
|
|
38
|
+
timeout: '扫码超时,请重试'
|
|
39
|
+
};
|
|
40
|
+
function emptyCredentials() {
|
|
41
|
+
return {
|
|
42
|
+
botToken: '',
|
|
43
|
+
botId: '',
|
|
44
|
+
userId: '',
|
|
45
|
+
pushTarget: '',
|
|
46
|
+
contextTokens: {},
|
|
47
|
+
contextTokenTs: {},
|
|
48
|
+
syncBuf: '',
|
|
49
|
+
conversationConfirmed: false,
|
|
50
|
+
linkedAt: '',
|
|
51
|
+
baseUrl: ILINK_BASE_URL
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** 拆条:超长消息按段落/长度切分 */
|
|
55
|
+
export function splitMessage(text, maxChars = MSG_MAX_CHARS) {
|
|
56
|
+
if (text.length <= maxChars)
|
|
57
|
+
return [text];
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let current = '';
|
|
60
|
+
for (const line of text.split('\n')) {
|
|
61
|
+
if ((current + line + '\n').length > maxChars && current) {
|
|
62
|
+
chunks.push(current.trimEnd());
|
|
63
|
+
current = '';
|
|
64
|
+
}
|
|
65
|
+
if (line.length > maxChars) {
|
|
66
|
+
// 单行超长硬切
|
|
67
|
+
for (let i = 0; i < line.length; i += maxChars) {
|
|
68
|
+
chunks.push(line.slice(i, i + maxChars));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
current += line + '\n';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (current.trim())
|
|
76
|
+
chunks.push(current.trimEnd());
|
|
77
|
+
return chunks;
|
|
78
|
+
}
|
|
79
|
+
/** 提取文本消息内容(item_list → text_item.text) */
|
|
80
|
+
export function extractText(msg) {
|
|
81
|
+
const items = msg['item_list'];
|
|
82
|
+
if (!Array.isArray(items))
|
|
83
|
+
return '';
|
|
84
|
+
const parts = [];
|
|
85
|
+
for (const item of items) {
|
|
86
|
+
const textItem = item['text_item'];
|
|
87
|
+
const t = textItem?.['text'];
|
|
88
|
+
if (typeof t === 'string' && t.trim())
|
|
89
|
+
parts.push(t.trim());
|
|
90
|
+
}
|
|
91
|
+
return parts.join('\n');
|
|
92
|
+
}
|
|
93
|
+
export class WechatBot extends EventEmitter {
|
|
94
|
+
creds = emptyCredentials();
|
|
95
|
+
loginStatus = 'idle';
|
|
96
|
+
loginAbort = null;
|
|
97
|
+
listenStop = false;
|
|
98
|
+
listening = false;
|
|
99
|
+
sessionAlive = false;
|
|
100
|
+
lastPollAt = 0;
|
|
101
|
+
reconnectAttempts = 0;
|
|
102
|
+
watchdog = null;
|
|
103
|
+
commandHandler = null;
|
|
104
|
+
/** 当前登录二维码内容(供 UI 渲染) */
|
|
105
|
+
qrContent = null;
|
|
106
|
+
fetchImpl;
|
|
107
|
+
credsPath;
|
|
108
|
+
getSettings;
|
|
109
|
+
constructor(credsPath, getSettings, fetchImpl) {
|
|
110
|
+
super();
|
|
111
|
+
this.credsPath = credsPath;
|
|
112
|
+
this.getSettings = getSettings;
|
|
113
|
+
this.fetchImpl = fetchImpl ?? fetch;
|
|
114
|
+
this.load();
|
|
115
|
+
}
|
|
116
|
+
// ── 凭据持久化 ──────────────────────────────────────────
|
|
117
|
+
load() {
|
|
118
|
+
try {
|
|
119
|
+
if (!existsSync(this.credsPath))
|
|
120
|
+
return;
|
|
121
|
+
const raw = JSON.parse(readFileSync(this.credsPath, 'utf-8'));
|
|
122
|
+
this.creds = {
|
|
123
|
+
botToken: String(raw['bot_token'] ?? ''),
|
|
124
|
+
botId: String(raw['bot_id'] ?? ''),
|
|
125
|
+
userId: String(raw['user_id'] ?? ''),
|
|
126
|
+
pushTarget: String(raw['push_target'] ?? ''),
|
|
127
|
+
contextTokens: raw['context_tokens'] ?? {},
|
|
128
|
+
contextTokenTs: raw['context_token_ts'] ?? {},
|
|
129
|
+
syncBuf: String(raw['sync_buf'] ?? ''),
|
|
130
|
+
conversationConfirmed: !!raw['conversation_confirmed'],
|
|
131
|
+
linkedAt: String(raw['linked_at'] ?? ''),
|
|
132
|
+
baseUrl: String(raw['base_url'] ?? '') || ILINK_BASE_URL
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.error('[wechat] 凭据读取失败:', err);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
save() {
|
|
140
|
+
try {
|
|
141
|
+
const c = this.creds;
|
|
142
|
+
writeFileSync(this.credsPath, JSON.stringify({
|
|
143
|
+
bot_token: c.botToken,
|
|
144
|
+
bot_id: c.botId,
|
|
145
|
+
user_id: c.userId,
|
|
146
|
+
push_target: c.pushTarget,
|
|
147
|
+
context_tokens: c.contextTokens,
|
|
148
|
+
context_token_ts: c.contextTokenTs,
|
|
149
|
+
sync_buf: c.syncBuf,
|
|
150
|
+
conversation_confirmed: c.conversationConfirmed,
|
|
151
|
+
linked_at: c.linkedAt,
|
|
152
|
+
base_url: c.baseUrl
|
|
153
|
+
}, null, 2), 'utf-8');
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
console.error('[wechat] 凭据写入失败:', err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ── 状态 ────────────────────────────────────────────────
|
|
160
|
+
get connected() {
|
|
161
|
+
return !!this.creds.botToken;
|
|
162
|
+
}
|
|
163
|
+
get sessionAliveFlag() {
|
|
164
|
+
return this.sessionAlive;
|
|
165
|
+
}
|
|
166
|
+
get isListening() {
|
|
167
|
+
return this.listening;
|
|
168
|
+
}
|
|
169
|
+
get currentQrContent() {
|
|
170
|
+
return this.qrContent;
|
|
171
|
+
}
|
|
172
|
+
getStatus() {
|
|
173
|
+
return {
|
|
174
|
+
status: this.loginStatus,
|
|
175
|
+
statusText: LOGIN_STATUS_TEXT[this.loginStatus],
|
|
176
|
+
connected: this.connected,
|
|
177
|
+
sessionAlive: this.sessionAlive,
|
|
178
|
+
listening: this.listening,
|
|
179
|
+
botId: this.creds.botId,
|
|
180
|
+
pushTarget: this.creds.pushTarget,
|
|
181
|
+
linkedAt: this.creds.linkedAt,
|
|
182
|
+
qrContent: this.qrContent
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
setStatus(status, extra = '') {
|
|
186
|
+
this.loginStatus = status;
|
|
187
|
+
this.emit('status', { status, text: extra || LOGIN_STATUS_TEXT[status] });
|
|
188
|
+
}
|
|
189
|
+
setCommandHandler(handler) {
|
|
190
|
+
this.commandHandler = handler;
|
|
191
|
+
}
|
|
192
|
+
// ── HTTP 基础 ───────────────────────────────────────────
|
|
193
|
+
anonHeaders() {
|
|
194
|
+
return {
|
|
195
|
+
'iLink-App-Id': 'bot',
|
|
196
|
+
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
authHeaders(token) {
|
|
200
|
+
// X-WECHAT-UIN: 随机 uin 的 base64(对齐旧版实现)
|
|
201
|
+
const uin = Buffer.from(String(Math.floor(Math.random() * 0xffffffff))).toString('base64');
|
|
202
|
+
return {
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
AuthorizationType: 'ilink_bot_token',
|
|
205
|
+
'X-WECHAT-UIN': uin,
|
|
206
|
+
'iLink-App-Id': 'bot',
|
|
207
|
+
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
|
|
208
|
+
Authorization: `Bearer ${token}`
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
withBaseInfo(payload) {
|
|
212
|
+
return { ...payload, base_info: { channel_version: CHANNEL_VERSION } };
|
|
213
|
+
}
|
|
214
|
+
async getJson(url, headers, timeoutMs) {
|
|
215
|
+
const resp = await this.fetchImpl(url, {
|
|
216
|
+
method: 'GET',
|
|
217
|
+
headers,
|
|
218
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
219
|
+
});
|
|
220
|
+
if (!resp.ok)
|
|
221
|
+
throw new Error(`HTTP ${resp.status}: ${url}`);
|
|
222
|
+
return (await resp.json());
|
|
223
|
+
}
|
|
224
|
+
async postJson(url, headers, body, timeoutMs) {
|
|
225
|
+
const resp = await this.fetchImpl(url, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers,
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
230
|
+
});
|
|
231
|
+
if (!resp.ok)
|
|
232
|
+
throw new Error(`HTTP ${resp.status}: ${url}`);
|
|
233
|
+
return (await resp.json());
|
|
234
|
+
}
|
|
235
|
+
// ── 扫码登录 ────────────────────────────────────────────
|
|
236
|
+
async fetchQrcode(baseUrl) {
|
|
237
|
+
const data = await this.getJson(`${baseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`, this.anonHeaders(), QR_FETCH_TIMEOUT_MS);
|
|
238
|
+
const qrId = String(data['qrcode'] ?? '').trim();
|
|
239
|
+
const content = String(data['qrcode_img_content'] ?? '').trim() || qrId;
|
|
240
|
+
if (!qrId)
|
|
241
|
+
throw new Error(`获取二维码失败: ${JSON.stringify(data)}`);
|
|
242
|
+
return { qrId, content };
|
|
243
|
+
}
|
|
244
|
+
async pollQrStatus(baseUrl, qrId) {
|
|
245
|
+
return this.getJson(`${baseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrId)}`, this.anonHeaders(), QR_FETCH_TIMEOUT_MS);
|
|
246
|
+
}
|
|
247
|
+
/** 发起扫码登录(后台轮询,状态经 'status' 事件通知) */
|
|
248
|
+
startLogin() {
|
|
249
|
+
if (this.loginAbort) {
|
|
250
|
+
console.warn('[wechat] 已有登录流程在进行中');
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
this.loginAbort = new AbortController();
|
|
254
|
+
void this.loginWorker(this.loginAbort.signal);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
cancelLogin() {
|
|
258
|
+
this.loginAbort?.abort();
|
|
259
|
+
this.loginAbort = null;
|
|
260
|
+
if (this.loginStatus !== 'confirmed')
|
|
261
|
+
this.setStatus('cancelled');
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 应用启动时用已持久化的凭据自动恢复会话,无需重新扫码。
|
|
265
|
+
* 仅当存在有效 botToken 且当前未在监听时生效;若 token 已过期,
|
|
266
|
+
* 监听循环会收到 -14 并自动清空凭据、提示重新扫码。
|
|
267
|
+
*/
|
|
268
|
+
resume() {
|
|
269
|
+
if (!this.connected || this.listening)
|
|
270
|
+
return false;
|
|
271
|
+
// iLink 的 context_token 约 24h 有效,且已持久化到磁盘。
|
|
272
|
+
// 只要磁盘上仍保存着该推送目标的令牌,就乐观认为会话活跃——重启后无需用户先发消息即可推送;
|
|
273
|
+
// 若令牌实际已过期,首次推送会返回 -2/-14,由 sendText 捕获并纠正状态。
|
|
274
|
+
if (this.creds.pushTarget && this.creds.contextTokens[this.creds.pushTarget]) {
|
|
275
|
+
this.sessionAlive = true;
|
|
276
|
+
}
|
|
277
|
+
this.loginStatus = 'confirmed';
|
|
278
|
+
this.setStatus('confirmed', '已用本地凭据恢复连接');
|
|
279
|
+
this.startListening();
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
async loginWorker(signal) {
|
|
283
|
+
const startedAt = Date.now();
|
|
284
|
+
let refreshCount = 0;
|
|
285
|
+
let lastQrStatus = '';
|
|
286
|
+
try {
|
|
287
|
+
this.setStatus('fetching');
|
|
288
|
+
let { qrId, content } = await this.fetchQrcode(ILINK_BASE_URL);
|
|
289
|
+
this.qrContent = content;
|
|
290
|
+
this.setStatus('waiting');
|
|
291
|
+
this.emit('qrcode', content);
|
|
292
|
+
while (!signal.aborted) {
|
|
293
|
+
if (Date.now() - startedAt > QR_LOGIN_TIMEOUT_MS) {
|
|
294
|
+
this.setStatus('timeout');
|
|
295
|
+
this.finishLogin(null);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const result = await this.pollQrStatus(ILINK_BASE_URL, qrId);
|
|
299
|
+
const status = String(result['status'] ?? 'wait').trim();
|
|
300
|
+
if (status === 'scaned' || status === 'scaned_but_redirect') {
|
|
301
|
+
if (lastQrStatus !== status)
|
|
302
|
+
this.setStatus('scanned');
|
|
303
|
+
}
|
|
304
|
+
else if (status === 'expired') {
|
|
305
|
+
refreshCount++;
|
|
306
|
+
if (refreshCount > QR_EXPIRE_MAX) {
|
|
307
|
+
this.setStatus('timeout');
|
|
308
|
+
this.finishLogin(null);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const next = await this.fetchQrcode(ILINK_BASE_URL);
|
|
312
|
+
qrId = next.qrId;
|
|
313
|
+
content = next.content;
|
|
314
|
+
this.qrContent = content;
|
|
315
|
+
this.setStatus('refreshed', `二维码已过期,已自动刷新 (${refreshCount}/${QR_EXPIRE_MAX})`);
|
|
316
|
+
this.emit('qrcode', content);
|
|
317
|
+
}
|
|
318
|
+
else if (status === 'confirmed') {
|
|
319
|
+
const token = String(result['bot_token'] ?? '').trim();
|
|
320
|
+
const botId = String(result['ilink_bot_id'] ?? '').trim();
|
|
321
|
+
const userId = String(result['ilink_user_id'] ?? '').trim();
|
|
322
|
+
const confirmedBase = String(result['baseurl'] ?? '').trim() || ILINK_BASE_URL;
|
|
323
|
+
if (!token || !botId) {
|
|
324
|
+
this.setStatus('failed');
|
|
325
|
+
this.finishLogin(new Error('登录已确认但返回凭据不完整'));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.creds = {
|
|
329
|
+
...emptyCredentials(),
|
|
330
|
+
botToken: token,
|
|
331
|
+
botId,
|
|
332
|
+
userId,
|
|
333
|
+
baseUrl: confirmedBase,
|
|
334
|
+
linkedAt: new Date().toLocaleString('zh-CN', { hour12: false })
|
|
335
|
+
};
|
|
336
|
+
this.sessionAlive = false;
|
|
337
|
+
this.save();
|
|
338
|
+
this.setStatus('confirmed');
|
|
339
|
+
this.finishLogin(null);
|
|
340
|
+
this.startListening();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
lastQrStatus = status;
|
|
344
|
+
await sleep(QR_POLL_INTERVAL_MS, signal);
|
|
345
|
+
}
|
|
346
|
+
// aborted
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
if (signal.aborted)
|
|
350
|
+
return;
|
|
351
|
+
console.error('[wechat] 登录异常:', err);
|
|
352
|
+
this.setStatus('failed', `连接失败: ${String(err)}`);
|
|
353
|
+
this.finishLogin(err instanceof Error ? err : new Error(String(err)));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
finishLogin(_err) {
|
|
357
|
+
this.loginAbort = null;
|
|
358
|
+
}
|
|
359
|
+
// ── 消息发送 ────────────────────────────────────────────
|
|
360
|
+
async sendOnce(toUserId, text, contextToken) {
|
|
361
|
+
const msg = {
|
|
362
|
+
from_user_id: '',
|
|
363
|
+
to_user_id: toUserId,
|
|
364
|
+
client_id: `deskpet-${Math.random().toString(16).slice(2, 14)}`,
|
|
365
|
+
message_type: 2,
|
|
366
|
+
message_state: 2,
|
|
367
|
+
// 微信(iLink)文本项不把 \n 渲染为换行,用 \u000b 作为换行符(实测可正确分行)
|
|
368
|
+
item_list: [
|
|
369
|
+
{
|
|
370
|
+
type: 1,
|
|
371
|
+
text_item: { text: text.replace(/\r\n/g, '\u000b').replace(/\n/g, '\u000b') }
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
};
|
|
375
|
+
if (contextToken)
|
|
376
|
+
msg['context_token'] = contextToken;
|
|
377
|
+
const data = await this.postJson(`${this.creds.baseUrl}/ilink/bot/sendmessage`, this.authHeaders(this.creds.botToken), this.withBaseInfo({ msg }), API_TIMEOUT_MS);
|
|
378
|
+
const ret = Number(data['ret'] ?? 0);
|
|
379
|
+
const errcode = Number(data['errcode'] ?? 0);
|
|
380
|
+
if (ret !== 0 || errcode !== 0) {
|
|
381
|
+
throw new Error(JSON.stringify(data));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** 发送文本(自动拆条;返回 {ok, message}) */
|
|
385
|
+
async sendText(text, toUserId) {
|
|
386
|
+
if (!this.connected)
|
|
387
|
+
return { ok: false, message: '微信未连接' };
|
|
388
|
+
const target = (toUserId ?? this.creds.pushTarget ?? '').trim();
|
|
389
|
+
if (!target) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
message: '没有可用的推送目标:请先在微信里给机器人发一条消息(如「你好」)建立会话'
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// iLink 协议:必须持有该用户的 context_token 才能推送(用户先发消息才会下发)。
|
|
396
|
+
// 该令牌已持久化到磁盘,24h 内重启后可直接复用,无需用户再次发消息。
|
|
397
|
+
const ctx = this.creds.contextTokens[target];
|
|
398
|
+
if (!ctx) {
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
message: '尚无该用户的会话令牌:iLink 限制 bot 不能主动发起对话,请先在微信里给机器人发一条消息(如「你好」)'
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const chunks = splitMessage(text);
|
|
405
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
406
|
+
try {
|
|
407
|
+
await this.sendOnce(target, chunks[i], ctx);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
// context_token 过期 / 登录会话失效 → 标记会话失效,等待用户重新发消息刷新
|
|
411
|
+
this.sessionAlive = false;
|
|
412
|
+
const reason = this.classifySendError(err);
|
|
413
|
+
console.warn('[wechat] 推送失败:', reason);
|
|
414
|
+
return { ok: false, message: reason };
|
|
415
|
+
}
|
|
416
|
+
if (i < chunks.length - 1)
|
|
417
|
+
await sleep(400);
|
|
418
|
+
}
|
|
419
|
+
this.sessionAlive = true;
|
|
420
|
+
return { ok: true, message: '发送成功' };
|
|
421
|
+
}
|
|
422
|
+
/** 把推送异常归类成可读提示(区分登录会话过期与 context_token 过期/超限) */
|
|
423
|
+
classifySendError(err) {
|
|
424
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
425
|
+
try {
|
|
426
|
+
const data = JSON.parse(raw);
|
|
427
|
+
const ret = Number(data['ret'] ?? 0);
|
|
428
|
+
const errcode = Number(data['errcode'] ?? 0);
|
|
429
|
+
if (ret === -14 || errcode === -14) {
|
|
430
|
+
return '微信登录会话已过期,请到设置页重新扫码连接';
|
|
431
|
+
}
|
|
432
|
+
if (ret === -2 || errcode === -2) {
|
|
433
|
+
return '微信推送令牌已过期或达每日上限:请在微信里给机器人发一条消息(如「你好」)刷新会话后即可恢复推送';
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
/* 非 JSON 错误(网络等),走通用文案 */
|
|
438
|
+
}
|
|
439
|
+
return `微信推送失败:${raw.slice(0, 200)}`;
|
|
440
|
+
}
|
|
441
|
+
async sendTestMessage() {
|
|
442
|
+
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
443
|
+
return this.sendText(`🐾 DeskPet 测试消息\n时间:${now}\n收到即说明推送链路正常`);
|
|
444
|
+
}
|
|
445
|
+
/** 任务结果推送(按设置过滤) */
|
|
446
|
+
async notifyTaskResult(taskName, success, detail = '') {
|
|
447
|
+
const s = this.getSettings();
|
|
448
|
+
if (success && !s.notifyOnTaskComplete)
|
|
449
|
+
return;
|
|
450
|
+
if (!success && !s.notifyOnTaskFailed)
|
|
451
|
+
return;
|
|
452
|
+
const icon = success ? '✅' : '❌';
|
|
453
|
+
const lines = [`${icon} 任务${success ? '完成' : '失败'}:${taskName}`];
|
|
454
|
+
if (detail)
|
|
455
|
+
lines.push(detail.slice(0, 500));
|
|
456
|
+
await this.sendText(lines.join('\n'));
|
|
457
|
+
}
|
|
458
|
+
// ── 长轮询接收 ──────────────────────────────────────────
|
|
459
|
+
startListening() {
|
|
460
|
+
if (!this.connected || this.listening)
|
|
461
|
+
return false;
|
|
462
|
+
this.listenStop = false;
|
|
463
|
+
this.listening = true;
|
|
464
|
+
this.lastPollAt = Date.now();
|
|
465
|
+
void this.listenLoop();
|
|
466
|
+
// 看门狗:长轮询卡死自动重启
|
|
467
|
+
if (!this.watchdog) {
|
|
468
|
+
this.watchdog = setInterval(() => {
|
|
469
|
+
if (!this.listening || this.listenStop)
|
|
470
|
+
return;
|
|
471
|
+
const staleMs = Date.now() - this.lastPollAt;
|
|
472
|
+
if (staleMs > HEARTBEAT_STALE_THRESHOLD_MS) {
|
|
473
|
+
console.warn(`[wechat] 长轮询 ${Math.round(staleMs / 1000)}s 无响应,重启监听`);
|
|
474
|
+
this.listening = false;
|
|
475
|
+
this.listenStop = true;
|
|
476
|
+
setTimeout(() => {
|
|
477
|
+
if (this.connected && !this.listening)
|
|
478
|
+
this.startListening();
|
|
479
|
+
}, 500);
|
|
480
|
+
}
|
|
481
|
+
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
|
482
|
+
this.watchdog.unref?.();
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
stopListening() {
|
|
487
|
+
this.listenStop = true;
|
|
488
|
+
this.listening = false;
|
|
489
|
+
}
|
|
490
|
+
async listenLoop() {
|
|
491
|
+
let failStreak = 0;
|
|
492
|
+
this.reconnectAttempts = 0;
|
|
493
|
+
while (!this.listenStop && this.connected) {
|
|
494
|
+
try {
|
|
495
|
+
const data = await this.postJson(`${this.creds.baseUrl}/ilink/bot/getupdates`, this.authHeaders(this.creds.botToken), this.withBaseInfo({ get_updates_buf: this.creds.syncBuf }), LONG_POLL_TIMEOUT_MS);
|
|
496
|
+
failStreak = 0;
|
|
497
|
+
this.lastPollAt = Date.now();
|
|
498
|
+
const ret = Number(data['ret'] ?? 0);
|
|
499
|
+
const errcode = Number(data['errcode'] ?? 0);
|
|
500
|
+
// 会话过期 → 自动重连
|
|
501
|
+
if (ret === -14 || errcode === -14) {
|
|
502
|
+
const ok = await this.tryReconnect();
|
|
503
|
+
if (ok)
|
|
504
|
+
continue;
|
|
505
|
+
this.creds.botToken = '';
|
|
506
|
+
this.sessionAlive = false;
|
|
507
|
+
this.save();
|
|
508
|
+
this.listening = false;
|
|
509
|
+
this.emit('disconnected', '微信会话已过期,自动重连失败,请重新扫码连接');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (ret !== 0 || errcode !== 0) {
|
|
513
|
+
await sleep(3000);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
let dirty = false;
|
|
517
|
+
const newBuf = String(data['get_updates_buf'] ?? '');
|
|
518
|
+
if (newBuf && newBuf !== this.creds.syncBuf) {
|
|
519
|
+
this.creds.syncBuf = newBuf;
|
|
520
|
+
dirty = true;
|
|
521
|
+
}
|
|
522
|
+
const msgs = data['msgs'] ?? [];
|
|
523
|
+
for (const msg of msgs) {
|
|
524
|
+
if (this.handleIncoming(msg))
|
|
525
|
+
dirty = true;
|
|
526
|
+
}
|
|
527
|
+
if (dirty)
|
|
528
|
+
this.save();
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
// 长轮询超时属正常,静默重试
|
|
532
|
+
const name = err instanceof Error ? err.name : String(err);
|
|
533
|
+
if (!name.includes('Timeout') && !name.includes('AbortError')) {
|
|
534
|
+
failStreak++;
|
|
535
|
+
if (failStreak === 5) {
|
|
536
|
+
console.warn('[wechat] 长轮询连续失败 5 次,可能网络不稳定');
|
|
537
|
+
}
|
|
538
|
+
const backoff = Math.min(3000 * failStreak, 30_000);
|
|
539
|
+
await sleep(backoff);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
this.listening = false;
|
|
544
|
+
}
|
|
545
|
+
async tryReconnect() {
|
|
546
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS)
|
|
547
|
+
return false;
|
|
548
|
+
this.reconnectAttempts++;
|
|
549
|
+
console.warn(`[wechat] 会话过期,尝试自动重连 (第${this.reconnectAttempts}次)`);
|
|
550
|
+
await sleep(RECONNECT_DELAY_MS);
|
|
551
|
+
// iLink 协议:bot_token 失效后无法本地续期,只能提示重新扫码
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
/** 处理一条收到的消息;返回是否有状态需要落盘 */
|
|
555
|
+
handleIncoming(msg) {
|
|
556
|
+
const fromUid = String(msg['from_user_id'] ?? '').trim();
|
|
557
|
+
if (!fromUid || fromUid === this.creds.botId)
|
|
558
|
+
return false;
|
|
559
|
+
let dirty = false;
|
|
560
|
+
const ctxToken = String(msg['context_token'] ?? '').trim();
|
|
561
|
+
if (ctxToken && this.creds.contextTokens[fromUid] !== ctxToken) {
|
|
562
|
+
this.creds.contextTokens[fromUid] = ctxToken;
|
|
563
|
+
this.creds.contextTokenTs[fromUid] = Date.now();
|
|
564
|
+
dirty = true;
|
|
565
|
+
}
|
|
566
|
+
// 用户主动发消息 = 推送会话恢复活跃
|
|
567
|
+
this.sessionAlive = true;
|
|
568
|
+
if (this.creds.pushTarget !== fromUid) {
|
|
569
|
+
this.creds.pushTarget = fromUid;
|
|
570
|
+
dirty = true;
|
|
571
|
+
}
|
|
572
|
+
// 首次会话确认
|
|
573
|
+
if (!this.creds.conversationConfirmed) {
|
|
574
|
+
this.creds.conversationConfirmed = true;
|
|
575
|
+
dirty = true;
|
|
576
|
+
void this.sendText('✅ 微信会话已建立\n之后任务执行结果和测试消息都会推送到这里。\n发「帮助」查看可用指令。', fromUid);
|
|
577
|
+
}
|
|
578
|
+
const text = extractText(msg);
|
|
579
|
+
if (!text)
|
|
580
|
+
return dirty;
|
|
581
|
+
this.emit('message', { fromUid, text });
|
|
582
|
+
// 指令处理(未命中由 router 返回提示;allowRemoteCommand=false 时忽略)
|
|
583
|
+
if (this.commandHandler && this.getSettings().allowRemoteCommand) {
|
|
584
|
+
let reply;
|
|
585
|
+
try {
|
|
586
|
+
reply = this.commandHandler(text);
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
reply = `❌ 指令执行出错: ${String(err)}`;
|
|
590
|
+
}
|
|
591
|
+
if (reply)
|
|
592
|
+
void this.sendText(reply, fromUid);
|
|
593
|
+
}
|
|
594
|
+
return dirty;
|
|
595
|
+
}
|
|
596
|
+
// ── 断开 ────────────────────────────────────────────────
|
|
597
|
+
disconnect() {
|
|
598
|
+
this.cancelLogin();
|
|
599
|
+
this.stopListening();
|
|
600
|
+
if (this.watchdog) {
|
|
601
|
+
clearInterval(this.watchdog);
|
|
602
|
+
this.watchdog = null;
|
|
603
|
+
}
|
|
604
|
+
this.creds = emptyCredentials();
|
|
605
|
+
this.sessionAlive = false;
|
|
606
|
+
this.loginStatus = 'idle';
|
|
607
|
+
this.qrContent = null;
|
|
608
|
+
try {
|
|
609
|
+
if (existsSync(this.credsPath))
|
|
610
|
+
writeFileSync(this.credsPath, '', 'utf-8');
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* ignore */
|
|
614
|
+
}
|
|
615
|
+
this.emit('status', { status: 'idle', text: LOGIN_STATUS_TEXT.idle });
|
|
616
|
+
}
|
|
617
|
+
/** 应用退出时调用 */
|
|
618
|
+
shutdown() {
|
|
619
|
+
this.stopListening();
|
|
620
|
+
if (this.watchdog) {
|
|
621
|
+
clearInterval(this.watchdog);
|
|
622
|
+
this.watchdog = null;
|
|
623
|
+
}
|
|
624
|
+
this.loginAbort?.abort();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function sleep(ms, signal) {
|
|
628
|
+
return new Promise((resolve) => {
|
|
629
|
+
const t = setTimeout(resolve, ms);
|
|
630
|
+
signal?.addEventListener('abort', () => {
|
|
631
|
+
clearTimeout(t);
|
|
632
|
+
resolve();
|
|
633
|
+
}, { once: true });
|
|
634
|
+
});
|
|
635
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|