@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/src/endpoint.ts
CHANGED
|
@@ -1,309 +1,236 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
segment,
|
|
14
|
-
expandInteractiveSegmentsInContent,} from 'zhin.js';
|
|
15
|
-
import { getUpdates, notifyStart, notifyStop, sendTyping } from "./ilink-api.js";
|
|
16
|
-
import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
|
|
17
|
-
import {
|
|
18
|
-
configureIlinkMeta,
|
|
19
|
-
DEFAULT_API_BASE_URL,
|
|
20
|
-
DEFAULT_CDN_BASE_URL,
|
|
21
|
-
} from "./ilink-meta.js";
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
8
|
+
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
9
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
|
+
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
11
|
+
import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
|
|
12
|
+
import { configureIlinkMeta } from './ilink-meta.js';
|
|
22
13
|
import {
|
|
23
14
|
loadSyncBuf,
|
|
24
15
|
resolveStateDir,
|
|
25
16
|
saveSyncBuf,
|
|
26
17
|
type WeixinIlinkCredentials,
|
|
27
|
-
} from
|
|
28
|
-
import { resolveCredentials } from
|
|
18
|
+
} from './credentials.js';
|
|
19
|
+
import { resolveCredentials } from './login.js';
|
|
29
20
|
import {
|
|
30
|
-
bodyFromItemList,
|
|
31
21
|
getContextToken,
|
|
32
|
-
isMediaItem,
|
|
33
22
|
restoreContextTokens,
|
|
34
23
|
setContextToken,
|
|
35
|
-
} from
|
|
36
|
-
import { WeixinConfigManager } from
|
|
24
|
+
} from './context-store.js';
|
|
25
|
+
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
37
26
|
import {
|
|
38
27
|
getRemainingPauseMs,
|
|
39
28
|
isSessionPaused,
|
|
40
29
|
pauseSession,
|
|
41
30
|
SESSION_EXPIRED_ERRCODE,
|
|
42
|
-
} from
|
|
43
|
-
import { downloadMediaFromItem } from
|
|
44
|
-
import { sendMessageWeixin } from
|
|
45
|
-
import { sendWeixinMediaFile } from
|
|
46
|
-
import { materializeOutboundMedia
|
|
47
|
-
import { MessageItemType, type
|
|
48
|
-
|
|
49
|
-
import {
|
|
50
|
-
import
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
31
|
+
} from './ilink-session-guard.js';
|
|
32
|
+
import { downloadMediaFromItem } from './media-download.js';
|
|
33
|
+
import { sendMessageWeixin } from './weixin-send.js';
|
|
34
|
+
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
35
|
+
import { materializeOutboundMedia } from './outbound-media.js';
|
|
36
|
+
import { MessageItemType, type WeixinMessage } from './ilink-types.js';
|
|
37
|
+
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
38
|
+
import type { WeixinInboundMediaOpts } from './weixin-inbound.js';
|
|
39
|
+
import {
|
|
40
|
+
formatInboundContent,
|
|
41
|
+
formatOutboundSegments,
|
|
42
|
+
inboundMessageId,
|
|
43
|
+
segmentLocalPath,
|
|
44
|
+
sleep,
|
|
45
|
+
type ResolvedWeixinIlinkConfig,
|
|
46
|
+
type WeixinMessageWithMedia,
|
|
47
|
+
type WeixinWireSegment,
|
|
48
|
+
} from './protocol.js';
|
|
49
|
+
|
|
50
|
+
const logger = getLogger('weixin-ilink');
|
|
51
|
+
|
|
55
52
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
56
53
|
const BACKOFF_DELAY_MS = 30_000;
|
|
57
54
|
const RETRY_DELAY_MS = 2_000;
|
|
58
55
|
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
59
56
|
|
|
60
|
-
export type
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{ once: true },
|
|
78
|
-
);
|
|
79
|
-
});
|
|
57
|
+
export type WeixinIlinkSendText = typeof sendMessageWeixin;
|
|
58
|
+
export type WeixinIlinkNotifyStart = typeof notifyStart;
|
|
59
|
+
export type WeixinIlinkNotifyStop = typeof notifyStop;
|
|
60
|
+
export type WeixinIlinkGetUpdates = typeof getUpdates;
|
|
61
|
+
|
|
62
|
+
export interface WeixinIlinkEndpointOptions {
|
|
63
|
+
readonly id: CapabilityId;
|
|
64
|
+
readonly gateway: MessageGateway;
|
|
65
|
+
readonly config: ResolvedWeixinIlinkConfig;
|
|
66
|
+
readonly resolveCredentials?: (
|
|
67
|
+
config: ResolvedWeixinIlinkConfig,
|
|
68
|
+
) => Promise<WeixinIlinkCredentials>;
|
|
69
|
+
/** Test / internal: override network side effects. */
|
|
70
|
+
readonly notifyStart?: WeixinIlinkNotifyStart;
|
|
71
|
+
readonly notifyStop?: WeixinIlinkNotifyStop;
|
|
72
|
+
readonly getUpdates?: WeixinIlinkGetUpdates;
|
|
73
|
+
readonly sendText?: WeixinIlinkSendText;
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
76
|
+
export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
77
|
+
readonly #options: WeixinIlinkEndpointOptions;
|
|
78
|
+
readonly #resolveCredentials: (
|
|
79
|
+
config: ResolvedWeixinIlinkConfig,
|
|
80
|
+
) => Promise<WeixinIlinkCredentials>;
|
|
81
|
+
readonly #notifyStart: WeixinIlinkNotifyStart;
|
|
82
|
+
readonly #notifyStop: WeixinIlinkNotifyStop;
|
|
83
|
+
readonly #getUpdates: WeixinIlinkGetUpdates;
|
|
84
|
+
readonly #sendText: WeixinIlinkSendText;
|
|
85
|
+
#creds: WeixinIlinkCredentials | null = null;
|
|
86
|
+
#pollAbort?: AbortController;
|
|
87
|
+
#pollPromise?: Promise<void>;
|
|
88
|
+
#configManager?: WeixinConfigManager;
|
|
89
|
+
#open = false;
|
|
90
|
+
#started = false;
|
|
91
|
+
|
|
92
|
+
constructor(options: WeixinIlinkEndpointOptions) {
|
|
93
|
+
this.#options = options;
|
|
94
|
+
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
95
|
+
this.#notifyStart = options.notifyStart ?? notifyStart;
|
|
96
|
+
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
97
|
+
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
98
|
+
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
101
|
get hasCredentials(): boolean {
|
|
103
|
-
return Boolean(this
|
|
102
|
+
return Boolean(this.#creds?.botToken);
|
|
104
103
|
}
|
|
105
104
|
|
|
106
|
-
get pluginLogger() {
|
|
107
|
-
return this.adapter.plugin.logger;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
constructor(
|
|
111
|
-
public adapter: WeixinIlinkAdapter,
|
|
112
|
-
public $config: WeixinIlinkEndpointConfig,
|
|
113
|
-
) {}
|
|
114
|
-
|
|
115
105
|
apiBaseUrl(): string {
|
|
116
|
-
return this
|
|
106
|
+
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
117
107
|
}
|
|
118
108
|
|
|
119
109
|
cdnBaseUrl(): string {
|
|
120
|
-
return this
|
|
110
|
+
return this.#options.config.cdnBaseUrl;
|
|
121
111
|
}
|
|
122
112
|
|
|
123
|
-
async
|
|
124
|
-
|
|
125
|
-
this
|
|
126
|
-
|
|
113
|
+
async start(): Promise<void> {
|
|
114
|
+
if (this.#started) return;
|
|
115
|
+
this.#started = true;
|
|
116
|
+
try {
|
|
117
|
+
configureIlinkMeta({ botAgent: this.#options.config.botAgent });
|
|
118
|
+
this.#creds = await this.#resolveCredentials(this.#options.config);
|
|
119
|
+
restoreContextTokens(this.#options.config.name);
|
|
127
120
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
121
|
+
await this.#notifyStart({
|
|
122
|
+
baseUrl: this.apiBaseUrl(),
|
|
123
|
+
token: this.#creds.botToken,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.#configManager = new WeixinConfigManager(
|
|
127
|
+
{ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken },
|
|
128
|
+
(msg) => logger.debug(msg),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
this.#pollAbort = new AbortController();
|
|
132
|
+
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
133
|
+
logger.info(formatCompact({
|
|
134
|
+
op: 'connect',
|
|
135
|
+
endpoint: this.#options.config.name,
|
|
136
|
+
}));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
await this.stop();
|
|
139
|
+
logger.error('Failed to connect weixin-ilink bot:', error);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
132
143
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
144
|
+
open(): void {
|
|
145
|
+
this.#open = true;
|
|
146
|
+
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
this
|
|
140
|
-
this.$connected = true;
|
|
141
|
-
this.pluginLogger.info(`weixin-ilink endpoint ${this.$id} connected`);
|
|
148
|
+
close(): void {
|
|
149
|
+
this.#open = false;
|
|
142
150
|
}
|
|
143
151
|
|
|
144
|
-
async
|
|
145
|
-
this
|
|
152
|
+
async stop(): Promise<void> {
|
|
153
|
+
this.#open = false;
|
|
154
|
+
this.#pollAbort?.abort();
|
|
146
155
|
try {
|
|
147
|
-
await this
|
|
156
|
+
await this.#pollPromise;
|
|
148
157
|
} catch {
|
|
149
158
|
/* poll loop exit */
|
|
150
159
|
}
|
|
151
|
-
if (this
|
|
160
|
+
if (this.#creds?.botToken) {
|
|
152
161
|
try {
|
|
153
|
-
await notifyStop({ baseUrl: this.apiBaseUrl(), token: this
|
|
162
|
+
await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
|
|
154
163
|
} catch (err) {
|
|
155
|
-
|
|
164
|
+
logger.warn(formatCompact({
|
|
165
|
+
op: 'notify_stop',
|
|
166
|
+
ok: false,
|
|
167
|
+
error: err instanceof Error ? err.message : String(err),
|
|
168
|
+
}));
|
|
156
169
|
}
|
|
157
170
|
}
|
|
158
|
-
this
|
|
159
|
-
|
|
171
|
+
this.#started = false;
|
|
172
|
+
logger.debug(formatCompact({
|
|
173
|
+
op: 'disconnect',
|
|
174
|
+
endpoint: this.#options.config.name,
|
|
175
|
+
}));
|
|
160
176
|
}
|
|
161
177
|
|
|
162
|
-
async
|
|
163
|
-
if (!this
|
|
164
|
-
|
|
165
|
-
const cfg = await this.configManager.getForUser(userId, contextToken);
|
|
166
|
-
if (!cfg.typingTicket) {
|
|
167
|
-
this.pluginLogger.debug(`sendTyping: no typing_ticket for user=${userId}`);
|
|
168
|
-
return;
|
|
178
|
+
async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
|
|
179
|
+
if (!this.#creds?.botToken) {
|
|
180
|
+
throw new Error('weixin-ilink bot not authenticated');
|
|
169
181
|
}
|
|
170
|
-
|
|
171
|
-
baseUrl: this.apiBaseUrl(),
|
|
172
|
-
token: this.creds.botToken,
|
|
173
|
-
body: {
|
|
174
|
-
ilink_user_id: userId,
|
|
175
|
-
typing_ticket: cfg.typingTicket,
|
|
176
|
-
status,
|
|
177
|
-
},
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async $recallMessage(_id: string): Promise<void> {
|
|
182
|
-
this.pluginLogger.debug("weixin-ilink does not support message recall");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
$formatMessage(msg: WeixinMessageWithMedia): Message<WeixinMessage> {
|
|
186
|
-
const userId = msg.from_user_id ?? "";
|
|
187
|
-
const wire = this.buildInboundContent(msg);
|
|
188
|
-
const quoteId = Message.quoteIdFromContent(wire);
|
|
189
|
-
Message.alignReplySegments(wire, quoteId);
|
|
190
|
-
const content = toCanonicalSegments(wire);
|
|
191
|
-
const msgId = String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
192
|
-
|
|
193
|
-
return Message.from(msg, {
|
|
194
|
-
$id: msgId,
|
|
195
|
-
$adapter: "weixin-ilink",
|
|
196
|
-
$endpoint: this.$config.name,
|
|
197
|
-
$sender: {
|
|
198
|
-
id: userId,
|
|
199
|
-
name: userId,
|
|
200
|
-
},
|
|
201
|
-
$channel: {
|
|
202
|
-
id: userId,
|
|
203
|
-
type: "private",
|
|
204
|
-
},
|
|
205
|
-
$content: content,
|
|
206
|
-
$quote_id: quoteId,
|
|
207
|
-
$raw: bodyFromItemList(msg.item_list),
|
|
208
|
-
$timestamp: msg.create_time_ms ?? Date.now(),
|
|
209
|
-
$recall: async () => this.$recallMessage(msgId),
|
|
210
|
-
$reply: async (replyContent: SendContent, _quote?: boolean | string) => {
|
|
211
|
-
if (!Array.isArray(replyContent)) replyContent = [replyContent];
|
|
212
|
-
return this.adapter.sendMessage({
|
|
213
|
-
context: "weixin-ilink",
|
|
214
|
-
endpoint: this.$config.name,
|
|
215
|
-
id: userId,
|
|
216
|
-
type: "private",
|
|
217
|
-
content: replyContent,
|
|
218
|
-
});
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private buildInboundContent(msg: WeixinMessageWithMedia): MessageSegment[] {
|
|
224
|
-
const segments: MessageSegment[] = [];
|
|
225
|
-
const text = bodyFromItemList(msg.item_list);
|
|
226
|
-
if (text) segments.push(segment.text(text));
|
|
227
|
-
|
|
228
|
-
const media = msg._media;
|
|
229
|
-
if (media?.decryptedPicPath) {
|
|
230
|
-
segments.push(segment("image", { file: media.decryptedPicPath }));
|
|
231
|
-
} else if (media?.decryptedVideoPath) {
|
|
232
|
-
segments.push(segment("video", { file: media.decryptedVideoPath }));
|
|
233
|
-
} else if (media?.decryptedFilePath) {
|
|
234
|
-
segments.push(segment("file", {
|
|
235
|
-
file: media.decryptedFilePath,
|
|
236
|
-
name: path.basename(media.decryptedFilePath),
|
|
237
|
-
}));
|
|
238
|
-
} else if (media?.decryptedVoicePath) {
|
|
239
|
-
segments.push(segment("record", { file: media.decryptedVoicePath }));
|
|
240
|
-
} else if (!text && msg.item_list?.some((i) => isMediaItem(i))) {
|
|
241
|
-
segments.push(segment.text("[媒体消息]"));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return segments.length ? segments : [segment.text("")];
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
async $sendMessage(options: SendOptions): Promise<string> {
|
|
248
|
-
if (!this.creds?.botToken) {
|
|
249
|
-
throw new Error("weixin-ilink bot not authenticated");
|
|
250
|
-
}
|
|
251
|
-
const peerId = options.id;
|
|
252
|
-
const contextToken = getContextToken(this.$id, peerId);
|
|
182
|
+
const contextToken = getContextToken(this.#options.config.name, target);
|
|
253
183
|
if (!contextToken) {
|
|
254
|
-
|
|
255
|
-
|
|
184
|
+
logger.warn(formatCompact({
|
|
185
|
+
op: 'send',
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: 'missing_context_token',
|
|
188
|
+
target,
|
|
189
|
+
}));
|
|
190
|
+
throw new Error(`missing context_token for peer ${target}`);
|
|
256
191
|
}
|
|
257
192
|
|
|
258
193
|
const apiOpts = {
|
|
259
194
|
baseUrl: this.apiBaseUrl(),
|
|
260
|
-
token: this
|
|
195
|
+
token: this.#creds.botToken,
|
|
261
196
|
contextToken,
|
|
262
197
|
};
|
|
263
198
|
|
|
264
|
-
const outboundDir = path.join(resolveStateDir(),
|
|
265
|
-
const
|
|
266
|
-
const wire = fromCanonicalSegments(
|
|
267
|
-
(Array.isArray(canonical) ? canonical : [canonical]).map((s) =>
|
|
268
|
-
typeof s === 'string' ? { type: 'text' as const, data: { text: s } } : s,
|
|
269
|
-
),
|
|
270
|
-
);
|
|
199
|
+
const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
|
|
200
|
+
const wire = formatOutboundSegments(payload);
|
|
271
201
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
272
202
|
const segments = Array.isArray(materialized) ? materialized : [materialized];
|
|
273
|
-
let lastId =
|
|
274
|
-
let pendingText =
|
|
203
|
+
let lastId = '';
|
|
204
|
+
let pendingText = '';
|
|
275
205
|
|
|
276
206
|
for (const raw of segments) {
|
|
277
|
-
if (typeof raw ===
|
|
207
|
+
if (typeof raw === 'string') {
|
|
278
208
|
pendingText += raw;
|
|
279
209
|
continue;
|
|
280
210
|
}
|
|
281
|
-
if (typeof raw !==
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (seg.type === "text" || seg.type === "markdown") {
|
|
286
|
-
const t = (seg.data as { text?: string }).text ?? "";
|
|
287
|
-
pendingText += t;
|
|
211
|
+
if (typeof raw !== 'object' || raw === null || !('type' in raw)) continue;
|
|
212
|
+
const seg = raw as WeixinWireSegment;
|
|
213
|
+
if (seg.type === 'text' || seg.type === 'markdown') {
|
|
214
|
+
pendingText += String((seg.data as { text?: string } | undefined)?.text ?? '');
|
|
288
215
|
continue;
|
|
289
216
|
}
|
|
290
217
|
|
|
291
218
|
const filePath = segmentLocalPath(seg);
|
|
292
219
|
if (filePath && fs.existsSync(filePath)) {
|
|
293
|
-
// 微信限制:单条消息不能图文混排 →
|
|
220
|
+
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
294
221
|
if (pendingText.trim()) {
|
|
295
|
-
const textResult = await
|
|
296
|
-
to:
|
|
222
|
+
const textResult = await this.#sendText({
|
|
223
|
+
to: target,
|
|
297
224
|
text: pendingText,
|
|
298
225
|
opts: apiOpts,
|
|
299
226
|
});
|
|
300
|
-
pendingText =
|
|
227
|
+
pendingText = '';
|
|
301
228
|
lastId = textResult.messageId;
|
|
302
229
|
}
|
|
303
230
|
const result = await sendWeixinMediaFile({
|
|
304
231
|
filePath,
|
|
305
|
-
to:
|
|
306
|
-
text:
|
|
232
|
+
to: target,
|
|
233
|
+
text: '',
|
|
307
234
|
opts: apiOpts,
|
|
308
235
|
cdnBaseUrl: this.cdnBaseUrl(),
|
|
309
236
|
});
|
|
@@ -311,14 +238,18 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
|
|
|
311
238
|
continue;
|
|
312
239
|
}
|
|
313
240
|
|
|
314
|
-
if (seg.type ===
|
|
315
|
-
|
|
241
|
+
if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
|
|
242
|
+
logger.warn(formatCompact({
|
|
243
|
+
op: 'send',
|
|
244
|
+
skip: 'no_local_file',
|
|
245
|
+
type: seg.type,
|
|
246
|
+
}));
|
|
316
247
|
}
|
|
317
248
|
}
|
|
318
249
|
|
|
319
250
|
if (pendingText.trim()) {
|
|
320
|
-
const result = await
|
|
321
|
-
to:
|
|
251
|
+
const result = await this.#sendText({
|
|
252
|
+
to: target,
|
|
322
253
|
text: pendingText,
|
|
323
254
|
opts: apiOpts,
|
|
324
255
|
});
|
|
@@ -328,23 +259,74 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
|
|
|
328
259
|
return lastId || `weixin-ilink-${Date.now()}`;
|
|
329
260
|
}
|
|
330
261
|
|
|
331
|
-
|
|
332
|
-
|
|
262
|
+
/** Test / internal: admit a parsed message when the endpoint is open. */
|
|
263
|
+
admit(msg: WeixinMessageWithMedia): void {
|
|
264
|
+
if (!this.#open) return;
|
|
265
|
+
const userId = msg.from_user_id ?? '';
|
|
266
|
+
if (msg.context_token && userId) {
|
|
267
|
+
setContextToken(this.#options.config.name, userId, msg.context_token);
|
|
268
|
+
}
|
|
269
|
+
void this.#options.gateway.receive({
|
|
270
|
+
adapter: this.#options.id,
|
|
271
|
+
target: userId,
|
|
272
|
+
content: formatInboundContent(msg),
|
|
273
|
+
sender: userId,
|
|
274
|
+
id: inboundMessageId(msg),
|
|
275
|
+
metadata: Object.freeze({
|
|
276
|
+
endpoint: this.#options.config.name,
|
|
277
|
+
messageType: msg.message_type,
|
|
278
|
+
createTimeMs: msg.create_time_ms,
|
|
279
|
+
}),
|
|
280
|
+
}).catch((err) => {
|
|
281
|
+
logger.warn(formatCompact({
|
|
282
|
+
op: 'weixin_ilink_gateway_receive_failed',
|
|
283
|
+
target: userId,
|
|
284
|
+
error: err instanceof Error ? err.message : String(err),
|
|
285
|
+
}));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
333
288
|
|
|
334
|
-
|
|
335
|
-
|
|
289
|
+
async sendTypingToUser(userId: string, status: number): Promise<void> {
|
|
290
|
+
if (!this.#creds?.botToken || !this.#configManager) return;
|
|
291
|
+
const contextToken = getContextToken(this.#options.config.name, userId);
|
|
292
|
+
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
293
|
+
if (!cfg.typingTicket) {
|
|
294
|
+
logger.debug(formatCompact({
|
|
295
|
+
op: 'send_typing',
|
|
296
|
+
skip: 'no_typing_ticket',
|
|
297
|
+
userId,
|
|
298
|
+
}));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
await sendTyping({
|
|
302
|
+
baseUrl: this.apiBaseUrl(),
|
|
303
|
+
token: this.#creds.botToken,
|
|
304
|
+
body: {
|
|
305
|
+
ilink_user_id: userId,
|
|
306
|
+
typing_ticket: cfg.typingTicket,
|
|
307
|
+
status,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async #pollLoop(abortSignal: AbortSignal): Promise<void> {
|
|
313
|
+
if (!this.#creds?.botToken) return;
|
|
314
|
+
|
|
315
|
+
const endpointName = this.#options.config.name;
|
|
316
|
+
let getUpdatesBuf = loadSyncBuf(endpointName);
|
|
317
|
+
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
336
318
|
let consecutiveFailures = 0;
|
|
337
319
|
|
|
338
320
|
while (!abortSignal.aborted) {
|
|
339
|
-
if (isSessionPaused(
|
|
340
|
-
await sleep(getRemainingPauseMs(
|
|
321
|
+
if (isSessionPaused(endpointName)) {
|
|
322
|
+
await sleep(getRemainingPauseMs(endpointName), abortSignal);
|
|
341
323
|
continue;
|
|
342
324
|
}
|
|
343
325
|
|
|
344
326
|
try {
|
|
345
|
-
const resp = await getUpdates({
|
|
327
|
+
const resp = await this.#getUpdates({
|
|
346
328
|
baseUrl: this.apiBaseUrl(),
|
|
347
|
-
token: this
|
|
329
|
+
token: this.#creds.botToken,
|
|
348
330
|
get_updates_buf: getUpdatesBuf,
|
|
349
331
|
timeoutMs: nextTimeoutMs,
|
|
350
332
|
abortSignal,
|
|
@@ -355,16 +337,16 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
|
|
|
355
337
|
}
|
|
356
338
|
|
|
357
339
|
const isApiError =
|
|
358
|
-
(resp.ret !== undefined && resp.ret !== 0)
|
|
359
|
-
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
340
|
+
(resp.ret !== undefined && resp.ret !== 0)
|
|
341
|
+
|| (resp.errcode !== undefined && resp.errcode !== 0);
|
|
360
342
|
|
|
361
343
|
if (isApiError) {
|
|
362
344
|
const sessionExpired =
|
|
363
345
|
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
364
346
|
if (sessionExpired) {
|
|
365
|
-
pauseSession(
|
|
347
|
+
pauseSession(endpointName);
|
|
366
348
|
consecutiveFailures = 0;
|
|
367
|
-
await sleep(getRemainingPauseMs(
|
|
349
|
+
await sleep(getRemainingPauseMs(endpointName), abortSignal);
|
|
368
350
|
continue;
|
|
369
351
|
}
|
|
370
352
|
consecutiveFailures += 1;
|
|
@@ -380,16 +362,20 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
|
|
|
380
362
|
consecutiveFailures = 0;
|
|
381
363
|
if (resp.get_updates_buf) {
|
|
382
364
|
getUpdatesBuf = resp.get_updates_buf;
|
|
383
|
-
saveSyncBuf(
|
|
365
|
+
saveSyncBuf(endpointName, getUpdatesBuf);
|
|
384
366
|
}
|
|
385
367
|
|
|
386
368
|
for (const inbound of resp.msgs ?? []) {
|
|
387
|
-
await this
|
|
369
|
+
await this.#handleInboundMessage(inbound);
|
|
388
370
|
}
|
|
389
371
|
} catch (err) {
|
|
390
372
|
if (abortSignal.aborted) return;
|
|
391
373
|
consecutiveFailures += 1;
|
|
392
|
-
|
|
374
|
+
logger.error(formatCompact({
|
|
375
|
+
op: 'poll',
|
|
376
|
+
ok: false,
|
|
377
|
+
error: err instanceof Error ? err.message : String(err),
|
|
378
|
+
}));
|
|
393
379
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
394
380
|
consecutiveFailures = 0;
|
|
395
381
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
@@ -400,37 +386,37 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
|
|
|
400
386
|
}
|
|
401
387
|
}
|
|
402
388
|
|
|
403
|
-
|
|
404
|
-
const fromUserId = full.from_user_id ??
|
|
389
|
+
async #handleInboundMessage(full: WeixinMessage): Promise<void> {
|
|
390
|
+
const fromUserId = full.from_user_id ?? '';
|
|
405
391
|
if (full.context_token && fromUserId) {
|
|
406
|
-
setContextToken(this
|
|
392
|
+
setContextToken(this.#options.config.name, fromUserId, full.context_token);
|
|
407
393
|
}
|
|
408
394
|
|
|
409
|
-
const mediaOpts = await this
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
);
|
|
395
|
+
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
396
|
+
this.admit({ ...full, _media: mediaOpts });
|
|
397
|
+
logger.debug(formatCompact({
|
|
398
|
+
op: 'recv',
|
|
399
|
+
endpoint: this.#options.config.name,
|
|
400
|
+
target: fromUserId,
|
|
401
|
+
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
402
|
+
}));
|
|
417
403
|
}
|
|
418
404
|
|
|
419
|
-
|
|
405
|
+
async #downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
|
|
420
406
|
const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
|
|
421
407
|
Boolean(m?.encrypt_query_param || m?.full_url);
|
|
422
408
|
|
|
423
409
|
const mediaItem =
|
|
424
410
|
full.item_list?.find(
|
|
425
411
|
(i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),
|
|
426
|
-
)
|
|
427
|
-
full.item_list?.find(
|
|
412
|
+
)
|
|
413
|
+
?? full.item_list?.find(
|
|
428
414
|
(i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),
|
|
429
|
-
)
|
|
430
|
-
full.item_list?.find(
|
|
415
|
+
)
|
|
416
|
+
?? full.item_list?.find(
|
|
431
417
|
(i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),
|
|
432
|
-
)
|
|
433
|
-
full.item_list?.find(
|
|
418
|
+
)
|
|
419
|
+
?? full.item_list?.find(
|
|
434
420
|
(i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media),
|
|
435
421
|
);
|
|
436
422
|
|
|
@@ -439,41 +425,37 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
|
|
|
439
425
|
return downloadMediaFromItem(mediaItem, {
|
|
440
426
|
cdnBaseUrl: this.cdnBaseUrl(),
|
|
441
427
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
|
|
442
|
-
this
|
|
443
|
-
log: (msg) =>
|
|
444
|
-
errLog: (msg) =>
|
|
445
|
-
label: `inbound:${
|
|
428
|
+
this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
429
|
+
log: (msg) => logger.debug(msg),
|
|
430
|
+
errLog: (msg) => logger.warn(msg),
|
|
431
|
+
label: `inbound:${full.from_user_id ?? 'unknown'}`,
|
|
446
432
|
});
|
|
447
433
|
}
|
|
448
434
|
|
|
449
|
-
|
|
435
|
+
async #saveInboundMedia(
|
|
450
436
|
buffer: Buffer,
|
|
451
437
|
contentType?: string,
|
|
452
|
-
subdir =
|
|
438
|
+
subdir = 'inbound',
|
|
453
439
|
maxBytes = WEIXIN_MEDIA_MAX_BYTES,
|
|
454
440
|
originalFilename?: string,
|
|
455
441
|
): Promise<{ path: string }> {
|
|
456
442
|
if (buffer.length > maxBytes) {
|
|
457
443
|
throw new Error(`media exceeds max size ${maxBytes}`);
|
|
458
444
|
}
|
|
459
|
-
const dir = path.join(resolveStateDir(),
|
|
445
|
+
const dir = path.join(resolveStateDir(), 'media', subdir);
|
|
460
446
|
fs.mkdirSync(dir, { recursive: true });
|
|
461
447
|
const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
|
|
462
448
|
const ext = originalFilename
|
|
463
449
|
? path.extname(originalFilename)
|
|
464
450
|
: resolvedMime
|
|
465
451
|
? getExtensionFromMime(resolvedMime)
|
|
466
|
-
:
|
|
452
|
+
: '.bin';
|
|
467
453
|
const base = originalFilename
|
|
468
454
|
? path.basename(originalFilename, ext)
|
|
469
|
-
:
|
|
470
|
-
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString(
|
|
455
|
+
: 'media';
|
|
456
|
+
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
|
|
471
457
|
const filePath = path.join(dir, fileName);
|
|
472
458
|
fs.writeFileSync(filePath, buffer);
|
|
473
459
|
return { path: filePath };
|
|
474
460
|
}
|
|
475
461
|
}
|
|
476
|
-
|
|
477
|
-
function fromUserIdLabel(msg: WeixinMessage): string {
|
|
478
|
-
return msg.from_user_id ?? "unknown";
|
|
479
|
-
}
|