@zhin.js/adapter-weixin-ilink 6.0.0 → 6.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -2
- package/adapters/weixin-ilink.js +0 -3
- package/adapters/weixin-ilink.ts +0 -3
- package/lib/client.d.ts +34 -0
- package/lib/client.js +72 -0
- package/lib/endpoint.d.ts +5 -10
- package/lib/endpoint.js +41 -71
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +1 -1
- package/lib/weixin-ilink-endpoint-commands.d.ts +1 -1
- package/package.json +10 -9
- package/src/client.ts +85 -0
- package/src/endpoint.ts +51 -82
- package/src/index.ts +6 -0
- package/src/protocol.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @zhin.js/adapter-weixin-ilink
|
|
2
2
|
|
|
3
|
+
## 6.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [ba7e17a]
|
|
8
|
+
- Updated dependencies [7108d0b]
|
|
9
|
+
- @zhin.js/core@1.5.15
|
|
10
|
+
- zhin.js@6.0.15
|
|
11
|
+
|
|
12
|
+
## 6.0.1
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 1fc78bc: Unify native platform Client access behind the literal `adapter` discriminant. Handlers infer both native events and Clients, while command, inbound/outbound middleware, and both Agent tool authoring surfaces expose the exact operation-scoped Client through a lazy `$client` getter. Definitions without `adapter` keep `$client` typed as `unknown`, and runtime dispatch rejects adapter mismatches before resolving the Client. Bundled platform tools now use this single path instead of model-provided endpoint ids and adapter-specific dependency wrappers. Every adapter registers one Client/EventMap contract, and protocol adapters including NapCat, Milky, OneBot and Satori now produce transport-independent Client objects rather than letting Endpoint instances impersonate Clients.
|
|
17
|
+
- Updated dependencies [4e8117c]
|
|
18
|
+
- Updated dependencies [54bfd6b]
|
|
19
|
+
- Updated dependencies [12025ee]
|
|
20
|
+
- Updated dependencies [09b14d6]
|
|
21
|
+
- Updated dependencies [1fc78bc]
|
|
22
|
+
- @zhin.js/adapter@1.2.1
|
|
23
|
+
- @zhin.js/core@1.5.14
|
|
24
|
+
- @zhin.js/command@1.0.16
|
|
25
|
+
- @zhin.js/logger@1.0.77
|
|
26
|
+
- zhin.js@6.0.14
|
|
27
|
+
- @zhin.js/feature-kit@1.0.13
|
|
28
|
+
|
|
3
29
|
## 6.0.0
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
| 项 | weixin-ilink | wechat-mp |
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| 账号类型 | 个人微信(ClawBot) | 微信公众号 |
|
|
12
|
-
| 入站 | 长轮询 `getupdates` → `
|
|
12
|
+
| 入站 | 长轮询 `getupdates` → `Endpoint.emit(...)` | Webhook(`httpHostToken`) |
|
|
13
13
|
| 登录 | 扫码 / 侧车凭证 / `botToken` | AppId/Secret/Token |
|
|
14
14
|
| 群聊 | 不支持(仅私聊) | 支持 |
|
|
15
15
|
|
|
@@ -52,7 +52,7 @@ data/weixin-ilink/<bot-name>.json
|
|
|
52
52
|
|
|
53
53
|
回复必须携带入站时缓存的 `context_token`(按 `endpointId + peerUserId`)。若用户久未发消息导致 token 缺失,出站会拒绝并打 warn。
|
|
54
54
|
|
|
55
|
-
出站经 Runtime:`
|
|
55
|
+
出站经 Runtime:`OutboundMessageService` → `endpoint.send({ conversation, payload })`(`conversation` 为入站归一化出的 `ConversationRef`,kind 恒为 `private`,`id` 即对端 user_id)。
|
|
56
56
|
|
|
57
57
|
**图文限制**:微信单条消息不支持图文混排。适配器会自动:
|
|
58
58
|
|
package/adapters/weixin-ilink.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Convention entry: discover `adapters/weixin-ilink.ts` → defineAdapter.
|
|
4
4
|
*/
|
|
5
5
|
import { defineAdapter } from 'zhin.js/adapter';
|
|
6
|
-
import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
|
|
7
6
|
import { WeixinIlinkEndpoint } from "../lib/endpoint.js";
|
|
8
7
|
import { resolveWeixinIlinkConfig, } from "../lib/protocol.js";
|
|
9
8
|
import { weixinIlinkRuntimeStateToken } from "../lib/weixin-ilink-runtime-state.js";
|
|
@@ -27,8 +26,6 @@ export default defineAdapter({
|
|
|
27
26
|
});
|
|
28
27
|
return new WeixinIlinkEndpoint({
|
|
29
28
|
id: context.id,
|
|
30
|
-
gateway: context.use(messageGatewayToken),
|
|
31
|
-
sideEvents: context.use(sideEventGatewayToken),
|
|
32
29
|
config,
|
|
33
30
|
});
|
|
34
31
|
},
|
package/adapters/weixin-ilink.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Convention entry: discover `adapters/weixin-ilink.ts` → defineAdapter.
|
|
3
3
|
*/
|
|
4
4
|
import { defineAdapter } from 'zhin.js/adapter';
|
|
5
|
-
import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
|
|
6
5
|
import { WeixinIlinkEndpoint } from '../src/endpoint.js';
|
|
7
6
|
import {
|
|
8
7
|
resolveWeixinIlinkConfig,
|
|
@@ -38,8 +37,6 @@ export default defineAdapter<WeixinIlinkAdapterConfig>({
|
|
|
38
37
|
});
|
|
39
38
|
return new WeixinIlinkEndpoint({
|
|
40
39
|
id: context.id,
|
|
41
|
-
gateway: context.use(messageGatewayToken),
|
|
42
|
-
sideEvents: context.use(sideEventGatewayToken),
|
|
43
40
|
config,
|
|
44
41
|
});
|
|
45
42
|
},
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { WeixinIlinkCredentials } from './credentials.js';
|
|
2
|
+
import type { WeixinConfigManager } from './ilink-config-cache.js';
|
|
3
|
+
import type { ResolvedWeixinIlinkConfig } from './protocol.js';
|
|
4
|
+
import { sendMessageWeixin } from './weixin-send.js';
|
|
5
|
+
/** Direct iLink API client backed by the Endpoint's live authenticated session. */
|
|
6
|
+
export declare class WeixinIlinkClient {
|
|
7
|
+
readonly config: ResolvedWeixinIlinkConfig;
|
|
8
|
+
private readonly resolveCredentials;
|
|
9
|
+
private readonly resolveConfigManager;
|
|
10
|
+
private readonly sendTextImpl;
|
|
11
|
+
constructor(config: ResolvedWeixinIlinkConfig, resolveCredentials: () => WeixinIlinkCredentials | null, resolveConfigManager: () => WeixinConfigManager | undefined, sendTextImpl: typeof sendMessageWeixin);
|
|
12
|
+
get credentials(): WeixinIlinkCredentials;
|
|
13
|
+
get authenticated(): boolean;
|
|
14
|
+
get apiBaseUrl(): string;
|
|
15
|
+
get cdnBaseUrl(): string;
|
|
16
|
+
contextToken(userId: string): string | undefined;
|
|
17
|
+
sendText(to: string, text: string): Promise<{
|
|
18
|
+
messageId: string;
|
|
19
|
+
}>;
|
|
20
|
+
sendMedia(to: string, filePath: string, text?: string): Promise<{
|
|
21
|
+
messageId: string;
|
|
22
|
+
}>;
|
|
23
|
+
sendTyping(userId: string, status: number): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
export type WeixinIlinkClientEventMap = Record<string, unknown>;
|
|
26
|
+
declare module '@zhin.js/feature-kit' {
|
|
27
|
+
interface AdapterClientRegistry {
|
|
28
|
+
readonly 'weixin-ilink': {
|
|
29
|
+
readonly client: WeixinIlinkClient;
|
|
30
|
+
readonly events: WeixinIlinkClientEventMap;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export declare const weixinIlinkClient: import("@zhin.js/adapter").EndpointClientToken<WeixinIlinkClient, WeixinIlinkClientEventMap>;
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getContextToken } from './context-store.js';
|
|
2
|
+
import { sendTyping } from './ilink-api.js';
|
|
3
|
+
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
4
|
+
import { defineEndpointClient } from 'zhin.js/adapter';
|
|
5
|
+
/** Direct iLink API client backed by the Endpoint's live authenticated session. */
|
|
6
|
+
export class WeixinIlinkClient {
|
|
7
|
+
config;
|
|
8
|
+
resolveCredentials;
|
|
9
|
+
resolveConfigManager;
|
|
10
|
+
sendTextImpl;
|
|
11
|
+
constructor(config, resolveCredentials, resolveConfigManager, sendTextImpl) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.resolveCredentials = resolveCredentials;
|
|
14
|
+
this.resolveConfigManager = resolveConfigManager;
|
|
15
|
+
this.sendTextImpl = sendTextImpl;
|
|
16
|
+
}
|
|
17
|
+
get credentials() {
|
|
18
|
+
const credentials = this.resolveCredentials();
|
|
19
|
+
if (!credentials?.botToken)
|
|
20
|
+
throw new Error('weixin-ilink client not authenticated');
|
|
21
|
+
return credentials;
|
|
22
|
+
}
|
|
23
|
+
get authenticated() {
|
|
24
|
+
return Boolean(this.resolveCredentials()?.botToken);
|
|
25
|
+
}
|
|
26
|
+
get apiBaseUrl() {
|
|
27
|
+
return this.resolveCredentials()?.baseUrl ?? this.config.baseUrl;
|
|
28
|
+
}
|
|
29
|
+
get cdnBaseUrl() {
|
|
30
|
+
return this.config.cdnBaseUrl;
|
|
31
|
+
}
|
|
32
|
+
contextToken(userId) {
|
|
33
|
+
return getContextToken(this.config.id, userId);
|
|
34
|
+
}
|
|
35
|
+
async sendText(to, text) {
|
|
36
|
+
const contextToken = this.contextToken(to);
|
|
37
|
+
if (!contextToken)
|
|
38
|
+
throw new Error(`missing context_token for peer ${to}`);
|
|
39
|
+
return this.sendTextImpl({
|
|
40
|
+
to,
|
|
41
|
+
text,
|
|
42
|
+
opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async sendMedia(to, filePath, text = '') {
|
|
46
|
+
const contextToken = this.contextToken(to);
|
|
47
|
+
if (!contextToken)
|
|
48
|
+
throw new Error(`missing context_token for peer ${to}`);
|
|
49
|
+
return sendWeixinMediaFile({
|
|
50
|
+
filePath,
|
|
51
|
+
to,
|
|
52
|
+
text,
|
|
53
|
+
opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
|
|
54
|
+
cdnBaseUrl: this.cdnBaseUrl,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async sendTyping(userId, status) {
|
|
58
|
+
const manager = this.resolveConfigManager();
|
|
59
|
+
if (!this.authenticated || !manager)
|
|
60
|
+
return false;
|
|
61
|
+
const cfg = await manager.getForUser(userId, this.contextToken(userId));
|
|
62
|
+
if (!cfg.typingTicket)
|
|
63
|
+
return false;
|
|
64
|
+
await sendTyping({
|
|
65
|
+
baseUrl: this.apiBaseUrl,
|
|
66
|
+
token: this.credentials.botToken,
|
|
67
|
+
body: { ilink_user_id: userId, typing_ticket: cfg.typingTicket, status },
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export const weixinIlinkClient = defineEndpointClient('weixin-ilink');
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
import type { EndpointControl, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
3
3
|
import type { CapabilityId } from 'zhin.js';
|
|
4
4
|
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
5
5
|
import { type WeixinIlinkCredentials } from './credentials.js';
|
|
6
6
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
7
7
|
import { type ResolvedWeixinIlinkConfig, type WeixinMessageWithMedia } from './protocol.js';
|
|
8
|
+
import { WeixinIlinkClient } from './client.js';
|
|
8
9
|
export type WeixinIlinkSendText = typeof sendMessageWeixin;
|
|
9
10
|
export type WeixinIlinkNotifyStart = typeof notifyStart;
|
|
10
11
|
export type WeixinIlinkNotifyStop = typeof notifyStop;
|
|
11
12
|
export type WeixinIlinkGetUpdates = typeof getUpdates;
|
|
12
13
|
export interface WeixinIlinkEndpointOptions {
|
|
13
14
|
readonly id: CapabilityId;
|
|
14
|
-
readonly gateway: MessageGateway;
|
|
15
|
-
readonly sideEvents?: SideEventGateway;
|
|
16
15
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
17
16
|
readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
|
|
18
17
|
/** Test / internal: override network side effects. */
|
|
@@ -21,16 +20,13 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
21
20
|
readonly getUpdates?: WeixinIlinkGetUpdates;
|
|
22
21
|
readonly sendText?: WeixinIlinkSendText;
|
|
23
22
|
}
|
|
24
|
-
export declare class WeixinIlinkEndpoint
|
|
23
|
+
export declare class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
25
24
|
#private;
|
|
25
|
+
readonly client: WeixinIlinkClient;
|
|
26
26
|
readonly management: EndpointManagement;
|
|
27
27
|
readonly control: EndpointControl;
|
|
28
28
|
constructor(options: WeixinIlinkEndpointOptions);
|
|
29
29
|
get hasCredentials(): boolean;
|
|
30
|
-
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
31
|
-
get configName(): string;
|
|
32
|
-
apiBaseUrl(): string;
|
|
33
|
-
cdnBaseUrl(): string;
|
|
34
30
|
start(): Promise<void>;
|
|
35
31
|
open(): void;
|
|
36
32
|
close(): void;
|
|
@@ -38,5 +34,4 @@ export declare class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
38
34
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
39
35
|
/** Test / internal: admit a parsed message when the endpoint is open. */
|
|
40
36
|
admit(msg: WeixinMessageWithMedia): void;
|
|
41
|
-
sendTypingToUser(userId: string, status: number): Promise<void>;
|
|
42
37
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
3
|
* WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
|
|
3
4
|
*/
|
|
@@ -5,20 +6,20 @@ import crypto from 'node:crypto';
|
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
|
-
import { getUpdates, notifyStart, notifyStop
|
|
9
|
+
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
9
10
|
import { configureIlinkMeta } from './ilink-meta.js';
|
|
10
11
|
import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from './credentials.js';
|
|
11
12
|
import { resolveCredentials } from './login.js';
|
|
12
|
-
import {
|
|
13
|
+
import { listContextTokenUserIds, restoreContextTokens, setContextToken, flushContextTokenPersist, } from './context-store.js';
|
|
13
14
|
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
14
15
|
import { getRemainingPauseMs, isSessionPaused, pauseSession, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
|
|
15
16
|
import { downloadMediaFromItem } from './media-download.js';
|
|
16
17
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
17
|
-
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
18
18
|
import { materializeOutboundMedia } from './outbound-media.js';
|
|
19
19
|
import { MessageItemType } from './ilink-types.js';
|
|
20
20
|
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
21
21
|
import { formatInboundContent, weixinInboundSegments, weixinReplyTo, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, weixinIlinkInboundConversation, } from './protocol.js';
|
|
22
|
+
import { WeixinIlinkClient } from './client.js';
|
|
22
23
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
23
24
|
const BACKOFF_DELAY_MS = 30_000;
|
|
24
25
|
const RETRY_DELAY_MS = 2_000;
|
|
@@ -26,7 +27,8 @@ const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
|
26
27
|
/** 入站媒体保留 24h,每小时清扫一次。 */
|
|
27
28
|
const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
|
28
29
|
const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
29
|
-
export class WeixinIlinkEndpoint {
|
|
30
|
+
export class WeixinIlinkEndpoint extends Endpoint {
|
|
31
|
+
client;
|
|
30
32
|
#logger;
|
|
31
33
|
#options;
|
|
32
34
|
#resolveCredentials;
|
|
@@ -42,11 +44,14 @@ export class WeixinIlinkEndpoint {
|
|
|
42
44
|
#mediaSweepTimer;
|
|
43
45
|
#open = false;
|
|
44
46
|
#started = false;
|
|
45
|
-
management = createWeixinIlinkEndpointManagement(this);
|
|
47
|
+
management = createWeixinIlinkEndpointManagement(() => this.client);
|
|
46
48
|
control = Object.freeze({
|
|
47
|
-
typing: (conversation, active) =>
|
|
49
|
+
typing: async (conversation, active) => {
|
|
50
|
+
await this.client.sendTyping(conversation.id, active === false ? 2 : 1);
|
|
51
|
+
},
|
|
48
52
|
});
|
|
49
53
|
constructor(options) {
|
|
54
|
+
super();
|
|
50
55
|
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
51
56
|
this.#options = options;
|
|
52
57
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
@@ -54,19 +59,10 @@ export class WeixinIlinkEndpoint {
|
|
|
54
59
|
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
55
60
|
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
56
61
|
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
62
|
+
this.client = new WeixinIlinkClient(options.config, () => this.#creds, () => this.#configManager, this.#sendText);
|
|
57
63
|
}
|
|
58
64
|
get hasCredentials() {
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
62
|
-
get configName() {
|
|
63
|
-
return this.#options.config.id;
|
|
64
|
-
}
|
|
65
|
-
apiBaseUrl() {
|
|
66
|
-
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
67
|
-
}
|
|
68
|
-
cdnBaseUrl() {
|
|
69
|
-
return this.#options.config.cdnBaseUrl;
|
|
65
|
+
return this.client.authenticated;
|
|
70
66
|
}
|
|
71
67
|
async start() {
|
|
72
68
|
if (this.#started)
|
|
@@ -79,10 +75,10 @@ export class WeixinIlinkEndpoint {
|
|
|
79
75
|
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
80
76
|
restoreContextTokens(this.#options.config.id);
|
|
81
77
|
await this.#notifyStart({
|
|
82
|
-
baseUrl: this.apiBaseUrl
|
|
78
|
+
baseUrl: this.client.apiBaseUrl,
|
|
83
79
|
token: this.#creds.botToken,
|
|
84
80
|
});
|
|
85
|
-
this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl
|
|
81
|
+
this.#configManager = new WeixinConfigManager({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken }, (msg) => this.#logger.debug(msg));
|
|
86
82
|
this.#pollAbort = new AbortController();
|
|
87
83
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
88
84
|
this.#startMediaSweep();
|
|
@@ -90,6 +86,9 @@ export class WeixinIlinkEndpoint {
|
|
|
90
86
|
op: 'connect',
|
|
91
87
|
endpoint: this.#options.config.id,
|
|
92
88
|
}));
|
|
89
|
+
void this.#emitPlatformEvent('system.online', {
|
|
90
|
+
account: this.#options.config.id,
|
|
91
|
+
});
|
|
93
92
|
}
|
|
94
93
|
catch (error) {
|
|
95
94
|
await this.stop();
|
|
@@ -118,7 +117,7 @@ export class WeixinIlinkEndpoint {
|
|
|
118
117
|
flushContextTokenPersist(this.#options.config.id);
|
|
119
118
|
if (this.#creds?.botToken) {
|
|
120
119
|
try {
|
|
121
|
-
await this.#notifyStop({ baseUrl: this.apiBaseUrl
|
|
120
|
+
await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
|
|
122
121
|
}
|
|
123
122
|
catch (err) {
|
|
124
123
|
this.#logger.warn(formatCompact({
|
|
@@ -129,6 +128,9 @@ export class WeixinIlinkEndpoint {
|
|
|
129
128
|
}
|
|
130
129
|
}
|
|
131
130
|
this.#started = false;
|
|
131
|
+
void this.#emitPlatformEvent('system.offline', {
|
|
132
|
+
account: this.#options.config.id,
|
|
133
|
+
});
|
|
132
134
|
this.#logger.debug(formatCompact({
|
|
133
135
|
op: 'disconnect',
|
|
134
136
|
}));
|
|
@@ -139,7 +141,7 @@ export class WeixinIlinkEndpoint {
|
|
|
139
141
|
}
|
|
140
142
|
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
141
143
|
const target = conversation.id;
|
|
142
|
-
const contextToken =
|
|
144
|
+
const contextToken = this.client.contextToken(target);
|
|
143
145
|
if (!contextToken) {
|
|
144
146
|
this.#logger.warn(formatCompact({
|
|
145
147
|
op: 'send',
|
|
@@ -149,11 +151,6 @@ export class WeixinIlinkEndpoint {
|
|
|
149
151
|
}));
|
|
150
152
|
throw new Error(`missing context_token for peer ${target}`);
|
|
151
153
|
}
|
|
152
|
-
const apiOpts = {
|
|
153
|
-
baseUrl: this.apiBaseUrl(),
|
|
154
|
-
token: this.#creds.botToken,
|
|
155
|
-
contextToken,
|
|
156
|
-
};
|
|
157
154
|
const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
|
|
158
155
|
const wire = formatOutboundSegments(payload);
|
|
159
156
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
@@ -176,21 +173,11 @@ export class WeixinIlinkEndpoint {
|
|
|
176
173
|
if (filePath && fs.existsSync(filePath)) {
|
|
177
174
|
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
178
175
|
if (pendingText.trim()) {
|
|
179
|
-
const textResult = await this
|
|
180
|
-
to: target,
|
|
181
|
-
text: pendingText,
|
|
182
|
-
opts: apiOpts,
|
|
183
|
-
});
|
|
176
|
+
const textResult = await this.client.sendText(target, pendingText);
|
|
184
177
|
pendingText = '';
|
|
185
178
|
lastId = textResult.messageId;
|
|
186
179
|
}
|
|
187
|
-
const result = await
|
|
188
|
-
filePath,
|
|
189
|
-
to: target,
|
|
190
|
-
text: '',
|
|
191
|
-
opts: apiOpts,
|
|
192
|
-
cdnBaseUrl: this.cdnBaseUrl(),
|
|
193
|
-
});
|
|
180
|
+
const result = await this.client.sendMedia(target, filePath);
|
|
194
181
|
lastId = result.messageId;
|
|
195
182
|
continue;
|
|
196
183
|
}
|
|
@@ -203,11 +190,7 @@ export class WeixinIlinkEndpoint {
|
|
|
203
190
|
}
|
|
204
191
|
}
|
|
205
192
|
if (pendingText.trim()) {
|
|
206
|
-
const result = await this
|
|
207
|
-
to: target,
|
|
208
|
-
text: pendingText,
|
|
209
|
-
opts: apiOpts,
|
|
210
|
-
});
|
|
193
|
+
const result = await this.client.sendText(target, pendingText);
|
|
211
194
|
lastId = result.messageId;
|
|
212
195
|
}
|
|
213
196
|
return lastId || `weixin-ilink-${Date.now()}`;
|
|
@@ -222,7 +205,7 @@ export class WeixinIlinkEndpoint {
|
|
|
222
205
|
}
|
|
223
206
|
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
224
207
|
const replyTo = weixinReplyTo(msg);
|
|
225
|
-
void this
|
|
208
|
+
void this.emit('message.receive', {
|
|
226
209
|
conversation,
|
|
227
210
|
message: { conversation, id: inboundMessageId(msg) },
|
|
228
211
|
content: formatInboundContent(msg),
|
|
@@ -242,29 +225,6 @@ export class WeixinIlinkEndpoint {
|
|
|
242
225
|
}));
|
|
243
226
|
});
|
|
244
227
|
}
|
|
245
|
-
async sendTypingToUser(userId, status) {
|
|
246
|
-
if (!this.#creds?.botToken || !this.#configManager)
|
|
247
|
-
return;
|
|
248
|
-
const contextToken = getContextToken(this.#options.config.id, userId);
|
|
249
|
-
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
250
|
-
if (!cfg.typingTicket) {
|
|
251
|
-
this.#logger.debug(formatCompact({
|
|
252
|
-
op: 'send_typing',
|
|
253
|
-
skip: 'no_typing_ticket',
|
|
254
|
-
userId,
|
|
255
|
-
}));
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
await sendTyping({
|
|
259
|
-
baseUrl: this.apiBaseUrl(),
|
|
260
|
-
token: this.#creds.botToken,
|
|
261
|
-
body: {
|
|
262
|
-
ilink_user_id: userId,
|
|
263
|
-
typing_ticket: cfg.typingTicket,
|
|
264
|
-
status,
|
|
265
|
-
},
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
228
|
async #pollLoop(abortSignal) {
|
|
269
229
|
if (!this.#creds?.botToken)
|
|
270
230
|
return;
|
|
@@ -279,7 +239,7 @@ export class WeixinIlinkEndpoint {
|
|
|
279
239
|
}
|
|
280
240
|
try {
|
|
281
241
|
const resp = await this.#getUpdates({
|
|
282
|
-
baseUrl: this.apiBaseUrl
|
|
242
|
+
baseUrl: this.client.apiBaseUrl,
|
|
283
243
|
token: this.#creds.botToken,
|
|
284
244
|
get_updates_buf: getUpdatesBuf,
|
|
285
245
|
timeoutMs: nextTimeoutMs,
|
|
@@ -338,6 +298,7 @@ export class WeixinIlinkEndpoint {
|
|
|
338
298
|
}
|
|
339
299
|
}
|
|
340
300
|
async #handleInboundMessage(full) {
|
|
301
|
+
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
341
302
|
const fromUserId = full.from_user_id ?? '';
|
|
342
303
|
if (full.context_token && fromUserId) {
|
|
343
304
|
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
@@ -351,6 +312,15 @@ export class WeixinIlinkEndpoint {
|
|
|
351
312
|
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
352
313
|
}));
|
|
353
314
|
}
|
|
315
|
+
async #emitPlatformEvent(name, event) {
|
|
316
|
+
await this.emitPlatform(name, event).catch((error) => {
|
|
317
|
+
this.#logger.warn(formatCompact({
|
|
318
|
+
op: 'weixin_ilink_platform_event_failed',
|
|
319
|
+
event: name,
|
|
320
|
+
error: error instanceof Error ? error.message : String(error),
|
|
321
|
+
}));
|
|
322
|
+
});
|
|
323
|
+
}
|
|
354
324
|
async #downloadInboundMedia(full) {
|
|
355
325
|
const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
|
|
356
326
|
const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
|
|
@@ -360,7 +330,7 @@ export class WeixinIlinkEndpoint {
|
|
|
360
330
|
if (!mediaItem)
|
|
361
331
|
return {};
|
|
362
332
|
return downloadMediaFromItem(mediaItem, {
|
|
363
|
-
cdnBaseUrl: this.cdnBaseUrl
|
|
333
|
+
cdnBaseUrl: this.client.cdnBaseUrl,
|
|
364
334
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
365
335
|
log: (msg) => this.#logger.debug(msg),
|
|
366
336
|
errLog: (msg) => this.#logger.warn(msg),
|
|
@@ -432,13 +402,13 @@ export class WeixinIlinkEndpoint {
|
|
|
432
402
|
}
|
|
433
403
|
}
|
|
434
404
|
}
|
|
435
|
-
function createWeixinIlinkEndpointManagement(
|
|
405
|
+
function createWeixinIlinkEndpointManagement(requireClient) {
|
|
436
406
|
return Object.freeze({
|
|
437
407
|
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
438
408
|
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
439
409
|
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
440
410
|
async listFriends() {
|
|
441
|
-
return listContextTokenUserIds(
|
|
411
|
+
return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
|
|
442
412
|
user_id: userId,
|
|
443
413
|
nickname: userId,
|
|
444
414
|
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { formatInboundContent, formatOutboundSegments, inboundMessageId, resolveWeixinIlinkConfig, segmentLocalPath, sleep, type ResolvedWeixinIlinkConfig, type WeixinIlinkAdapterConfig, type WeixinIlinkEndpointConfig, type WeixinInboundMediaPaths, type WeixinMessageWithMedia, type WeixinWireSegment, } from './protocol.js';
|
|
2
2
|
export type { WeixinMessage, MessageItem } from './ilink-types.js';
|
|
3
3
|
export type { WeixinIlinkCredentials } from './credentials.js';
|
|
4
|
+
export { WeixinIlinkClient, weixinIlinkClient, type WeixinIlinkClientEventMap, } from './client.js';
|
|
4
5
|
export { WeixinIlinkEndpoint, type WeixinIlinkEndpointOptions, type WeixinIlinkGetUpdates, type WeixinIlinkNotifyStart, type WeixinIlinkNotifyStop, type WeixinIlinkSendText, } from './endpoint.js';
|
package/lib/index.js
CHANGED
package/lib/protocol.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export type WeixinMessageWithMedia = WeixinMessage & {
|
|
|
41
41
|
readonly _media?: WeixinInboundMediaPaths;
|
|
42
42
|
};
|
|
43
43
|
export declare function resolveWeixinIlinkConfig(config?: WeixinIlinkAdapterConfig): ResolvedWeixinIlinkConfig;
|
|
44
|
-
/** Build inbound text for
|
|
44
|
+
/** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
|
|
45
45
|
export declare function formatInboundContent(msg: WeixinMessageWithMedia): string;
|
|
46
46
|
export declare function weixinInboundSegments(msg: WeixinMessageWithMedia): readonly Segment[];
|
|
47
47
|
export declare function weixinReplyTo(msg: WeixinMessageWithMedia): Readonly<{
|
package/lib/protocol.js
CHANGED
|
@@ -28,7 +28,7 @@ export function resolveWeixinIlinkConfig(config = {}) {
|
|
|
28
28
|
botToken,
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
-
/** Build inbound text for
|
|
31
|
+
/** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
|
|
32
32
|
export function formatInboundContent(msg) {
|
|
33
33
|
return bodyFromItemList(msg.item_list).trim();
|
|
34
34
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
1
|
+
export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-weixin-ilink",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -48,16 +48,17 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@zhin.js/adapter": "1.2.
|
|
52
|
-
"@zhin.js/core": "1.5.
|
|
51
|
+
"@zhin.js/adapter": "1.2.1",
|
|
52
|
+
"@zhin.js/core": "1.5.15",
|
|
53
|
+
"@zhin.js/feature-kit": "1.0.13",
|
|
53
54
|
"@zhin.js/im-contract": "1.0.4",
|
|
54
|
-
"@zhin.js/logger": "1.0.
|
|
55
|
+
"@zhin.js/logger": "1.0.77"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
|
-
"@zhin.js/adapter": "1.2.
|
|
58
|
-
"@zhin.js/command": "1.0.
|
|
59
|
-
"@zhin.js/core": "1.5.
|
|
60
|
-
"zhin.js": "6.0.
|
|
58
|
+
"@zhin.js/adapter": "1.2.1",
|
|
59
|
+
"@zhin.js/command": "1.0.16",
|
|
60
|
+
"@zhin.js/core": "1.5.15",
|
|
61
|
+
"zhin.js": "6.0.15"
|
|
61
62
|
},
|
|
62
63
|
"peerDependenciesMeta": {
|
|
63
64
|
"@zhin.js/command": {
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
"@types/node": "^26.1.2",
|
|
72
73
|
"typescript": "^6.0.3",
|
|
73
74
|
"vitest": "^4.1.10",
|
|
74
|
-
"zhin.js": "6.0.
|
|
75
|
+
"zhin.js": "6.0.15"
|
|
75
76
|
},
|
|
76
77
|
"ilink_appid": "bot",
|
|
77
78
|
"engines": {
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getContextToken } from './context-store.js';
|
|
2
|
+
import type { WeixinIlinkCredentials } from './credentials.js';
|
|
3
|
+
import { sendTyping } from './ilink-api.js';
|
|
4
|
+
import type { WeixinConfigManager } from './ilink-config-cache.js';
|
|
5
|
+
import type { ResolvedWeixinIlinkConfig } from './protocol.js';
|
|
6
|
+
import { sendMessageWeixin } from './weixin-send.js';
|
|
7
|
+
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
8
|
+
import { defineEndpointClient } from 'zhin.js/adapter';
|
|
9
|
+
|
|
10
|
+
/** Direct iLink API client backed by the Endpoint's live authenticated session. */
|
|
11
|
+
export class WeixinIlinkClient {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly config: ResolvedWeixinIlinkConfig,
|
|
14
|
+
private readonly resolveCredentials: () => WeixinIlinkCredentials | null,
|
|
15
|
+
private readonly resolveConfigManager: () => WeixinConfigManager | undefined,
|
|
16
|
+
private readonly sendTextImpl: typeof sendMessageWeixin,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
get credentials(): WeixinIlinkCredentials {
|
|
20
|
+
const credentials = this.resolveCredentials();
|
|
21
|
+
if (!credentials?.botToken) throw new Error('weixin-ilink client not authenticated');
|
|
22
|
+
return credentials;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get authenticated(): boolean {
|
|
26
|
+
return Boolean(this.resolveCredentials()?.botToken);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get apiBaseUrl(): string {
|
|
30
|
+
return this.resolveCredentials()?.baseUrl ?? this.config.baseUrl;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get cdnBaseUrl(): string {
|
|
34
|
+
return this.config.cdnBaseUrl;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
contextToken(userId: string): string | undefined {
|
|
38
|
+
return getContextToken(this.config.id, userId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async sendText(to: string, text: string): Promise<{ messageId: string }> {
|
|
42
|
+
const contextToken = this.contextToken(to);
|
|
43
|
+
if (!contextToken) throw new Error(`missing context_token for peer ${to}`);
|
|
44
|
+
return this.sendTextImpl({
|
|
45
|
+
to,
|
|
46
|
+
text,
|
|
47
|
+
opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async sendMedia(to: string, filePath: string, text = ''): Promise<{ messageId: string }> {
|
|
52
|
+
const contextToken = this.contextToken(to);
|
|
53
|
+
if (!contextToken) throw new Error(`missing context_token for peer ${to}`);
|
|
54
|
+
return sendWeixinMediaFile({
|
|
55
|
+
filePath,
|
|
56
|
+
to,
|
|
57
|
+
text,
|
|
58
|
+
opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
|
|
59
|
+
cdnBaseUrl: this.cdnBaseUrl,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async sendTyping(userId: string, status: number): Promise<boolean> {
|
|
64
|
+
const manager = this.resolveConfigManager();
|
|
65
|
+
if (!this.authenticated || !manager) return false;
|
|
66
|
+
const cfg = await manager.getForUser(userId, this.contextToken(userId));
|
|
67
|
+
if (!cfg.typingTicket) return false;
|
|
68
|
+
await sendTyping({
|
|
69
|
+
baseUrl: this.apiBaseUrl,
|
|
70
|
+
token: this.credentials.botToken,
|
|
71
|
+
body: { ilink_user_id: userId, typing_ticket: cfg.typingTicket, status },
|
|
72
|
+
});
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type WeixinIlinkClientEventMap = Record<string, unknown>;
|
|
78
|
+
|
|
79
|
+
declare module '@zhin.js/feature-kit' {
|
|
80
|
+
interface AdapterClientRegistry {
|
|
81
|
+
readonly 'weixin-ilink': { readonly client: WeixinIlinkClient; readonly events: WeixinIlinkClientEventMap };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const weixinIlinkClient = defineEndpointClient<WeixinIlinkClient, WeixinIlinkClientEventMap>('weixin-ilink');
|
package/src/endpoint.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
3
|
* WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
|
|
3
4
|
*/
|
|
@@ -7,14 +8,12 @@ import path from 'node:path';
|
|
|
7
8
|
import type {
|
|
8
9
|
EndpointFriend,
|
|
9
10
|
EndpointControl,
|
|
10
|
-
EndpointInstance,
|
|
11
11
|
EndpointManagement,
|
|
12
12
|
EndpointSendRequest,
|
|
13
13
|
} from 'zhin.js/adapter';
|
|
14
|
-
import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
|
|
15
14
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
16
15
|
import type { CapabilityId } from 'zhin.js';
|
|
17
|
-
import { getUpdates, notifyStart, notifyStop
|
|
16
|
+
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
18
17
|
import { configureIlinkMeta } from './ilink-meta.js';
|
|
19
18
|
import {
|
|
20
19
|
loadSyncBuf,
|
|
@@ -24,7 +23,6 @@ import {
|
|
|
24
23
|
} from './credentials.js';
|
|
25
24
|
import { resolveCredentials } from './login.js';
|
|
26
25
|
import {
|
|
27
|
-
getContextToken,
|
|
28
26
|
listContextTokenUserIds,
|
|
29
27
|
restoreContextTokens,
|
|
30
28
|
setContextToken,
|
|
@@ -39,7 +37,6 @@ import {
|
|
|
39
37
|
} from './ilink-session-guard.js';
|
|
40
38
|
import { downloadMediaFromItem } from './media-download.js';
|
|
41
39
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
42
|
-
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
43
40
|
import { materializeOutboundMedia } from './outbound-media.js';
|
|
44
41
|
import { MessageItemType, type WeixinMessage } from './ilink-types.js';
|
|
45
42
|
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
@@ -57,6 +54,7 @@ import {
|
|
|
57
54
|
type WeixinMessageWithMedia,
|
|
58
55
|
type WeixinWireSegment,
|
|
59
56
|
} from './protocol.js';
|
|
57
|
+
import { WeixinIlinkClient } from './client.js';
|
|
60
58
|
|
|
61
59
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
62
60
|
const BACKOFF_DELAY_MS = 30_000;
|
|
@@ -73,8 +71,6 @@ export type WeixinIlinkGetUpdates = typeof getUpdates;
|
|
|
73
71
|
|
|
74
72
|
export interface WeixinIlinkEndpointOptions {
|
|
75
73
|
readonly id: CapabilityId;
|
|
76
|
-
readonly gateway: MessageGateway;
|
|
77
|
-
readonly sideEvents?: SideEventGateway;
|
|
78
74
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
79
75
|
readonly resolveCredentials?: (
|
|
80
76
|
config: ResolvedWeixinIlinkConfig,
|
|
@@ -87,7 +83,8 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
87
83
|
readonly sendText?: WeixinIlinkSendText;
|
|
88
84
|
}
|
|
89
85
|
|
|
90
|
-
export class WeixinIlinkEndpoint
|
|
86
|
+
export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
87
|
+
readonly client: WeixinIlinkClient;
|
|
91
88
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
92
89
|
|
|
93
90
|
readonly #options: WeixinIlinkEndpointOptions;
|
|
@@ -107,15 +104,18 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
107
104
|
#mediaSweepTimer?: ReturnType<typeof setInterval>;
|
|
108
105
|
#open = false;
|
|
109
106
|
#started = false;
|
|
110
|
-
readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
|
|
107
|
+
readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(() => this.client);
|
|
111
108
|
readonly control: EndpointControl = Object.freeze<EndpointControl>({
|
|
112
|
-
typing: (conversation, active) =>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
typing: async (conversation, active) => {
|
|
110
|
+
await this.client.sendTyping(
|
|
111
|
+
conversation.id,
|
|
112
|
+
active === false ? 2 : 1,
|
|
113
|
+
);
|
|
114
|
+
},
|
|
116
115
|
});
|
|
117
116
|
|
|
118
117
|
constructor(options: WeixinIlinkEndpointOptions) {
|
|
118
|
+
super();
|
|
119
119
|
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
120
120
|
this.#options = options;
|
|
121
121
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
@@ -123,23 +123,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
123
123
|
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
124
124
|
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
125
125
|
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
126
|
+
this.client = new WeixinIlinkClient(
|
|
127
|
+
options.config,
|
|
128
|
+
() => this.#creds,
|
|
129
|
+
() => this.#configManager,
|
|
130
|
+
this.#sendText,
|
|
131
|
+
);
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
get hasCredentials(): boolean {
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
133
|
-
get configName(): string {
|
|
134
|
-
return this.#options.config.id;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
apiBaseUrl(): string {
|
|
138
|
-
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
cdnBaseUrl(): string {
|
|
142
|
-
return this.#options.config.cdnBaseUrl;
|
|
135
|
+
return this.client.authenticated;
|
|
143
136
|
}
|
|
144
137
|
|
|
145
138
|
async start(): Promise<void> {
|
|
@@ -153,12 +146,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
153
146
|
restoreContextTokens(this.#options.config.id);
|
|
154
147
|
|
|
155
148
|
await this.#notifyStart({
|
|
156
|
-
baseUrl: this.apiBaseUrl
|
|
149
|
+
baseUrl: this.client.apiBaseUrl,
|
|
157
150
|
token: this.#creds.botToken,
|
|
158
151
|
});
|
|
159
152
|
|
|
160
153
|
this.#configManager = new WeixinConfigManager(
|
|
161
|
-
{ baseUrl: this.apiBaseUrl
|
|
154
|
+
{ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken },
|
|
162
155
|
(msg) => this.#logger.debug(msg),
|
|
163
156
|
);
|
|
164
157
|
|
|
@@ -169,6 +162,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
169
162
|
op: 'connect',
|
|
170
163
|
endpoint: this.#options.config.id,
|
|
171
164
|
}));
|
|
165
|
+
void this.#emitPlatformEvent('system.online', {
|
|
166
|
+
account: this.#options.config.id,
|
|
167
|
+
});
|
|
172
168
|
} catch (error) {
|
|
173
169
|
await this.stop();
|
|
174
170
|
this.#logger.error('Failed to connect weixin-ilink bot:', error);
|
|
@@ -198,7 +194,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
198
194
|
flushContextTokenPersist(this.#options.config.id);
|
|
199
195
|
if (this.#creds?.botToken) {
|
|
200
196
|
try {
|
|
201
|
-
await this.#notifyStop({ baseUrl: this.apiBaseUrl
|
|
197
|
+
await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
|
|
202
198
|
} catch (err) {
|
|
203
199
|
this.#logger.warn(formatCompact({
|
|
204
200
|
op: 'notify_stop',
|
|
@@ -208,6 +204,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
208
204
|
}
|
|
209
205
|
}
|
|
210
206
|
this.#started = false;
|
|
207
|
+
void this.#emitPlatformEvent('system.offline', {
|
|
208
|
+
account: this.#options.config.id,
|
|
209
|
+
});
|
|
211
210
|
this.#logger.debug(formatCompact({
|
|
212
211
|
op: 'disconnect',
|
|
213
212
|
}));
|
|
@@ -219,7 +218,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
219
218
|
}
|
|
220
219
|
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
221
220
|
const target = conversation.id;
|
|
222
|
-
const contextToken =
|
|
221
|
+
const contextToken = this.client.contextToken(target);
|
|
223
222
|
if (!contextToken) {
|
|
224
223
|
this.#logger.warn(formatCompact({
|
|
225
224
|
op: 'send',
|
|
@@ -230,12 +229,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
230
229
|
throw new Error(`missing context_token for peer ${target}`);
|
|
231
230
|
}
|
|
232
231
|
|
|
233
|
-
const apiOpts = {
|
|
234
|
-
baseUrl: this.apiBaseUrl(),
|
|
235
|
-
token: this.#creds.botToken,
|
|
236
|
-
contextToken,
|
|
237
|
-
};
|
|
238
|
-
|
|
239
232
|
const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
|
|
240
233
|
const wire = formatOutboundSegments(payload);
|
|
241
234
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
@@ -259,21 +252,11 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
259
252
|
if (filePath && fs.existsSync(filePath)) {
|
|
260
253
|
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
261
254
|
if (pendingText.trim()) {
|
|
262
|
-
const textResult = await this
|
|
263
|
-
to: target,
|
|
264
|
-
text: pendingText,
|
|
265
|
-
opts: apiOpts,
|
|
266
|
-
});
|
|
255
|
+
const textResult = await this.client.sendText(target, pendingText);
|
|
267
256
|
pendingText = '';
|
|
268
257
|
lastId = textResult.messageId;
|
|
269
258
|
}
|
|
270
|
-
const result = await
|
|
271
|
-
filePath,
|
|
272
|
-
to: target,
|
|
273
|
-
text: '',
|
|
274
|
-
opts: apiOpts,
|
|
275
|
-
cdnBaseUrl: this.cdnBaseUrl(),
|
|
276
|
-
});
|
|
259
|
+
const result = await this.client.sendMedia(target, filePath);
|
|
277
260
|
lastId = result.messageId;
|
|
278
261
|
continue;
|
|
279
262
|
}
|
|
@@ -288,11 +271,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
288
271
|
}
|
|
289
272
|
|
|
290
273
|
if (pendingText.trim()) {
|
|
291
|
-
const result = await this
|
|
292
|
-
to: target,
|
|
293
|
-
text: pendingText,
|
|
294
|
-
opts: apiOpts,
|
|
295
|
-
});
|
|
274
|
+
const result = await this.client.sendText(target, pendingText);
|
|
296
275
|
lastId = result.messageId;
|
|
297
276
|
}
|
|
298
277
|
|
|
@@ -308,7 +287,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
308
287
|
}
|
|
309
288
|
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
310
289
|
const replyTo = weixinReplyTo(msg);
|
|
311
|
-
void this
|
|
290
|
+
void this.emit('message.receive', {
|
|
312
291
|
conversation,
|
|
313
292
|
message: { conversation, id: inboundMessageId(msg) },
|
|
314
293
|
content: formatInboundContent(msg),
|
|
@@ -329,29 +308,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
329
308
|
});
|
|
330
309
|
}
|
|
331
310
|
|
|
332
|
-
async sendTypingToUser(userId: string, status: number): Promise<void> {
|
|
333
|
-
if (!this.#creds?.botToken || !this.#configManager) return;
|
|
334
|
-
const contextToken = getContextToken(this.#options.config.id, userId);
|
|
335
|
-
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
336
|
-
if (!cfg.typingTicket) {
|
|
337
|
-
this.#logger.debug(formatCompact({
|
|
338
|
-
op: 'send_typing',
|
|
339
|
-
skip: 'no_typing_ticket',
|
|
340
|
-
userId,
|
|
341
|
-
}));
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
await sendTyping({
|
|
345
|
-
baseUrl: this.apiBaseUrl(),
|
|
346
|
-
token: this.#creds.botToken,
|
|
347
|
-
body: {
|
|
348
|
-
ilink_user_id: userId,
|
|
349
|
-
typing_ticket: cfg.typingTicket,
|
|
350
|
-
status,
|
|
351
|
-
},
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
|
|
355
311
|
async #pollLoop(abortSignal: AbortSignal): Promise<void> {
|
|
356
312
|
if (!this.#creds?.botToken) return;
|
|
357
313
|
|
|
@@ -368,7 +324,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
368
324
|
|
|
369
325
|
try {
|
|
370
326
|
const resp = await this.#getUpdates({
|
|
371
|
-
baseUrl: this.apiBaseUrl
|
|
327
|
+
baseUrl: this.client.apiBaseUrl,
|
|
372
328
|
token: this.#creds.botToken,
|
|
373
329
|
get_updates_buf: getUpdatesBuf,
|
|
374
330
|
timeoutMs: nextTimeoutMs,
|
|
@@ -430,6 +386,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
430
386
|
}
|
|
431
387
|
|
|
432
388
|
async #handleInboundMessage(full: WeixinMessage): Promise<void> {
|
|
389
|
+
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
433
390
|
const fromUserId = full.from_user_id ?? '';
|
|
434
391
|
if (full.context_token && fromUserId) {
|
|
435
392
|
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
@@ -445,6 +402,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
445
402
|
}));
|
|
446
403
|
}
|
|
447
404
|
|
|
405
|
+
async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
|
|
406
|
+
await this.emitPlatform(name, event).catch((error) => {
|
|
407
|
+
this.#logger.warn(formatCompact({
|
|
408
|
+
op: 'weixin_ilink_platform_event_failed',
|
|
409
|
+
event: name,
|
|
410
|
+
error: error instanceof Error ? error.message : String(error),
|
|
411
|
+
}));
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
448
415
|
async #downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
|
|
449
416
|
const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
|
|
450
417
|
Boolean(m?.encrypt_query_param || m?.full_url);
|
|
@@ -466,7 +433,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
466
433
|
if (!mediaItem) return {};
|
|
467
434
|
|
|
468
435
|
return downloadMediaFromItem(mediaItem, {
|
|
469
|
-
cdnBaseUrl: this.cdnBaseUrl
|
|
436
|
+
cdnBaseUrl: this.client.cdnBaseUrl,
|
|
470
437
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
|
|
471
438
|
this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
472
439
|
log: (msg) => this.#logger.debug(msg),
|
|
@@ -546,13 +513,15 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
546
513
|
}
|
|
547
514
|
}
|
|
548
515
|
|
|
549
|
-
function createWeixinIlinkEndpointManagement(
|
|
516
|
+
function createWeixinIlinkEndpointManagement(
|
|
517
|
+
requireClient: () => WeixinIlinkClient,
|
|
518
|
+
): EndpointManagement {
|
|
550
519
|
return Object.freeze<EndpointManagement>({
|
|
551
520
|
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
552
521
|
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
553
522
|
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
554
523
|
async listFriends(): Promise<readonly EndpointFriend[]> {
|
|
555
|
-
return listContextTokenUserIds(
|
|
524
|
+
return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
|
|
556
525
|
user_id: userId,
|
|
557
526
|
nickname: userId,
|
|
558
527
|
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ export {
|
|
|
16
16
|
export type { WeixinMessage, MessageItem } from './ilink-types.js';
|
|
17
17
|
export type { WeixinIlinkCredentials } from './credentials.js';
|
|
18
18
|
|
|
19
|
+
export {
|
|
20
|
+
WeixinIlinkClient,
|
|
21
|
+
weixinIlinkClient,
|
|
22
|
+
type WeixinIlinkClientEventMap,
|
|
23
|
+
} from './client.js';
|
|
24
|
+
|
|
19
25
|
export {
|
|
20
26
|
WeixinIlinkEndpoint,
|
|
21
27
|
type WeixinIlinkEndpointOptions,
|
package/src/protocol.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function resolveWeixinIlinkConfig(
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** Build inbound text for
|
|
85
|
+
/** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
|
|
86
86
|
export function formatInboundContent(msg: WeixinMessageWithMedia): string {
|
|
87
87
|
return bodyFromItemList(msg.item_list).trim();
|
|
88
88
|
}
|