@zhin.js/adapter-weixin-ilink 1.0.1 → 1.1.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/CHANGELOG.md +512 -0
- package/README.md +24 -42
- package/adapters/weixin-ilink/index.js +32 -0
- package/adapters/weixin-ilink/index.ts +43 -0
- package/commands/weixin-ilink/endpoint/add/[id]/index.js +3 -0
- package/commands/weixin-ilink/endpoint/add/[id]/index.ts +3 -0
- package/commands/weixin-ilink/endpoint/definition.js +17 -0
- package/commands/weixin-ilink/endpoint/definition.ts +17 -0
- package/commands/weixin-ilink/endpoint/list/index.js +3 -0
- package/commands/weixin-ilink/endpoint/list/index.ts +3 -0
- package/commands/weixin-ilink/endpoint/remove/[id]/index.js +3 -0
- package/commands/weixin-ilink/endpoint/remove/[id]/index.ts +3 -0
- package/lib/aes-ecb.d.ts +0 -1
- package/lib/aes-ecb.js +0 -1
- package/lib/cdn-upload.d.ts +0 -1
- package/lib/cdn-upload.js +0 -1
- package/lib/cdn-url.d.ts +0 -1
- package/lib/cdn-url.js +0 -1
- package/lib/client.d.ts +35 -0
- package/lib/client.js +89 -0
- package/lib/context-store.d.ts +15 -2
- package/lib/context-store.js +93 -2
- package/lib/credentials.d.ts +15 -8
- package/lib/credentials.js +59 -43
- package/lib/endpoint.d.ts +40 -35
- package/lib/endpoint.js +311 -262
- package/lib/ilink-api.d.ts +7 -6
- package/lib/ilink-api.js +26 -16
- package/lib/ilink-config-cache.d.ts +3 -8
- package/lib/ilink-config-cache.js +13 -15
- package/lib/ilink-logger.d.ts +0 -2
- package/lib/ilink-logger.js +11 -26
- package/lib/ilink-meta.d.ts +13 -8
- package/lib/ilink-meta.js +24 -21
- package/lib/ilink-random.d.ts +0 -1
- package/lib/ilink-random.js +0 -1
- package/lib/ilink-redact.d.ts +0 -1
- package/lib/ilink-redact.js +0 -1
- package/lib/ilink-session-guard.d.ts +13 -15
- package/lib/ilink-session-guard.js +29 -43
- package/lib/ilink-types.d.ts +0 -1
- package/lib/ilink-types.js +0 -1
- package/lib/index.d.ts +8 -20
- package/lib/index.js +7 -38
- package/lib/login.d.ts +5 -6
- package/lib/login.js +85 -77
- package/lib/media-download.d.ts +1 -2
- package/lib/media-download.js +1 -2
- package/lib/mime.d.ts +0 -1
- package/lib/mime.js +0 -1
- package/lib/outbound-media.d.ts +4 -5
- package/lib/outbound-media.js +85 -86
- package/lib/pic-decrypt.d.ts +0 -1
- package/lib/pic-decrypt.js +0 -1
- package/lib/protocol.d.ts +60 -0
- package/lib/protocol.js +127 -0
- package/lib/silk-transcode.d.ts +0 -1
- package/lib/silk-transcode.js +0 -1
- package/lib/types.d.ts +2 -26
- package/lib/types.js +0 -1
- package/lib/upload.d.ts +1 -2
- package/lib/upload.js +1 -2
- package/lib/weixin-ilink-runtime-state.d.ts +1 -0
- package/lib/weixin-ilink-runtime-state.js +6 -0
- package/lib/weixin-inbound.d.ts +4 -43
- package/lib/weixin-inbound.js +22 -162
- package/lib/weixin-send-media.d.ts +0 -1
- package/lib/weixin-send-media.js +5 -2
- package/lib/weixin-send.d.ts +1 -2
- package/lib/weixin-send.js +4 -3
- package/package.json +48 -39
- package/plugin.js +14 -0
- package/schema.json +116 -0
- package/src/client.ts +117 -0
- package/src/context-store.ts +101 -8
- package/src/credentials.ts +68 -45
- package/src/endpoint.ts +384 -315
- package/src/ilink-api.ts +31 -21
- package/src/ilink-config-cache.ts +17 -13
- package/src/ilink-logger.ts +11 -26
- package/src/ilink-meta.ts +31 -21
- package/src/ilink-session-guard.ts +29 -46
- package/src/index.ts +41 -56
- package/src/login.ts +112 -85
- package/src/media-download.ts +1 -2
- package/src/outbound-media.ts +97 -97
- package/src/protocol.ts +180 -0
- package/src/silk-wasm.d.ts +6 -2
- package/src/types.ts +5 -25
- package/src/upload.test.ts +11 -8
- package/src/upload.ts +2 -2
- package/src/weixin-ilink-runtime-state.ts +7 -0
- package/src/weixin-inbound.ts +25 -187
- package/src/weixin-send-media.ts +5 -1
- package/src/weixin-send.ts +6 -4
- package/client/LoginAssistPanel.tsx +0 -200
- package/client/WeixinIlinkManagement.tsx +0 -139
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -13
- package/client/utils/api.ts +0 -30
- package/dist/index.js +0 -29
- package/lib/adapter.d.ts +0 -12
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -15
- package/lib/adapter.js.map +0 -1
- package/lib/aes-ecb.d.ts.map +0 -1
- package/lib/aes-ecb.js.map +0 -1
- package/lib/cdn-upload.d.ts.map +0 -1
- package/lib/cdn-upload.js.map +0 -1
- package/lib/cdn-upload.test.d.ts +0 -2
- package/lib/cdn-upload.test.d.ts.map +0 -1
- package/lib/cdn-upload.test.js +0 -150
- package/lib/cdn-upload.test.js.map +0 -1
- package/lib/cdn-url.d.ts.map +0 -1
- package/lib/cdn-url.js.map +0 -1
- package/lib/context-store.d.ts.map +0 -1
- package/lib/context-store.js.map +0 -1
- package/lib/credentials.d.ts.map +0 -1
- package/lib/credentials.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/ilink-api.d.ts.map +0 -1
- package/lib/ilink-api.js.map +0 -1
- package/lib/ilink-config-cache.d.ts.map +0 -1
- package/lib/ilink-config-cache.js.map +0 -1
- package/lib/ilink-logger.d.ts.map +0 -1
- package/lib/ilink-logger.js.map +0 -1
- package/lib/ilink-meta.d.ts.map +0 -1
- package/lib/ilink-meta.js.map +0 -1
- package/lib/ilink-random.d.ts.map +0 -1
- package/lib/ilink-random.js.map +0 -1
- package/lib/ilink-redact.d.ts.map +0 -1
- package/lib/ilink-redact.js.map +0 -1
- package/lib/ilink-session-guard.d.ts.map +0 -1
- package/lib/ilink-session-guard.js.map +0 -1
- package/lib/ilink-types.d.ts.map +0 -1
- package/lib/ilink-types.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/login-assist-routes.d.ts +0 -7
- package/lib/login-assist-routes.d.ts.map +0 -1
- package/lib/login-assist-routes.js +0 -29
- package/lib/login-assist-routes.js.map +0 -1
- package/lib/login.d.ts.map +0 -1
- package/lib/login.js.map +0 -1
- package/lib/media-download.d.ts.map +0 -1
- package/lib/media-download.js.map +0 -1
- package/lib/mime.d.ts.map +0 -1
- package/lib/mime.js.map +0 -1
- package/lib/mime.test.d.ts +0 -2
- package/lib/mime.test.d.ts.map +0 -1
- package/lib/mime.test.js +0 -62
- package/lib/mime.test.js.map +0 -1
- package/lib/outbound-media.d.ts.map +0 -1
- package/lib/outbound-media.js.map +0 -1
- package/lib/pic-decrypt.d.ts.map +0 -1
- package/lib/pic-decrypt.js.map +0 -1
- package/lib/routes.d.ts +0 -8
- package/lib/routes.d.ts.map +0 -1
- package/lib/routes.js +0 -29
- package/lib/routes.js.map +0 -1
- package/lib/silk-transcode.d.ts.map +0 -1
- package/lib/silk-transcode.js.map +0 -1
- package/lib/silk-transcode.test.d.ts +0 -2
- package/lib/silk-transcode.test.d.ts.map +0 -1
- package/lib/silk-transcode.test.js +0 -53
- package/lib/silk-transcode.test.js.map +0 -1
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js.map +0 -1
- package/lib/typing-indicator.d.ts +0 -32
- package/lib/typing-indicator.d.ts.map +0 -1
- package/lib/typing-indicator.js +0 -95
- package/lib/typing-indicator.js.map +0 -1
- package/lib/upload.d.ts.map +0 -1
- package/lib/upload.js.map +0 -1
- package/lib/upload.test.d.ts +0 -2
- package/lib/upload.test.d.ts.map +0 -1
- package/lib/upload.test.js +0 -250
- package/lib/upload.test.js.map +0 -1
- package/lib/weixin-inbound.d.ts.map +0 -1
- package/lib/weixin-inbound.js.map +0 -1
- package/lib/weixin-send-media.d.ts.map +0 -1
- package/lib/weixin-send-media.js.map +0 -1
- package/lib/weixin-send.d.ts.map +0 -1
- package/lib/weixin-send.js.map +0 -1
- package/src/adapter.ts +0 -18
- package/src/login-assist-routes.ts +0 -36
- package/src/routes.ts +0 -39
- package/src/typing-indicator.ts +0 -139
package/lib/endpoint.js
CHANGED
|
@@ -1,282 +1,263 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
|
|
3
3
|
*/
|
|
4
|
-
import crypto from
|
|
5
|
-
import fs from
|
|
6
|
-
import path from
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { materializeOutboundMedia
|
|
19
|
-
import { MessageItemType } from
|
|
20
|
-
import { getExtensionFromMime, sniffMimeFromBuffer } from
|
|
21
|
-
import {
|
|
22
|
-
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Endpoint, } from 'zhin.js/adapter';
|
|
8
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
9
|
+
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
10
|
+
import { IlinkClientMetadata } from './ilink-meta.js';
|
|
11
|
+
import { WeixinIlinkStateStore, } from './credentials.js';
|
|
12
|
+
import { resolveCredentials } from './login.js';
|
|
13
|
+
import { WeixinContextTokenStore, } from './context-store.js';
|
|
14
|
+
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
15
|
+
import { IlinkSessionGuard, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
|
|
16
|
+
import { downloadMediaFromItem } from './media-download.js';
|
|
17
|
+
import { sendMessageWeixin } from './weixin-send.js';
|
|
18
|
+
import { materializeOutboundMedia } from './outbound-media.js';
|
|
19
|
+
import { MessageItemType } from './ilink-types.js';
|
|
20
|
+
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
21
|
+
import { formatInboundContent, weixinInboundSegments, weixinReplyTo, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, weixinIlinkInboundConversation, } from './protocol.js';
|
|
22
|
+
import { WeixinIlinkClient } from './client.js';
|
|
23
23
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
24
24
|
const BACKOFF_DELAY_MS = 30_000;
|
|
25
25
|
const RETRY_DELAY_MS = 2_000;
|
|
26
26
|
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
/** 入站媒体保留 24h,每小时清扫一次。 */
|
|
28
|
+
const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
30
|
+
export class WeixinIlinkEndpoint extends Endpoint {
|
|
31
|
+
client;
|
|
32
|
+
#logger;
|
|
33
|
+
#options;
|
|
34
|
+
#resolveCredentials;
|
|
35
|
+
#notifyStart;
|
|
36
|
+
#notifyStop;
|
|
37
|
+
#getUpdates;
|
|
38
|
+
#sendText;
|
|
39
|
+
#state;
|
|
40
|
+
#contextTokens;
|
|
41
|
+
#sessionGuard;
|
|
42
|
+
#metadata;
|
|
43
|
+
#creds = null;
|
|
44
|
+
#pollAbort;
|
|
45
|
+
#pollPromise;
|
|
46
|
+
#loginAbort;
|
|
47
|
+
#configManager;
|
|
48
|
+
#mediaSweepTimer;
|
|
49
|
+
#open = false;
|
|
50
|
+
#started = false;
|
|
51
|
+
management = createWeixinIlinkEndpointManagement(() => this.client);
|
|
52
|
+
control = Object.freeze({
|
|
53
|
+
typing: async (conversation, active) => {
|
|
54
|
+
await this.client.sendTyping(conversation.id, active === false ? 2 : 1);
|
|
55
|
+
},
|
|
38
56
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
pollPromise;
|
|
55
|
-
configManager;
|
|
56
|
-
get $id() {
|
|
57
|
-
return this.$config.name;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
super();
|
|
59
|
+
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
60
|
+
this.#options = options;
|
|
61
|
+
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
62
|
+
this.#notifyStart = options.notifyStart ?? notifyStart;
|
|
63
|
+
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
64
|
+
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
65
|
+
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
66
|
+
this.#state = options.state
|
|
67
|
+
?? new WeixinIlinkStateStore(options.config.id, options.config.dataDir);
|
|
68
|
+
this.#contextTokens = options.contextTokens ?? new WeixinContextTokenStore(this.#state);
|
|
69
|
+
this.#sessionGuard = options.sessionGuard ?? new IlinkSessionGuard(options.config.id);
|
|
70
|
+
this.#metadata = new IlinkClientMetadata({ botAgent: options.config.botAgent });
|
|
71
|
+
this.client = new WeixinIlinkClient(options.config, this.#metadata, () => this.#creds, () => this.#configManager, this.#sendText, this.#contextTokens);
|
|
58
72
|
}
|
|
59
73
|
get hasCredentials() {
|
|
60
|
-
return
|
|
74
|
+
return this.client.authenticated;
|
|
61
75
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
async start() {
|
|
77
|
+
if (this.#started)
|
|
78
|
+
return;
|
|
79
|
+
this.#started = true;
|
|
80
|
+
try {
|
|
81
|
+
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
82
|
+
this.#loginAbort = new AbortController();
|
|
83
|
+
this.#creds = await this.#resolveCredentials(this.#options.config, this.#metadata, this.#state, this.#loginAbort.signal);
|
|
84
|
+
this.#contextTokens.restore();
|
|
85
|
+
await this.#notifyStart({
|
|
86
|
+
baseUrl: this.client.apiBaseUrl,
|
|
87
|
+
metadata: this.#metadata,
|
|
88
|
+
token: this.#creds.botToken,
|
|
89
|
+
});
|
|
90
|
+
this.#configManager = new WeixinConfigManager({
|
|
91
|
+
baseUrl: this.client.apiBaseUrl,
|
|
92
|
+
metadata: this.#metadata,
|
|
93
|
+
token: this.#creds.botToken,
|
|
94
|
+
}, (msg) => this.#logger.debug(msg));
|
|
95
|
+
this.#pollAbort = new AbortController();
|
|
96
|
+
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
97
|
+
this.#startMediaSweep();
|
|
98
|
+
this.#logger.info(formatCompact({
|
|
99
|
+
op: 'connect',
|
|
100
|
+
endpoint: this.#options.config.id,
|
|
101
|
+
}));
|
|
102
|
+
void this.#emitPlatformEvent('system.online', {
|
|
103
|
+
account: this.#options.config.id,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await this.stop();
|
|
108
|
+
this.#logger.error('Failed to connect weixin-ilink bot:', error);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
71
111
|
}
|
|
72
|
-
|
|
73
|
-
|
|
112
|
+
open() {
|
|
113
|
+
this.#open = true;
|
|
74
114
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.creds = await resolveCredentials(this.adapter.plugin, this.$config);
|
|
78
|
-
restoreContextTokens(this.$id);
|
|
79
|
-
await notifyStart({
|
|
80
|
-
baseUrl: this.apiBaseUrl(),
|
|
81
|
-
token: this.creds.botToken,
|
|
82
|
-
});
|
|
83
|
-
this.configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.creds.botToken }, (msg) => this.pluginLogger.debug(msg));
|
|
84
|
-
if (this.$config.typingIndicator?.enabled !== false) {
|
|
85
|
-
this.$typingIndicator = new WeixinIlinkTypingIndicatorManager(this, this.$config.typingIndicator);
|
|
86
|
-
}
|
|
87
|
-
this.pollAbort = new AbortController();
|
|
88
|
-
this.pollPromise = this.pollLoop(this.pollAbort.signal);
|
|
89
|
-
this.$connected = true;
|
|
90
|
-
this.pluginLogger.info(`weixin-ilink endpoint ${this.$id} connected`);
|
|
115
|
+
close() {
|
|
116
|
+
this.#open = false;
|
|
91
117
|
}
|
|
92
|
-
async
|
|
93
|
-
this
|
|
118
|
+
async stop() {
|
|
119
|
+
this.#open = false;
|
|
120
|
+
this.#loginAbort?.abort();
|
|
121
|
+
this.#pollAbort?.abort();
|
|
94
122
|
try {
|
|
95
|
-
await this
|
|
123
|
+
await this.#pollPromise;
|
|
96
124
|
}
|
|
97
125
|
catch {
|
|
98
126
|
/* poll loop exit */
|
|
99
127
|
}
|
|
100
|
-
|
|
128
|
+
this.#stopMediaSweep();
|
|
129
|
+
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
130
|
+
this.#contextTokens.flush();
|
|
131
|
+
if (this.#creds?.botToken) {
|
|
101
132
|
try {
|
|
102
|
-
await notifyStop({
|
|
133
|
+
await this.#notifyStop({
|
|
134
|
+
baseUrl: this.client.apiBaseUrl,
|
|
135
|
+
metadata: this.#metadata,
|
|
136
|
+
token: this.#creds.botToken,
|
|
137
|
+
});
|
|
103
138
|
}
|
|
104
139
|
catch (err) {
|
|
105
|
-
this.
|
|
140
|
+
this.#logger.warn(formatCompact({
|
|
141
|
+
op: 'notify_stop',
|
|
142
|
+
ok: false,
|
|
143
|
+
error: err instanceof Error ? err.message : String(err),
|
|
144
|
+
}));
|
|
106
145
|
}
|
|
107
146
|
}
|
|
108
|
-
this
|
|
109
|
-
this.
|
|
110
|
-
|
|
111
|
-
async sendTypingToUser(userId, status) {
|
|
112
|
-
if (!this.creds?.botToken || !this.configManager)
|
|
113
|
-
return;
|
|
114
|
-
const contextToken = getContextToken(this.$id, userId);
|
|
115
|
-
const cfg = await this.configManager.getForUser(userId, contextToken);
|
|
116
|
-
if (!cfg.typingTicket) {
|
|
117
|
-
this.pluginLogger.debug(`sendTyping: no typing_ticket for user=${userId}`);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
await sendTyping({
|
|
121
|
-
baseUrl: this.apiBaseUrl(),
|
|
122
|
-
token: this.creds.botToken,
|
|
123
|
-
body: {
|
|
124
|
-
ilink_user_id: userId,
|
|
125
|
-
typing_ticket: cfg.typingTicket,
|
|
126
|
-
status,
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
async $recallMessage(_id) {
|
|
131
|
-
this.pluginLogger.debug("weixin-ilink does not support message recall");
|
|
132
|
-
}
|
|
133
|
-
$formatMessage(msg) {
|
|
134
|
-
const userId = msg.from_user_id ?? "";
|
|
135
|
-
const content = this.buildInboundContent(msg);
|
|
136
|
-
const quoteId = Message.quoteIdFromContent(content);
|
|
137
|
-
Message.alignReplySegments(content, quoteId);
|
|
138
|
-
const msgId = String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
139
|
-
return Message.from(msg, {
|
|
140
|
-
$id: msgId,
|
|
141
|
-
$adapter: "weixin-ilink",
|
|
142
|
-
$endpoint: this.$config.name,
|
|
143
|
-
$sender: {
|
|
144
|
-
id: userId,
|
|
145
|
-
name: userId,
|
|
146
|
-
},
|
|
147
|
-
$channel: {
|
|
148
|
-
id: userId,
|
|
149
|
-
type: "private",
|
|
150
|
-
},
|
|
151
|
-
$content: content,
|
|
152
|
-
$quote_id: quoteId,
|
|
153
|
-
$raw: bodyFromItemList(msg.item_list),
|
|
154
|
-
$timestamp: msg.create_time_ms ?? Date.now(),
|
|
155
|
-
$recall: async () => this.$recallMessage(msgId),
|
|
156
|
-
$reply: async (replyContent, _quote) => {
|
|
157
|
-
if (!Array.isArray(replyContent))
|
|
158
|
-
replyContent = [replyContent];
|
|
159
|
-
return this.adapter.sendMessage({
|
|
160
|
-
context: "weixin-ilink",
|
|
161
|
-
endpoint: this.$config.name,
|
|
162
|
-
id: userId,
|
|
163
|
-
type: "private",
|
|
164
|
-
content: replyContent,
|
|
165
|
-
});
|
|
166
|
-
},
|
|
147
|
+
this.#started = false;
|
|
148
|
+
void this.#emitPlatformEvent('system.offline', {
|
|
149
|
+
account: this.#options.config.id,
|
|
167
150
|
});
|
|
151
|
+
this.#logger.debug(formatCompact({
|
|
152
|
+
op: 'disconnect',
|
|
153
|
+
}));
|
|
168
154
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (text)
|
|
173
|
-
segments.push(segment.text(text));
|
|
174
|
-
const media = msg._media;
|
|
175
|
-
if (media?.decryptedPicPath) {
|
|
176
|
-
segments.push(segment("image", { file: media.decryptedPicPath }));
|
|
155
|
+
async send({ conversation, payload }) {
|
|
156
|
+
if (!this.#creds?.botToken) {
|
|
157
|
+
throw new Error('weixin-ilink bot not authenticated');
|
|
177
158
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
else if (media?.decryptedFilePath) {
|
|
182
|
-
segments.push(segment("file", {
|
|
183
|
-
file: media.decryptedFilePath,
|
|
184
|
-
name: path.basename(media.decryptedFilePath),
|
|
185
|
-
}));
|
|
186
|
-
}
|
|
187
|
-
else if (media?.decryptedVoicePath) {
|
|
188
|
-
segments.push(segment("record", { file: media.decryptedVoicePath }));
|
|
189
|
-
}
|
|
190
|
-
else if (!text && msg.item_list?.some((i) => isMediaItem(i))) {
|
|
191
|
-
segments.push(segment.text("[媒体消息]"));
|
|
192
|
-
}
|
|
193
|
-
return segments.length ? segments : [segment.text("")];
|
|
194
|
-
}
|
|
195
|
-
async $sendMessage(options) {
|
|
196
|
-
if (!this.creds?.botToken) {
|
|
197
|
-
throw new Error("weixin-ilink bot not authenticated");
|
|
198
|
-
}
|
|
199
|
-
const peerId = options.id;
|
|
200
|
-
const contextToken = getContextToken(this.$id, peerId);
|
|
159
|
+
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
160
|
+
const target = conversation.id;
|
|
161
|
+
const contextToken = this.client.contextToken(target);
|
|
201
162
|
if (!contextToken) {
|
|
202
|
-
this.
|
|
203
|
-
|
|
163
|
+
this.#logger.warn(formatCompact({
|
|
164
|
+
op: 'send',
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: 'missing_context_token',
|
|
167
|
+
target,
|
|
168
|
+
}));
|
|
169
|
+
throw new Error(`missing context_token for peer ${target}`);
|
|
204
170
|
}
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
contextToken,
|
|
209
|
-
};
|
|
210
|
-
const outboundDir = path.join(resolveStateDir(), "media", "outbound");
|
|
211
|
-
const materialized = await materializeOutboundMedia(options.content, outboundDir);
|
|
171
|
+
const outboundDir = this.#state.mediaDirectory('outbound');
|
|
172
|
+
const wire = formatOutboundSegments(payload);
|
|
173
|
+
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
212
174
|
const segments = Array.isArray(materialized) ? materialized : [materialized];
|
|
213
|
-
let lastId =
|
|
214
|
-
let pendingText =
|
|
175
|
+
let lastId = '';
|
|
176
|
+
let pendingText = '';
|
|
215
177
|
for (const raw of segments) {
|
|
216
|
-
if (typeof raw ===
|
|
178
|
+
if (typeof raw === 'string') {
|
|
217
179
|
pendingText += raw;
|
|
218
180
|
continue;
|
|
219
181
|
}
|
|
220
|
-
if (typeof raw !==
|
|
182
|
+
if (typeof raw !== 'object' || raw === null || !('type' in raw))
|
|
221
183
|
continue;
|
|
222
|
-
}
|
|
223
184
|
const seg = raw;
|
|
224
|
-
if (seg.type ===
|
|
225
|
-
|
|
226
|
-
pendingText += t;
|
|
185
|
+
if (seg.type === 'text' || seg.type === 'markdown') {
|
|
186
|
+
pendingText += String(seg.data?.text ?? '');
|
|
227
187
|
continue;
|
|
228
188
|
}
|
|
229
189
|
const filePath = segmentLocalPath(seg);
|
|
230
190
|
if (filePath && fs.existsSync(filePath)) {
|
|
231
|
-
// 微信限制:单条消息不能图文混排 →
|
|
191
|
+
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
232
192
|
if (pendingText.trim()) {
|
|
233
|
-
const textResult = await
|
|
234
|
-
|
|
235
|
-
text: pendingText,
|
|
236
|
-
opts: apiOpts,
|
|
237
|
-
});
|
|
238
|
-
pendingText = "";
|
|
193
|
+
const textResult = await this.client.sendText(target, pendingText);
|
|
194
|
+
pendingText = '';
|
|
239
195
|
lastId = textResult.messageId;
|
|
240
196
|
}
|
|
241
|
-
const result = await
|
|
242
|
-
filePath,
|
|
243
|
-
to: peerId,
|
|
244
|
-
text: "",
|
|
245
|
-
opts: apiOpts,
|
|
246
|
-
cdnBaseUrl: this.cdnBaseUrl(),
|
|
247
|
-
});
|
|
197
|
+
const result = await this.client.sendMedia(target, filePath);
|
|
248
198
|
lastId = result.messageId;
|
|
249
199
|
continue;
|
|
250
200
|
}
|
|
251
|
-
if (seg.type ===
|
|
252
|
-
this.
|
|
201
|
+
if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
|
|
202
|
+
this.#logger.warn(formatCompact({
|
|
203
|
+
op: 'send',
|
|
204
|
+
skip: 'no_local_file',
|
|
205
|
+
type: seg.type,
|
|
206
|
+
}));
|
|
253
207
|
}
|
|
254
208
|
}
|
|
255
209
|
if (pendingText.trim()) {
|
|
256
|
-
const result = await
|
|
257
|
-
to: peerId,
|
|
258
|
-
text: pendingText,
|
|
259
|
-
opts: apiOpts,
|
|
260
|
-
});
|
|
210
|
+
const result = await this.client.sendText(target, pendingText);
|
|
261
211
|
lastId = result.messageId;
|
|
262
212
|
}
|
|
263
213
|
return lastId || `weixin-ilink-${Date.now()}`;
|
|
264
214
|
}
|
|
265
|
-
|
|
266
|
-
|
|
215
|
+
/** Test / internal: admit a parsed message when the endpoint is open. */
|
|
216
|
+
admit(msg) {
|
|
217
|
+
if (!this.#open)
|
|
218
|
+
return;
|
|
219
|
+
const userId = msg.from_user_id ?? '';
|
|
220
|
+
if (msg.context_token && userId) {
|
|
221
|
+
this.#contextTokens.set(userId, msg.context_token);
|
|
222
|
+
}
|
|
223
|
+
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
224
|
+
const replyTo = weixinReplyTo(msg);
|
|
225
|
+
void this.emit('message.receive', {
|
|
226
|
+
conversation,
|
|
227
|
+
message: { conversation, id: inboundMessageId(msg) },
|
|
228
|
+
content: formatInboundContent(msg),
|
|
229
|
+
segments: weixinInboundSegments(msg),
|
|
230
|
+
...(replyTo ? { replyTo } : {}),
|
|
231
|
+
sender: { id: userId },
|
|
232
|
+
endpointId: this.#options.config.id,
|
|
233
|
+
metadata: Object.freeze({
|
|
234
|
+
messageType: msg.message_type,
|
|
235
|
+
createTimeMs: msg.create_time_ms,
|
|
236
|
+
}),
|
|
237
|
+
}).catch((err) => {
|
|
238
|
+
this.#logger.warn(formatCompact({
|
|
239
|
+
op: 'weixin_ilink_gateway_receive_failed',
|
|
240
|
+
target: userId,
|
|
241
|
+
error: err instanceof Error ? err.message : String(err),
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
async #pollLoop(abortSignal) {
|
|
246
|
+
if (!this.#creds?.botToken)
|
|
267
247
|
return;
|
|
268
|
-
let getUpdatesBuf = loadSyncBuf(
|
|
269
|
-
let nextTimeoutMs = this
|
|
248
|
+
let getUpdatesBuf = this.#state.loadSyncBuf();
|
|
249
|
+
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
270
250
|
let consecutiveFailures = 0;
|
|
271
251
|
while (!abortSignal.aborted) {
|
|
272
|
-
if (
|
|
273
|
-
await sleep(
|
|
252
|
+
if (this.#sessionGuard.paused) {
|
|
253
|
+
await sleep(this.#sessionGuard.remainingMs, abortSignal);
|
|
274
254
|
continue;
|
|
275
255
|
}
|
|
276
256
|
try {
|
|
277
|
-
const resp = await getUpdates({
|
|
278
|
-
baseUrl: this.apiBaseUrl
|
|
279
|
-
|
|
257
|
+
const resp = await this.#getUpdates({
|
|
258
|
+
baseUrl: this.client.apiBaseUrl,
|
|
259
|
+
metadata: this.#metadata,
|
|
260
|
+
token: this.#creds.botToken,
|
|
280
261
|
get_updates_buf: getUpdatesBuf,
|
|
281
262
|
timeoutMs: nextTimeoutMs,
|
|
282
263
|
abortSignal,
|
|
@@ -284,19 +265,19 @@ export class WeixinIlinkEndpoint {
|
|
|
284
265
|
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
285
266
|
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
286
267
|
}
|
|
287
|
-
const isApiError = (resp.ret !== undefined && resp.ret !== 0)
|
|
288
|
-
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
268
|
+
const isApiError = (resp.ret !== undefined && resp.ret !== 0)
|
|
269
|
+
|| (resp.errcode !== undefined && resp.errcode !== 0);
|
|
289
270
|
if (isApiError) {
|
|
290
271
|
const sessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
291
272
|
if (sessionExpired) {
|
|
292
|
-
|
|
273
|
+
this.#sessionGuard.pause();
|
|
293
274
|
consecutiveFailures = 0;
|
|
294
|
-
await sleep(
|
|
275
|
+
await sleep(this.#sessionGuard.remainingMs, abortSignal);
|
|
295
276
|
continue;
|
|
296
277
|
}
|
|
297
278
|
consecutiveFailures += 1;
|
|
279
|
+
// 退避后不清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS。
|
|
298
280
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
299
|
-
consecutiveFailures = 0;
|
|
300
281
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
301
282
|
}
|
|
302
283
|
else {
|
|
@@ -305,21 +286,26 @@ export class WeixinIlinkEndpoint {
|
|
|
305
286
|
continue;
|
|
306
287
|
}
|
|
307
288
|
consecutiveFailures = 0;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
saveSyncBuf(this.$id, getUpdatesBuf);
|
|
311
|
-
}
|
|
289
|
+
// 先分发再推进 buf:分发途中崩溃时 buf 未推进,下一轮重拉不丢消息。
|
|
290
|
+
const nextBuf = resp.get_updates_buf;
|
|
312
291
|
for (const inbound of resp.msgs ?? []) {
|
|
313
|
-
await this
|
|
292
|
+
await this.#handleInboundMessage(inbound);
|
|
293
|
+
}
|
|
294
|
+
if (nextBuf) {
|
|
295
|
+
getUpdatesBuf = nextBuf;
|
|
296
|
+
this.#state.saveSyncBuf(getUpdatesBuf);
|
|
314
297
|
}
|
|
315
298
|
}
|
|
316
299
|
catch (err) {
|
|
317
300
|
if (abortSignal.aborted)
|
|
318
301
|
return;
|
|
319
302
|
consecutiveFailures += 1;
|
|
320
|
-
this.
|
|
303
|
+
this.#logger.error(formatCompact({
|
|
304
|
+
op: 'poll',
|
|
305
|
+
ok: false,
|
|
306
|
+
error: err instanceof Error ? err.message : String(err),
|
|
307
|
+
}));
|
|
321
308
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
322
|
-
consecutiveFailures = 0;
|
|
323
309
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
324
310
|
}
|
|
325
311
|
else {
|
|
@@ -328,56 +314,119 @@ export class WeixinIlinkEndpoint {
|
|
|
328
314
|
}
|
|
329
315
|
}
|
|
330
316
|
}
|
|
331
|
-
async handleInboundMessage(full) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
317
|
+
async #handleInboundMessage(full) {
|
|
318
|
+
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
319
|
+
const fromUserId = full.from_user_id ?? '';
|
|
320
|
+
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
321
|
+
this.admit({ ...full, _media: mediaOpts });
|
|
322
|
+
this.#logger.debug(formatCompact({
|
|
323
|
+
op: 'recv',
|
|
324
|
+
endpoint: this.#options.config.id,
|
|
325
|
+
target: fromUserId,
|
|
326
|
+
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
327
|
+
}));
|
|
342
328
|
}
|
|
343
|
-
async
|
|
329
|
+
async #emitPlatformEvent(name, event) {
|
|
330
|
+
await this.emitPlatform(name, event).catch((error) => {
|
|
331
|
+
this.#logger.warn(formatCompact({
|
|
332
|
+
op: 'weixin_ilink_platform_event_failed',
|
|
333
|
+
event: name,
|
|
334
|
+
error: error instanceof Error ? error.message : String(error),
|
|
335
|
+
}));
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async #downloadInboundMedia(full) {
|
|
344
339
|
const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
|
|
345
|
-
const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
|
|
346
|
-
full.item_list?.find((i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media))
|
|
347
|
-
full.item_list?.find((i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media))
|
|
348
|
-
full.item_list?.find((i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media));
|
|
340
|
+
const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
|
|
341
|
+
?? full.item_list?.find((i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media))
|
|
342
|
+
?? full.item_list?.find((i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media))
|
|
343
|
+
?? full.item_list?.find((i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media));
|
|
349
344
|
if (!mediaItem)
|
|
350
345
|
return {};
|
|
351
346
|
return downloadMediaFromItem(mediaItem, {
|
|
352
|
-
cdnBaseUrl: this.cdnBaseUrl
|
|
353
|
-
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this
|
|
354
|
-
log: (msg) => this.
|
|
355
|
-
errLog: (msg) => this.
|
|
356
|
-
label: `inbound:${
|
|
347
|
+
cdnBaseUrl: this.client.cdnBaseUrl,
|
|
348
|
+
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
349
|
+
log: (msg) => this.#logger.debug(msg),
|
|
350
|
+
errLog: (msg) => this.#logger.warn(msg),
|
|
351
|
+
label: `inbound:${full.from_user_id ?? 'unknown'}`,
|
|
357
352
|
});
|
|
358
353
|
}
|
|
359
|
-
async saveInboundMedia(buffer, contentType, subdir =
|
|
354
|
+
async #saveInboundMedia(buffer, contentType, subdir = 'inbound', maxBytes = WEIXIN_MEDIA_MAX_BYTES, originalFilename) {
|
|
360
355
|
if (buffer.length > maxBytes) {
|
|
361
356
|
throw new Error(`media exceeds max size ${maxBytes}`);
|
|
362
357
|
}
|
|
363
|
-
const dir =
|
|
358
|
+
const dir = this.#state.mediaDirectory(subdir);
|
|
364
359
|
fs.mkdirSync(dir, { recursive: true });
|
|
365
360
|
const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
|
|
366
361
|
const ext = originalFilename
|
|
367
362
|
? path.extname(originalFilename)
|
|
368
363
|
: resolvedMime
|
|
369
364
|
? getExtensionFromMime(resolvedMime)
|
|
370
|
-
:
|
|
365
|
+
: '.bin';
|
|
371
366
|
const base = originalFilename
|
|
372
367
|
? path.basename(originalFilename, ext)
|
|
373
|
-
:
|
|
374
|
-
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString(
|
|
368
|
+
: 'media';
|
|
369
|
+
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
|
|
375
370
|
const filePath = path.join(dir, fileName);
|
|
376
371
|
fs.writeFileSync(filePath, buffer);
|
|
377
372
|
return { path: filePath };
|
|
378
373
|
}
|
|
374
|
+
/** 入站媒体 TTL 清扫:start 时立即扫一次,之后每小时扫一次。 */
|
|
375
|
+
#startMediaSweep() {
|
|
376
|
+
this.#stopMediaSweep();
|
|
377
|
+
const sweep = () => this.#sweepInboundMedia();
|
|
378
|
+
sweep();
|
|
379
|
+
this.#mediaSweepTimer = setInterval(sweep, MEDIA_SWEEP_INTERVAL_MS);
|
|
380
|
+
this.#mediaSweepTimer.unref?.();
|
|
381
|
+
}
|
|
382
|
+
#stopMediaSweep() {
|
|
383
|
+
if (this.#mediaSweepTimer) {
|
|
384
|
+
clearInterval(this.#mediaSweepTimer);
|
|
385
|
+
this.#mediaSweepTimer = undefined;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
#sweepInboundMedia() {
|
|
389
|
+
const dir = this.#state.mediaDirectory('inbound');
|
|
390
|
+
let entries;
|
|
391
|
+
try {
|
|
392
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
for (const entry of entries) {
|
|
399
|
+
if (!entry.isFile())
|
|
400
|
+
continue;
|
|
401
|
+
const filePath = path.join(dir, entry.name);
|
|
402
|
+
try {
|
|
403
|
+
const stat = fs.statSync(filePath);
|
|
404
|
+
if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS)
|
|
405
|
+
continue;
|
|
406
|
+
fs.unlinkSync(filePath);
|
|
407
|
+
this.#logger.debug(formatCompact({
|
|
408
|
+
op: 'media_sweep',
|
|
409
|
+
endpoint: this.#options.config.id,
|
|
410
|
+
file: entry.name,
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
/* 单个文件失败不阻塞整体清扫 */
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
379
418
|
}
|
|
380
|
-
function
|
|
381
|
-
return
|
|
419
|
+
function createWeixinIlinkEndpointManagement(requireClient) {
|
|
420
|
+
return Object.freeze({
|
|
421
|
+
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
422
|
+
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
423
|
+
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
424
|
+
async listFriends() {
|
|
425
|
+
return requireClient().reachableUserIds().map((userId) => ({
|
|
426
|
+
user_id: userId,
|
|
427
|
+
nickname: userId,
|
|
428
|
+
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
|
429
|
+
}));
|
|
430
|
+
},
|
|
431
|
+
});
|
|
382
432
|
}
|
|
383
|
-
//# sourceMappingURL=endpoint.js.map
|