@zhin.js/adapter-weixin-ilink 3.0.2 → 4.0.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/CHANGELOG.md +50 -0
- package/README.md +18 -40
- package/adapters/weixin-ilink.ts +30 -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/context-store.d.ts +0 -1
- package/lib/context-store.js +0 -1
- package/lib/credentials.d.ts +0 -1
- package/lib/credentials.js +0 -1
- package/lib/endpoint.d.ts +35 -30
- package/lib/endpoint.js +217 -235
- package/lib/ilink-api.d.ts +0 -1
- package/lib/ilink-api.js +0 -1
- package/lib/ilink-config-cache.d.ts +0 -1
- package/lib/ilink-config-cache.js +0 -1
- package/lib/ilink-logger.d.ts +0 -1
- package/lib/ilink-logger.js +0 -1
- package/lib/ilink-meta.d.ts +0 -1
- package/lib/ilink-meta.js +0 -1
- 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 +0 -1
- package/lib/ilink-session-guard.js +0 -1
- package/lib/ilink-types.d.ts +0 -1
- package/lib/ilink-types.js +0 -1
- package/lib/index.d.ts +4 -19
- package/lib/index.js +2 -37
- package/lib/login.d.ts +4 -6
- package/lib/login.js +43 -65
- package/lib/media-download.d.ts +0 -1
- package/lib/media-download.js +0 -1
- package/lib/mime.d.ts +0 -1
- package/lib/mime.js +0 -1
- package/lib/outbound-media.d.ts +4 -4
- package/lib/outbound-media.js +44 -42
- package/lib/pic-decrypt.d.ts +0 -1
- package/lib/pic-decrypt.js +0 -1
- package/lib/protocol.d.ts +52 -0
- package/lib/protocol.js +109 -0
- package/lib/silk-transcode.d.ts +0 -1
- package/lib/silk-transcode.js +0 -1
- package/lib/types.d.ts +2 -16
- package/lib/types.js +0 -1
- package/lib/upload.d.ts +0 -1
- package/lib/upload.js +0 -1
- package/lib/weixin-inbound.d.ts +0 -1
- package/lib/weixin-inbound.js +0 -1
- package/lib/weixin-send-media.d.ts +0 -1
- package/lib/weixin-send-media.js +0 -1
- package/lib/weixin-send.d.ts +0 -1
- package/lib/weixin-send.js +0 -1
- package/package.json +37 -46
- package/plugin.ts +8 -0
- package/schema.json +52 -0
- package/src/endpoint.ts +261 -279
- package/src/index.ts +24 -53
- package/src/login.ts +56 -72
- package/src/outbound-media.ts +56 -50
- package/src/protocol.ts +172 -0
- package/src/types.ts +6 -15
- 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 -14
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -21
- 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/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.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/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 -24
- package/src/login-assist-routes.ts +0 -36
- package/src/routes.ts +0 -39
- package/src/segment-mapper.ts +0 -1
package/lib/endpoint.js
CHANGED
|
@@ -1,259 +1,188 @@
|
|
|
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 { getUpdates, notifyStart, notifyStop, sendTyping } from
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { materializeOutboundMedia, segmentMediaRef } from "./outbound-media.js";
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
|
+
import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
|
|
9
|
+
import { configureIlinkMeta } from './ilink-meta.js';
|
|
10
|
+
import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from './credentials.js';
|
|
11
|
+
import { resolveCredentials } from './login.js';
|
|
12
|
+
import { getContextToken, restoreContextTokens, setContextToken, } from './context-store.js';
|
|
13
|
+
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
14
|
+
import { getRemainingPauseMs, isSessionPaused, pauseSession, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
|
|
15
|
+
import { downloadMediaFromItem } from './media-download.js';
|
|
16
|
+
import { sendMessageWeixin } from './weixin-send.js';
|
|
17
|
+
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
18
|
+
import { materializeOutboundMedia } from './outbound-media.js';
|
|
20
19
|
import { MessageItemType } from './ilink-types.js';
|
|
21
|
-
import { getExtensionFromMime, sniffMimeFromBuffer } from
|
|
22
|
-
|
|
20
|
+
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
21
|
+
import { formatInboundContent, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, } from './protocol.js';
|
|
22
|
+
const logger = getLogger('weixin-ilink');
|
|
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
|
-
function sleep(ms, signal) {
|
|
28
|
-
return new Promise((resolve) => {
|
|
29
|
-
if (signal?.aborted) {
|
|
30
|
-
resolve();
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const timer = setTimeout(resolve, ms);
|
|
34
|
-
signal?.addEventListener("abort", () => {
|
|
35
|
-
clearTimeout(timer);
|
|
36
|
-
resolve();
|
|
37
|
-
}, { once: true });
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
function segmentLocalPath(seg) {
|
|
41
|
-
const ref = segmentMediaRef(seg);
|
|
42
|
-
if (!ref || ref.startsWith("base64://") || /^data:/.test(ref) || /^https?:\/\//i.test(ref)) {
|
|
43
|
-
return undefined;
|
|
44
|
-
}
|
|
45
|
-
return ref.replace(/^file:\/\//, "");
|
|
46
|
-
}
|
|
47
27
|
export class WeixinIlinkEndpoint {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
28
|
+
#options;
|
|
29
|
+
#resolveCredentials;
|
|
30
|
+
#notifyStart;
|
|
31
|
+
#notifyStop;
|
|
32
|
+
#getUpdates;
|
|
33
|
+
#sendText;
|
|
34
|
+
#creds = null;
|
|
35
|
+
#pollAbort;
|
|
36
|
+
#pollPromise;
|
|
37
|
+
#configManager;
|
|
38
|
+
#open = false;
|
|
39
|
+
#started = false;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.#options = options;
|
|
42
|
+
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
43
|
+
this.#notifyStart = options.notifyStart ?? notifyStart;
|
|
44
|
+
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
45
|
+
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
46
|
+
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
57
47
|
}
|
|
58
48
|
get hasCredentials() {
|
|
59
|
-
return Boolean(this
|
|
60
|
-
}
|
|
61
|
-
get pluginLogger() {
|
|
62
|
-
return this.adapter.plugin.logger;
|
|
63
|
-
}
|
|
64
|
-
constructor(adapter, $config) {
|
|
65
|
-
this.adapter = adapter;
|
|
66
|
-
this.$config = $config;
|
|
49
|
+
return Boolean(this.#creds?.botToken);
|
|
67
50
|
}
|
|
68
51
|
apiBaseUrl() {
|
|
69
|
-
return this
|
|
52
|
+
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
70
53
|
}
|
|
71
54
|
cdnBaseUrl() {
|
|
72
|
-
return this
|
|
55
|
+
return this.#options.config.cdnBaseUrl;
|
|
73
56
|
}
|
|
74
|
-
async
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
57
|
+
async start() {
|
|
58
|
+
if (this.#started)
|
|
59
|
+
return;
|
|
60
|
+
this.#started = true;
|
|
61
|
+
try {
|
|
62
|
+
configureIlinkMeta({ botAgent: this.#options.config.botAgent });
|
|
63
|
+
this.#creds = await this.#resolveCredentials(this.#options.config);
|
|
64
|
+
restoreContextTokens(this.#options.config.name);
|
|
65
|
+
await this.#notifyStart({
|
|
66
|
+
baseUrl: this.apiBaseUrl(),
|
|
67
|
+
token: this.#creds.botToken,
|
|
68
|
+
});
|
|
69
|
+
this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken }, (msg) => logger.debug(msg));
|
|
70
|
+
this.#pollAbort = new AbortController();
|
|
71
|
+
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
72
|
+
logger.info(formatCompact({
|
|
73
|
+
op: 'connect',
|
|
74
|
+
endpoint: this.#options.config.name,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
await this.stop();
|
|
79
|
+
logger.error('Failed to connect weixin-ilink bot:', error);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
open() {
|
|
84
|
+
this.#open = true;
|
|
85
|
+
}
|
|
86
|
+
close() {
|
|
87
|
+
this.#open = false;
|
|
87
88
|
}
|
|
88
|
-
async
|
|
89
|
-
this
|
|
89
|
+
async stop() {
|
|
90
|
+
this.#open = false;
|
|
91
|
+
this.#pollAbort?.abort();
|
|
90
92
|
try {
|
|
91
|
-
await this
|
|
93
|
+
await this.#pollPromise;
|
|
92
94
|
}
|
|
93
95
|
catch {
|
|
94
96
|
/* poll loop exit */
|
|
95
97
|
}
|
|
96
|
-
if (this
|
|
98
|
+
if (this.#creds?.botToken) {
|
|
97
99
|
try {
|
|
98
|
-
await notifyStop({ baseUrl: this.apiBaseUrl(), token: this
|
|
100
|
+
await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
|
|
99
101
|
}
|
|
100
102
|
catch (err) {
|
|
101
|
-
|
|
103
|
+
logger.warn(formatCompact({
|
|
104
|
+
op: 'notify_stop',
|
|
105
|
+
ok: false,
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
}));
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
|
-
this
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return;
|
|
110
|
-
const contextToken = getContextToken(this.$id, userId);
|
|
111
|
-
const cfg = await this.configManager.getForUser(userId, contextToken);
|
|
112
|
-
if (!cfg.typingTicket) {
|
|
113
|
-
this.pluginLogger.debug(`sendTyping: no typing_ticket for user=${userId}`);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
await sendTyping({
|
|
117
|
-
baseUrl: this.apiBaseUrl(),
|
|
118
|
-
token: this.creds.botToken,
|
|
119
|
-
body: {
|
|
120
|
-
ilink_user_id: userId,
|
|
121
|
-
typing_ticket: cfg.typingTicket,
|
|
122
|
-
status,
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
async $recallMessage(_id) {
|
|
127
|
-
this.pluginLogger.debug("weixin-ilink does not support message recall");
|
|
128
|
-
}
|
|
129
|
-
$formatMessage(msg) {
|
|
130
|
-
const userId = msg.from_user_id ?? "";
|
|
131
|
-
const wire = this.buildInboundContent(msg);
|
|
132
|
-
const quoteId = Message.quoteIdFromContent(wire);
|
|
133
|
-
Message.alignReplySegments(wire, quoteId);
|
|
134
|
-
const content = toCanonicalSegments(wire);
|
|
135
|
-
const msgId = String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
136
|
-
return Message.from(msg, {
|
|
137
|
-
$id: msgId,
|
|
138
|
-
$adapter: "weixin-ilink",
|
|
139
|
-
$endpoint: this.$config.name,
|
|
140
|
-
$sender: {
|
|
141
|
-
id: userId,
|
|
142
|
-
name: userId,
|
|
143
|
-
},
|
|
144
|
-
$channel: {
|
|
145
|
-
id: userId,
|
|
146
|
-
type: "private",
|
|
147
|
-
},
|
|
148
|
-
$content: content,
|
|
149
|
-
$quote_id: quoteId,
|
|
150
|
-
$raw: bodyFromItemList(msg.item_list),
|
|
151
|
-
$timestamp: msg.create_time_ms ?? Date.now(),
|
|
152
|
-
$recall: async () => this.$recallMessage(msgId),
|
|
153
|
-
$reply: async (replyContent, _quote) => {
|
|
154
|
-
if (!Array.isArray(replyContent))
|
|
155
|
-
replyContent = [replyContent];
|
|
156
|
-
return this.adapter.sendMessage({
|
|
157
|
-
context: "weixin-ilink",
|
|
158
|
-
endpoint: this.$config.name,
|
|
159
|
-
id: userId,
|
|
160
|
-
type: "private",
|
|
161
|
-
content: replyContent,
|
|
162
|
-
});
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
buildInboundContent(msg) {
|
|
167
|
-
const segments = [];
|
|
168
|
-
const text = bodyFromItemList(msg.item_list);
|
|
169
|
-
if (text)
|
|
170
|
-
segments.push(segment.text(text));
|
|
171
|
-
const media = msg._media;
|
|
172
|
-
if (media?.decryptedPicPath) {
|
|
173
|
-
segments.push(segment("image", { file: media.decryptedPicPath }));
|
|
174
|
-
}
|
|
175
|
-
else if (media?.decryptedVideoPath) {
|
|
176
|
-
segments.push(segment("video", { file: media.decryptedVideoPath }));
|
|
177
|
-
}
|
|
178
|
-
else if (media?.decryptedFilePath) {
|
|
179
|
-
segments.push(segment("file", {
|
|
180
|
-
file: media.decryptedFilePath,
|
|
181
|
-
name: path.basename(media.decryptedFilePath),
|
|
182
|
-
}));
|
|
183
|
-
}
|
|
184
|
-
else if (media?.decryptedVoicePath) {
|
|
185
|
-
segments.push(segment("record", { file: media.decryptedVoicePath }));
|
|
186
|
-
}
|
|
187
|
-
else if (!text && msg.item_list?.some((i) => isMediaItem(i))) {
|
|
188
|
-
segments.push(segment.text("[媒体消息]"));
|
|
189
|
-
}
|
|
190
|
-
return segments.length ? segments : [segment.text("")];
|
|
110
|
+
this.#started = false;
|
|
111
|
+
logger.debug(formatCompact({
|
|
112
|
+
op: 'disconnect',
|
|
113
|
+
endpoint: this.#options.config.name,
|
|
114
|
+
}));
|
|
191
115
|
}
|
|
192
|
-
async
|
|
193
|
-
if (!this
|
|
194
|
-
throw new Error(
|
|
116
|
+
async send({ target, payload }) {
|
|
117
|
+
if (!this.#creds?.botToken) {
|
|
118
|
+
throw new Error('weixin-ilink bot not authenticated');
|
|
195
119
|
}
|
|
196
|
-
const
|
|
197
|
-
const contextToken = getContextToken(this.$id, peerId);
|
|
120
|
+
const contextToken = getContextToken(this.#options.config.name, target);
|
|
198
121
|
if (!contextToken) {
|
|
199
|
-
|
|
200
|
-
|
|
122
|
+
logger.warn(formatCompact({
|
|
123
|
+
op: 'send',
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: 'missing_context_token',
|
|
126
|
+
target,
|
|
127
|
+
}));
|
|
128
|
+
throw new Error(`missing context_token for peer ${target}`);
|
|
201
129
|
}
|
|
202
130
|
const apiOpts = {
|
|
203
131
|
baseUrl: this.apiBaseUrl(),
|
|
204
|
-
token: this
|
|
132
|
+
token: this.#creds.botToken,
|
|
205
133
|
contextToken,
|
|
206
134
|
};
|
|
207
|
-
const outboundDir = path.join(resolveStateDir(),
|
|
208
|
-
const
|
|
209
|
-
const wire = fromCanonicalSegments((Array.isArray(canonical) ? canonical : [canonical]).map((s) => typeof s === 'string' ? { type: 'text', data: { text: s } } : s));
|
|
135
|
+
const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
|
|
136
|
+
const wire = formatOutboundSegments(payload);
|
|
210
137
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
211
138
|
const segments = Array.isArray(materialized) ? materialized : [materialized];
|
|
212
|
-
let lastId =
|
|
213
|
-
let pendingText =
|
|
139
|
+
let lastId = '';
|
|
140
|
+
let pendingText = '';
|
|
214
141
|
for (const raw of segments) {
|
|
215
|
-
if (typeof raw ===
|
|
142
|
+
if (typeof raw === 'string') {
|
|
216
143
|
pendingText += raw;
|
|
217
144
|
continue;
|
|
218
145
|
}
|
|
219
|
-
if (typeof raw !==
|
|
146
|
+
if (typeof raw !== 'object' || raw === null || !('type' in raw))
|
|
220
147
|
continue;
|
|
221
|
-
}
|
|
222
148
|
const seg = raw;
|
|
223
|
-
if (seg.type ===
|
|
224
|
-
|
|
225
|
-
pendingText += t;
|
|
149
|
+
if (seg.type === 'text' || seg.type === 'markdown') {
|
|
150
|
+
pendingText += String(seg.data?.text ?? '');
|
|
226
151
|
continue;
|
|
227
152
|
}
|
|
228
153
|
const filePath = segmentLocalPath(seg);
|
|
229
154
|
if (filePath && fs.existsSync(filePath)) {
|
|
230
|
-
// 微信限制:单条消息不能图文混排 →
|
|
155
|
+
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
231
156
|
if (pendingText.trim()) {
|
|
232
|
-
const textResult = await
|
|
233
|
-
to:
|
|
157
|
+
const textResult = await this.#sendText({
|
|
158
|
+
to: target,
|
|
234
159
|
text: pendingText,
|
|
235
160
|
opts: apiOpts,
|
|
236
161
|
});
|
|
237
|
-
pendingText =
|
|
162
|
+
pendingText = '';
|
|
238
163
|
lastId = textResult.messageId;
|
|
239
164
|
}
|
|
240
165
|
const result = await sendWeixinMediaFile({
|
|
241
166
|
filePath,
|
|
242
|
-
to:
|
|
243
|
-
text:
|
|
167
|
+
to: target,
|
|
168
|
+
text: '',
|
|
244
169
|
opts: apiOpts,
|
|
245
170
|
cdnBaseUrl: this.cdnBaseUrl(),
|
|
246
171
|
});
|
|
247
172
|
lastId = result.messageId;
|
|
248
173
|
continue;
|
|
249
174
|
}
|
|
250
|
-
if (seg.type ===
|
|
251
|
-
|
|
175
|
+
if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
|
|
176
|
+
logger.warn(formatCompact({
|
|
177
|
+
op: 'send',
|
|
178
|
+
skip: 'no_local_file',
|
|
179
|
+
type: seg.type,
|
|
180
|
+
}));
|
|
252
181
|
}
|
|
253
182
|
}
|
|
254
183
|
if (pendingText.trim()) {
|
|
255
|
-
const result = await
|
|
256
|
-
to:
|
|
184
|
+
const result = await this.#sendText({
|
|
185
|
+
to: target,
|
|
257
186
|
text: pendingText,
|
|
258
187
|
opts: apiOpts,
|
|
259
188
|
});
|
|
@@ -261,21 +190,72 @@ export class WeixinIlinkEndpoint {
|
|
|
261
190
|
}
|
|
262
191
|
return lastId || `weixin-ilink-${Date.now()}`;
|
|
263
192
|
}
|
|
264
|
-
|
|
265
|
-
|
|
193
|
+
/** Test / internal: admit a parsed message when the endpoint is open. */
|
|
194
|
+
admit(msg) {
|
|
195
|
+
if (!this.#open)
|
|
196
|
+
return;
|
|
197
|
+
const userId = msg.from_user_id ?? '';
|
|
198
|
+
if (msg.context_token && userId) {
|
|
199
|
+
setContextToken(this.#options.config.name, userId, msg.context_token);
|
|
200
|
+
}
|
|
201
|
+
void this.#options.gateway.receive({
|
|
202
|
+
adapter: this.#options.id,
|
|
203
|
+
target: userId,
|
|
204
|
+
content: formatInboundContent(msg),
|
|
205
|
+
sender: userId,
|
|
206
|
+
id: inboundMessageId(msg),
|
|
207
|
+
metadata: Object.freeze({
|
|
208
|
+
endpoint: this.#options.config.name,
|
|
209
|
+
messageType: msg.message_type,
|
|
210
|
+
createTimeMs: msg.create_time_ms,
|
|
211
|
+
}),
|
|
212
|
+
}).catch((err) => {
|
|
213
|
+
logger.warn(formatCompact({
|
|
214
|
+
op: 'weixin_ilink_gateway_receive_failed',
|
|
215
|
+
target: userId,
|
|
216
|
+
error: err instanceof Error ? err.message : String(err),
|
|
217
|
+
}));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async sendTypingToUser(userId, status) {
|
|
221
|
+
if (!this.#creds?.botToken || !this.#configManager)
|
|
222
|
+
return;
|
|
223
|
+
const contextToken = getContextToken(this.#options.config.name, userId);
|
|
224
|
+
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
225
|
+
if (!cfg.typingTicket) {
|
|
226
|
+
logger.debug(formatCompact({
|
|
227
|
+
op: 'send_typing',
|
|
228
|
+
skip: 'no_typing_ticket',
|
|
229
|
+
userId,
|
|
230
|
+
}));
|
|
266
231
|
return;
|
|
267
|
-
|
|
268
|
-
|
|
232
|
+
}
|
|
233
|
+
await sendTyping({
|
|
234
|
+
baseUrl: this.apiBaseUrl(),
|
|
235
|
+
token: this.#creds.botToken,
|
|
236
|
+
body: {
|
|
237
|
+
ilink_user_id: userId,
|
|
238
|
+
typing_ticket: cfg.typingTicket,
|
|
239
|
+
status,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async #pollLoop(abortSignal) {
|
|
244
|
+
if (!this.#creds?.botToken)
|
|
245
|
+
return;
|
|
246
|
+
const endpointName = this.#options.config.name;
|
|
247
|
+
let getUpdatesBuf = loadSyncBuf(endpointName);
|
|
248
|
+
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
269
249
|
let consecutiveFailures = 0;
|
|
270
250
|
while (!abortSignal.aborted) {
|
|
271
|
-
if (isSessionPaused(
|
|
272
|
-
await sleep(getRemainingPauseMs(
|
|
251
|
+
if (isSessionPaused(endpointName)) {
|
|
252
|
+
await sleep(getRemainingPauseMs(endpointName), abortSignal);
|
|
273
253
|
continue;
|
|
274
254
|
}
|
|
275
255
|
try {
|
|
276
|
-
const resp = await getUpdates({
|
|
256
|
+
const resp = await this.#getUpdates({
|
|
277
257
|
baseUrl: this.apiBaseUrl(),
|
|
278
|
-
token: this
|
|
258
|
+
token: this.#creds.botToken,
|
|
279
259
|
get_updates_buf: getUpdatesBuf,
|
|
280
260
|
timeoutMs: nextTimeoutMs,
|
|
281
261
|
abortSignal,
|
|
@@ -283,14 +263,14 @@ export class WeixinIlinkEndpoint {
|
|
|
283
263
|
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
284
264
|
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
285
265
|
}
|
|
286
|
-
const isApiError = (resp.ret !== undefined && resp.ret !== 0)
|
|
287
|
-
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
266
|
+
const isApiError = (resp.ret !== undefined && resp.ret !== 0)
|
|
267
|
+
|| (resp.errcode !== undefined && resp.errcode !== 0);
|
|
288
268
|
if (isApiError) {
|
|
289
269
|
const sessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
290
270
|
if (sessionExpired) {
|
|
291
|
-
pauseSession(
|
|
271
|
+
pauseSession(endpointName);
|
|
292
272
|
consecutiveFailures = 0;
|
|
293
|
-
await sleep(getRemainingPauseMs(
|
|
273
|
+
await sleep(getRemainingPauseMs(endpointName), abortSignal);
|
|
294
274
|
continue;
|
|
295
275
|
}
|
|
296
276
|
consecutiveFailures += 1;
|
|
@@ -306,17 +286,21 @@ export class WeixinIlinkEndpoint {
|
|
|
306
286
|
consecutiveFailures = 0;
|
|
307
287
|
if (resp.get_updates_buf) {
|
|
308
288
|
getUpdatesBuf = resp.get_updates_buf;
|
|
309
|
-
saveSyncBuf(
|
|
289
|
+
saveSyncBuf(endpointName, getUpdatesBuf);
|
|
310
290
|
}
|
|
311
291
|
for (const inbound of resp.msgs ?? []) {
|
|
312
|
-
await this
|
|
292
|
+
await this.#handleInboundMessage(inbound);
|
|
313
293
|
}
|
|
314
294
|
}
|
|
315
295
|
catch (err) {
|
|
316
296
|
if (abortSignal.aborted)
|
|
317
297
|
return;
|
|
318
298
|
consecutiveFailures += 1;
|
|
319
|
-
|
|
299
|
+
logger.error(formatCompact({
|
|
300
|
+
op: 'poll',
|
|
301
|
+
ok: false,
|
|
302
|
+
error: err instanceof Error ? err.message : String(err),
|
|
303
|
+
}));
|
|
320
304
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
321
305
|
consecutiveFailures = 0;
|
|
322
306
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
@@ -327,56 +311,54 @@ export class WeixinIlinkEndpoint {
|
|
|
327
311
|
}
|
|
328
312
|
}
|
|
329
313
|
}
|
|
330
|
-
async handleInboundMessage(full) {
|
|
331
|
-
const fromUserId = full.from_user_id ??
|
|
314
|
+
async #handleInboundMessage(full) {
|
|
315
|
+
const fromUserId = full.from_user_id ?? '';
|
|
332
316
|
if (full.context_token && fromUserId) {
|
|
333
|
-
setContextToken(this
|
|
317
|
+
setContextToken(this.#options.config.name, fromUserId, full.context_token);
|
|
334
318
|
}
|
|
335
|
-
const mediaOpts = await this
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
319
|
+
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
320
|
+
this.admit({ ...full, _media: mediaOpts });
|
|
321
|
+
logger.debug(formatCompact({
|
|
322
|
+
op: 'recv',
|
|
323
|
+
endpoint: this.#options.config.name,
|
|
324
|
+
target: fromUserId,
|
|
325
|
+
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
326
|
+
}));
|
|
341
327
|
}
|
|
342
|
-
async downloadInboundMedia(full) {
|
|
328
|
+
async #downloadInboundMedia(full) {
|
|
343
329
|
const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
|
|
344
|
-
const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
|
|
345
|
-
full.item_list?.find((i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media))
|
|
346
|
-
full.item_list?.find((i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media))
|
|
347
|
-
full.item_list?.find((i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media));
|
|
330
|
+
const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
|
|
331
|
+
?? full.item_list?.find((i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media))
|
|
332
|
+
?? full.item_list?.find((i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media))
|
|
333
|
+
?? full.item_list?.find((i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media));
|
|
348
334
|
if (!mediaItem)
|
|
349
335
|
return {};
|
|
350
336
|
return downloadMediaFromItem(mediaItem, {
|
|
351
337
|
cdnBaseUrl: this.cdnBaseUrl(),
|
|
352
|
-
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this
|
|
353
|
-
log: (msg) =>
|
|
354
|
-
errLog: (msg) =>
|
|
355
|
-
label: `inbound:${
|
|
338
|
+
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
339
|
+
log: (msg) => logger.debug(msg),
|
|
340
|
+
errLog: (msg) => logger.warn(msg),
|
|
341
|
+
label: `inbound:${full.from_user_id ?? 'unknown'}`,
|
|
356
342
|
});
|
|
357
343
|
}
|
|
358
|
-
async saveInboundMedia(buffer, contentType, subdir =
|
|
344
|
+
async #saveInboundMedia(buffer, contentType, subdir = 'inbound', maxBytes = WEIXIN_MEDIA_MAX_BYTES, originalFilename) {
|
|
359
345
|
if (buffer.length > maxBytes) {
|
|
360
346
|
throw new Error(`media exceeds max size ${maxBytes}`);
|
|
361
347
|
}
|
|
362
|
-
const dir = path.join(resolveStateDir(),
|
|
348
|
+
const dir = path.join(resolveStateDir(), 'media', subdir);
|
|
363
349
|
fs.mkdirSync(dir, { recursive: true });
|
|
364
350
|
const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
|
|
365
351
|
const ext = originalFilename
|
|
366
352
|
? path.extname(originalFilename)
|
|
367
353
|
: resolvedMime
|
|
368
354
|
? getExtensionFromMime(resolvedMime)
|
|
369
|
-
:
|
|
355
|
+
: '.bin';
|
|
370
356
|
const base = originalFilename
|
|
371
357
|
? path.basename(originalFilename, ext)
|
|
372
|
-
:
|
|
373
|
-
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString(
|
|
358
|
+
: 'media';
|
|
359
|
+
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
|
|
374
360
|
const filePath = path.join(dir, fileName);
|
|
375
361
|
fs.writeFileSync(filePath, buffer);
|
|
376
362
|
return { path: filePath };
|
|
377
363
|
}
|
|
378
364
|
}
|
|
379
|
-
function fromUserIdLabel(msg) {
|
|
380
|
-
return msg.from_user_id ?? "unknown";
|
|
381
|
-
}
|
|
382
|
-
//# sourceMappingURL=endpoint.js.map
|
package/lib/ilink-api.d.ts
CHANGED
|
@@ -78,4 +78,3 @@ export declare function notifyStop(params: WeixinApiOptions): Promise<NotifyStop
|
|
|
78
78
|
* Notify Weixin that this channel client is starting (gateway startup / channel start).
|
|
79
79
|
*/
|
|
80
80
|
export declare function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp>;
|
|
81
|
-
//# sourceMappingURL=ilink-api.d.ts.map
|
package/lib/ilink-api.js
CHANGED
package/lib/ilink-logger.d.ts
CHANGED
package/lib/ilink-logger.js
CHANGED
package/lib/ilink-meta.d.ts
CHANGED
|
@@ -10,4 +10,3 @@ export declare function buildClientVersion(version: string): number;
|
|
|
10
10
|
export declare const ILINK_APP_CLIENT_VERSION: number;
|
|
11
11
|
export declare function sanitizeBotAgent(raw: string | undefined): string;
|
|
12
12
|
export declare function buildBaseInfo(): BaseInfo;
|
|
13
|
-
//# sourceMappingURL=ilink-meta.d.ts.map
|
package/lib/ilink-meta.js
CHANGED
package/lib/ilink-random.d.ts
CHANGED
package/lib/ilink-random.js
CHANGED
package/lib/ilink-redact.d.ts
CHANGED