@zhin.js/adapter-weixin-ilink 5.0.13 → 6.0.1
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 +27 -0
- package/README.md +2 -2
- package/adapters/weixin-ilink.js +1 -3
- package/adapters/weixin-ilink.ts +1 -3
- package/lib/client.d.ts +34 -0
- package/lib/client.js +72 -0
- package/lib/endpoint.d.ts +6 -10
- package/lib/endpoint.js +43 -70
- 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 +54 -78
- package/src/index.ts +6 -0
- package/src/protocol.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @zhin.js/adapter-weixin-ilink
|
|
2
2
|
|
|
3
|
+
## 6.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- Updated dependencies [4e8117c]
|
|
9
|
+
- Updated dependencies [54bfd6b]
|
|
10
|
+
- Updated dependencies [12025ee]
|
|
11
|
+
- Updated dependencies [09b14d6]
|
|
12
|
+
- Updated dependencies [1fc78bc]
|
|
13
|
+
- @zhin.js/adapter@1.2.1
|
|
14
|
+
- @zhin.js/core@1.5.14
|
|
15
|
+
- @zhin.js/command@1.0.16
|
|
16
|
+
- @zhin.js/logger@1.0.77
|
|
17
|
+
- zhin.js@6.0.14
|
|
18
|
+
- @zhin.js/feature-kit@1.0.13
|
|
19
|
+
|
|
20
|
+
## 6.0.0
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- f2c532f: Expose exact per-Endpoint message operations through one validated Adapter capability model, route Core control calls through declared active capabilities, and connect existing recall, edit, reaction, and typing implementations across platform adapters.
|
|
25
|
+
- Updated dependencies [f2c532f]
|
|
26
|
+
- @zhin.js/adapter@1.2.0
|
|
27
|
+
- @zhin.js/core@1.5.13
|
|
28
|
+
- zhin.js@6.0.13
|
|
29
|
+
|
|
3
30
|
## 5.0.13
|
|
4
31
|
|
|
5
32
|
### 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,13 +3,13 @@
|
|
|
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";
|
|
10
9
|
export { WeixinIlinkEndpoint } from "../lib/endpoint.js";
|
|
11
10
|
export default defineAdapter({
|
|
12
11
|
capabilities: ['inbound', 'outbound'],
|
|
12
|
+
operations: ['typing'],
|
|
13
13
|
// 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
|
|
14
14
|
// 无直发面;微信无卡片交互面,交互段降级纯文本。
|
|
15
15
|
segments: {
|
|
@@ -26,8 +26,6 @@ export default defineAdapter({
|
|
|
26
26
|
});
|
|
27
27
|
return new WeixinIlinkEndpoint({
|
|
28
28
|
id: context.id,
|
|
29
|
-
gateway: context.use(messageGatewayToken),
|
|
30
|
-
sideEvents: context.use(sideEventGatewayToken),
|
|
31
29
|
config,
|
|
32
30
|
});
|
|
33
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,
|
|
@@ -21,6 +20,7 @@ export type {
|
|
|
21
20
|
|
|
22
21
|
export default defineAdapter<WeixinIlinkAdapterConfig>({
|
|
23
22
|
capabilities: ['inbound', 'outbound'],
|
|
23
|
+
operations: ['typing'],
|
|
24
24
|
// 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
|
|
25
25
|
// 无直发面;微信无卡片交互面,交互段降级纯文本。
|
|
26
26
|
segments: {
|
|
@@ -37,8 +37,6 @@ export default defineAdapter<WeixinIlinkAdapterConfig>({
|
|
|
37
37
|
});
|
|
38
38
|
return new WeixinIlinkEndpoint({
|
|
39
39
|
id: context.id,
|
|
40
|
-
gateway: context.use(messageGatewayToken),
|
|
41
|
-
sideEvents: context.use(sideEventGatewayToken),
|
|
42
40
|
config,
|
|
43
41
|
});
|
|
44
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,15 +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
|
+
readonly control: EndpointControl;
|
|
27
28
|
constructor(options: WeixinIlinkEndpointOptions);
|
|
28
29
|
get hasCredentials(): boolean;
|
|
29
|
-
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
30
|
-
get configName(): string;
|
|
31
|
-
apiBaseUrl(): string;
|
|
32
|
-
cdnBaseUrl(): string;
|
|
33
30
|
start(): Promise<void>;
|
|
34
31
|
open(): void;
|
|
35
32
|
close(): void;
|
|
@@ -37,5 +34,4 @@ export declare class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
37
34
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
38
35
|
/** Test / internal: admit a parsed message when the endpoint is open. */
|
|
39
36
|
admit(msg: WeixinMessageWithMedia): void;
|
|
40
|
-
sendTypingToUser(userId: string, status: number): Promise<void>;
|
|
41
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,8 +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);
|
|
48
|
+
control = Object.freeze({
|
|
49
|
+
typing: async (conversation, active) => {
|
|
50
|
+
await this.client.sendTyping(conversation.id, active === false ? 2 : 1);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
46
53
|
constructor(options) {
|
|
54
|
+
super();
|
|
47
55
|
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
48
56
|
this.#options = options;
|
|
49
57
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
@@ -51,19 +59,10 @@ export class WeixinIlinkEndpoint {
|
|
|
51
59
|
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
52
60
|
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
53
61
|
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
62
|
+
this.client = new WeixinIlinkClient(options.config, () => this.#creds, () => this.#configManager, this.#sendText);
|
|
54
63
|
}
|
|
55
64
|
get hasCredentials() {
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
59
|
-
get configName() {
|
|
60
|
-
return this.#options.config.id;
|
|
61
|
-
}
|
|
62
|
-
apiBaseUrl() {
|
|
63
|
-
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
64
|
-
}
|
|
65
|
-
cdnBaseUrl() {
|
|
66
|
-
return this.#options.config.cdnBaseUrl;
|
|
65
|
+
return this.client.authenticated;
|
|
67
66
|
}
|
|
68
67
|
async start() {
|
|
69
68
|
if (this.#started)
|
|
@@ -76,10 +75,10 @@ export class WeixinIlinkEndpoint {
|
|
|
76
75
|
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
77
76
|
restoreContextTokens(this.#options.config.id);
|
|
78
77
|
await this.#notifyStart({
|
|
79
|
-
baseUrl: this.apiBaseUrl
|
|
78
|
+
baseUrl: this.client.apiBaseUrl,
|
|
80
79
|
token: this.#creds.botToken,
|
|
81
80
|
});
|
|
82
|
-
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));
|
|
83
82
|
this.#pollAbort = new AbortController();
|
|
84
83
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
85
84
|
this.#startMediaSweep();
|
|
@@ -87,6 +86,9 @@ export class WeixinIlinkEndpoint {
|
|
|
87
86
|
op: 'connect',
|
|
88
87
|
endpoint: this.#options.config.id,
|
|
89
88
|
}));
|
|
89
|
+
void this.#emitPlatformEvent('system.online', {
|
|
90
|
+
account: this.#options.config.id,
|
|
91
|
+
});
|
|
90
92
|
}
|
|
91
93
|
catch (error) {
|
|
92
94
|
await this.stop();
|
|
@@ -115,7 +117,7 @@ export class WeixinIlinkEndpoint {
|
|
|
115
117
|
flushContextTokenPersist(this.#options.config.id);
|
|
116
118
|
if (this.#creds?.botToken) {
|
|
117
119
|
try {
|
|
118
|
-
await this.#notifyStop({ baseUrl: this.apiBaseUrl
|
|
120
|
+
await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
|
|
119
121
|
}
|
|
120
122
|
catch (err) {
|
|
121
123
|
this.#logger.warn(formatCompact({
|
|
@@ -126,6 +128,9 @@ export class WeixinIlinkEndpoint {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
this.#started = false;
|
|
131
|
+
void this.#emitPlatformEvent('system.offline', {
|
|
132
|
+
account: this.#options.config.id,
|
|
133
|
+
});
|
|
129
134
|
this.#logger.debug(formatCompact({
|
|
130
135
|
op: 'disconnect',
|
|
131
136
|
}));
|
|
@@ -136,7 +141,7 @@ export class WeixinIlinkEndpoint {
|
|
|
136
141
|
}
|
|
137
142
|
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
138
143
|
const target = conversation.id;
|
|
139
|
-
const contextToken =
|
|
144
|
+
const contextToken = this.client.contextToken(target);
|
|
140
145
|
if (!contextToken) {
|
|
141
146
|
this.#logger.warn(formatCompact({
|
|
142
147
|
op: 'send',
|
|
@@ -146,11 +151,6 @@ export class WeixinIlinkEndpoint {
|
|
|
146
151
|
}));
|
|
147
152
|
throw new Error(`missing context_token for peer ${target}`);
|
|
148
153
|
}
|
|
149
|
-
const apiOpts = {
|
|
150
|
-
baseUrl: this.apiBaseUrl(),
|
|
151
|
-
token: this.#creds.botToken,
|
|
152
|
-
contextToken,
|
|
153
|
-
};
|
|
154
154
|
const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
|
|
155
155
|
const wire = formatOutboundSegments(payload);
|
|
156
156
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
@@ -173,21 +173,11 @@ export class WeixinIlinkEndpoint {
|
|
|
173
173
|
if (filePath && fs.existsSync(filePath)) {
|
|
174
174
|
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
175
175
|
if (pendingText.trim()) {
|
|
176
|
-
const textResult = await this
|
|
177
|
-
to: target,
|
|
178
|
-
text: pendingText,
|
|
179
|
-
opts: apiOpts,
|
|
180
|
-
});
|
|
176
|
+
const textResult = await this.client.sendText(target, pendingText);
|
|
181
177
|
pendingText = '';
|
|
182
178
|
lastId = textResult.messageId;
|
|
183
179
|
}
|
|
184
|
-
const result = await
|
|
185
|
-
filePath,
|
|
186
|
-
to: target,
|
|
187
|
-
text: '',
|
|
188
|
-
opts: apiOpts,
|
|
189
|
-
cdnBaseUrl: this.cdnBaseUrl(),
|
|
190
|
-
});
|
|
180
|
+
const result = await this.client.sendMedia(target, filePath);
|
|
191
181
|
lastId = result.messageId;
|
|
192
182
|
continue;
|
|
193
183
|
}
|
|
@@ -200,11 +190,7 @@ export class WeixinIlinkEndpoint {
|
|
|
200
190
|
}
|
|
201
191
|
}
|
|
202
192
|
if (pendingText.trim()) {
|
|
203
|
-
const result = await this
|
|
204
|
-
to: target,
|
|
205
|
-
text: pendingText,
|
|
206
|
-
opts: apiOpts,
|
|
207
|
-
});
|
|
193
|
+
const result = await this.client.sendText(target, pendingText);
|
|
208
194
|
lastId = result.messageId;
|
|
209
195
|
}
|
|
210
196
|
return lastId || `weixin-ilink-${Date.now()}`;
|
|
@@ -219,7 +205,7 @@ export class WeixinIlinkEndpoint {
|
|
|
219
205
|
}
|
|
220
206
|
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
221
207
|
const replyTo = weixinReplyTo(msg);
|
|
222
|
-
void this
|
|
208
|
+
void this.emit('message.receive', {
|
|
223
209
|
conversation,
|
|
224
210
|
message: { conversation, id: inboundMessageId(msg) },
|
|
225
211
|
content: formatInboundContent(msg),
|
|
@@ -239,29 +225,6 @@ export class WeixinIlinkEndpoint {
|
|
|
239
225
|
}));
|
|
240
226
|
});
|
|
241
227
|
}
|
|
242
|
-
async sendTypingToUser(userId, status) {
|
|
243
|
-
if (!this.#creds?.botToken || !this.#configManager)
|
|
244
|
-
return;
|
|
245
|
-
const contextToken = getContextToken(this.#options.config.id, userId);
|
|
246
|
-
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
247
|
-
if (!cfg.typingTicket) {
|
|
248
|
-
this.#logger.debug(formatCompact({
|
|
249
|
-
op: 'send_typing',
|
|
250
|
-
skip: 'no_typing_ticket',
|
|
251
|
-
userId,
|
|
252
|
-
}));
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
await sendTyping({
|
|
256
|
-
baseUrl: this.apiBaseUrl(),
|
|
257
|
-
token: this.#creds.botToken,
|
|
258
|
-
body: {
|
|
259
|
-
ilink_user_id: userId,
|
|
260
|
-
typing_ticket: cfg.typingTicket,
|
|
261
|
-
status,
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
228
|
async #pollLoop(abortSignal) {
|
|
266
229
|
if (!this.#creds?.botToken)
|
|
267
230
|
return;
|
|
@@ -276,7 +239,7 @@ export class WeixinIlinkEndpoint {
|
|
|
276
239
|
}
|
|
277
240
|
try {
|
|
278
241
|
const resp = await this.#getUpdates({
|
|
279
|
-
baseUrl: this.apiBaseUrl
|
|
242
|
+
baseUrl: this.client.apiBaseUrl,
|
|
280
243
|
token: this.#creds.botToken,
|
|
281
244
|
get_updates_buf: getUpdatesBuf,
|
|
282
245
|
timeoutMs: nextTimeoutMs,
|
|
@@ -335,6 +298,7 @@ export class WeixinIlinkEndpoint {
|
|
|
335
298
|
}
|
|
336
299
|
}
|
|
337
300
|
async #handleInboundMessage(full) {
|
|
301
|
+
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
338
302
|
const fromUserId = full.from_user_id ?? '';
|
|
339
303
|
if (full.context_token && fromUserId) {
|
|
340
304
|
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
@@ -348,6 +312,15 @@ export class WeixinIlinkEndpoint {
|
|
|
348
312
|
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
349
313
|
}));
|
|
350
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
|
+
}
|
|
351
324
|
async #downloadInboundMedia(full) {
|
|
352
325
|
const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
|
|
353
326
|
const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
|
|
@@ -357,7 +330,7 @@ export class WeixinIlinkEndpoint {
|
|
|
357
330
|
if (!mediaItem)
|
|
358
331
|
return {};
|
|
359
332
|
return downloadMediaFromItem(mediaItem, {
|
|
360
|
-
cdnBaseUrl: this.cdnBaseUrl
|
|
333
|
+
cdnBaseUrl: this.client.cdnBaseUrl,
|
|
361
334
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
362
335
|
log: (msg) => this.#logger.debug(msg),
|
|
363
336
|
errLog: (msg) => this.#logger.warn(msg),
|
|
@@ -429,13 +402,13 @@ export class WeixinIlinkEndpoint {
|
|
|
429
402
|
}
|
|
430
403
|
}
|
|
431
404
|
}
|
|
432
|
-
function createWeixinIlinkEndpointManagement(
|
|
405
|
+
function createWeixinIlinkEndpointManagement(requireClient) {
|
|
433
406
|
return Object.freeze({
|
|
434
407
|
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
435
408
|
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
436
409
|
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
437
410
|
async listFriends() {
|
|
438
|
-
return listContextTokenUserIds(
|
|
411
|
+
return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
|
|
439
412
|
user_id: userId,
|
|
440
413
|
nickname: userId,
|
|
441
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": "
|
|
3
|
+
"version": "6.0.1",
|
|
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.1
|
|
52
|
-
"@zhin.js/core": "1.5.
|
|
51
|
+
"@zhin.js/adapter": "1.2.1",
|
|
52
|
+
"@zhin.js/core": "1.5.14",
|
|
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.1
|
|
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.14",
|
|
61
|
+
"zhin.js": "6.0.14"
|
|
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.14"
|
|
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
|
*/
|
|
@@ -6,14 +7,13 @@ import fs from 'node:fs';
|
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import type {
|
|
8
9
|
EndpointFriend,
|
|
9
|
-
|
|
10
|
+
EndpointControl,
|
|
10
11
|
EndpointManagement,
|
|
11
12
|
EndpointSendRequest,
|
|
12
13
|
} from 'zhin.js/adapter';
|
|
13
|
-
import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
|
|
14
14
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
15
15
|
import type { CapabilityId } from 'zhin.js';
|
|
16
|
-
import { getUpdates, notifyStart, notifyStop
|
|
16
|
+
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
17
17
|
import { configureIlinkMeta } from './ilink-meta.js';
|
|
18
18
|
import {
|
|
19
19
|
loadSyncBuf,
|
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
} from './credentials.js';
|
|
24
24
|
import { resolveCredentials } from './login.js';
|
|
25
25
|
import {
|
|
26
|
-
getContextToken,
|
|
27
26
|
listContextTokenUserIds,
|
|
28
27
|
restoreContextTokens,
|
|
29
28
|
setContextToken,
|
|
@@ -38,7 +37,6 @@ import {
|
|
|
38
37
|
} from './ilink-session-guard.js';
|
|
39
38
|
import { downloadMediaFromItem } from './media-download.js';
|
|
40
39
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
41
|
-
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
42
40
|
import { materializeOutboundMedia } from './outbound-media.js';
|
|
43
41
|
import { MessageItemType, type WeixinMessage } from './ilink-types.js';
|
|
44
42
|
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
@@ -56,6 +54,7 @@ import {
|
|
|
56
54
|
type WeixinMessageWithMedia,
|
|
57
55
|
type WeixinWireSegment,
|
|
58
56
|
} from './protocol.js';
|
|
57
|
+
import { WeixinIlinkClient } from './client.js';
|
|
59
58
|
|
|
60
59
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
61
60
|
const BACKOFF_DELAY_MS = 30_000;
|
|
@@ -72,8 +71,6 @@ export type WeixinIlinkGetUpdates = typeof getUpdates;
|
|
|
72
71
|
|
|
73
72
|
export interface WeixinIlinkEndpointOptions {
|
|
74
73
|
readonly id: CapabilityId;
|
|
75
|
-
readonly gateway: MessageGateway;
|
|
76
|
-
readonly sideEvents?: SideEventGateway;
|
|
77
74
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
78
75
|
readonly resolveCredentials?: (
|
|
79
76
|
config: ResolvedWeixinIlinkConfig,
|
|
@@ -86,7 +83,8 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
86
83
|
readonly sendText?: WeixinIlinkSendText;
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
export class WeixinIlinkEndpoint
|
|
86
|
+
export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
87
|
+
readonly client: WeixinIlinkClient;
|
|
90
88
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
91
89
|
|
|
92
90
|
readonly #options: WeixinIlinkEndpointOptions;
|
|
@@ -106,9 +104,18 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
106
104
|
#mediaSweepTimer?: ReturnType<typeof setInterval>;
|
|
107
105
|
#open = false;
|
|
108
106
|
#started = false;
|
|
109
|
-
readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
|
|
107
|
+
readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(() => this.client);
|
|
108
|
+
readonly control: EndpointControl = Object.freeze<EndpointControl>({
|
|
109
|
+
typing: async (conversation, active) => {
|
|
110
|
+
await this.client.sendTyping(
|
|
111
|
+
conversation.id,
|
|
112
|
+
active === false ? 2 : 1,
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
110
116
|
|
|
111
117
|
constructor(options: WeixinIlinkEndpointOptions) {
|
|
118
|
+
super();
|
|
112
119
|
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
113
120
|
this.#options = options;
|
|
114
121
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
@@ -116,23 +123,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
116
123
|
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
117
124
|
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
118
125
|
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
126
|
+
this.client = new WeixinIlinkClient(
|
|
127
|
+
options.config,
|
|
128
|
+
() => this.#creds,
|
|
129
|
+
() => this.#configManager,
|
|
130
|
+
this.#sendText,
|
|
131
|
+
);
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
get hasCredentials(): boolean {
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
126
|
-
get configName(): string {
|
|
127
|
-
return this.#options.config.id;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
apiBaseUrl(): string {
|
|
131
|
-
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
cdnBaseUrl(): string {
|
|
135
|
-
return this.#options.config.cdnBaseUrl;
|
|
135
|
+
return this.client.authenticated;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
async start(): Promise<void> {
|
|
@@ -146,12 +146,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
146
146
|
restoreContextTokens(this.#options.config.id);
|
|
147
147
|
|
|
148
148
|
await this.#notifyStart({
|
|
149
|
-
baseUrl: this.apiBaseUrl
|
|
149
|
+
baseUrl: this.client.apiBaseUrl,
|
|
150
150
|
token: this.#creds.botToken,
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
this.#configManager = new WeixinConfigManager(
|
|
154
|
-
{ baseUrl: this.apiBaseUrl
|
|
154
|
+
{ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken },
|
|
155
155
|
(msg) => this.#logger.debug(msg),
|
|
156
156
|
);
|
|
157
157
|
|
|
@@ -162,6 +162,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
162
162
|
op: 'connect',
|
|
163
163
|
endpoint: this.#options.config.id,
|
|
164
164
|
}));
|
|
165
|
+
void this.#emitPlatformEvent('system.online', {
|
|
166
|
+
account: this.#options.config.id,
|
|
167
|
+
});
|
|
165
168
|
} catch (error) {
|
|
166
169
|
await this.stop();
|
|
167
170
|
this.#logger.error('Failed to connect weixin-ilink bot:', error);
|
|
@@ -191,7 +194,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
191
194
|
flushContextTokenPersist(this.#options.config.id);
|
|
192
195
|
if (this.#creds?.botToken) {
|
|
193
196
|
try {
|
|
194
|
-
await this.#notifyStop({ baseUrl: this.apiBaseUrl
|
|
197
|
+
await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
|
|
195
198
|
} catch (err) {
|
|
196
199
|
this.#logger.warn(formatCompact({
|
|
197
200
|
op: 'notify_stop',
|
|
@@ -201,6 +204,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
201
204
|
}
|
|
202
205
|
}
|
|
203
206
|
this.#started = false;
|
|
207
|
+
void this.#emitPlatformEvent('system.offline', {
|
|
208
|
+
account: this.#options.config.id,
|
|
209
|
+
});
|
|
204
210
|
this.#logger.debug(formatCompact({
|
|
205
211
|
op: 'disconnect',
|
|
206
212
|
}));
|
|
@@ -212,7 +218,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
212
218
|
}
|
|
213
219
|
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
214
220
|
const target = conversation.id;
|
|
215
|
-
const contextToken =
|
|
221
|
+
const contextToken = this.client.contextToken(target);
|
|
216
222
|
if (!contextToken) {
|
|
217
223
|
this.#logger.warn(formatCompact({
|
|
218
224
|
op: 'send',
|
|
@@ -223,12 +229,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
223
229
|
throw new Error(`missing context_token for peer ${target}`);
|
|
224
230
|
}
|
|
225
231
|
|
|
226
|
-
const apiOpts = {
|
|
227
|
-
baseUrl: this.apiBaseUrl(),
|
|
228
|
-
token: this.#creds.botToken,
|
|
229
|
-
contextToken,
|
|
230
|
-
};
|
|
231
|
-
|
|
232
232
|
const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
|
|
233
233
|
const wire = formatOutboundSegments(payload);
|
|
234
234
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
@@ -252,21 +252,11 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
252
252
|
if (filePath && fs.existsSync(filePath)) {
|
|
253
253
|
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
|
|
254
254
|
if (pendingText.trim()) {
|
|
255
|
-
const textResult = await this
|
|
256
|
-
to: target,
|
|
257
|
-
text: pendingText,
|
|
258
|
-
opts: apiOpts,
|
|
259
|
-
});
|
|
255
|
+
const textResult = await this.client.sendText(target, pendingText);
|
|
260
256
|
pendingText = '';
|
|
261
257
|
lastId = textResult.messageId;
|
|
262
258
|
}
|
|
263
|
-
const result = await
|
|
264
|
-
filePath,
|
|
265
|
-
to: target,
|
|
266
|
-
text: '',
|
|
267
|
-
opts: apiOpts,
|
|
268
|
-
cdnBaseUrl: this.cdnBaseUrl(),
|
|
269
|
-
});
|
|
259
|
+
const result = await this.client.sendMedia(target, filePath);
|
|
270
260
|
lastId = result.messageId;
|
|
271
261
|
continue;
|
|
272
262
|
}
|
|
@@ -281,11 +271,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
281
271
|
}
|
|
282
272
|
|
|
283
273
|
if (pendingText.trim()) {
|
|
284
|
-
const result = await this
|
|
285
|
-
to: target,
|
|
286
|
-
text: pendingText,
|
|
287
|
-
opts: apiOpts,
|
|
288
|
-
});
|
|
274
|
+
const result = await this.client.sendText(target, pendingText);
|
|
289
275
|
lastId = result.messageId;
|
|
290
276
|
}
|
|
291
277
|
|
|
@@ -301,7 +287,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
301
287
|
}
|
|
302
288
|
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
303
289
|
const replyTo = weixinReplyTo(msg);
|
|
304
|
-
void this
|
|
290
|
+
void this.emit('message.receive', {
|
|
305
291
|
conversation,
|
|
306
292
|
message: { conversation, id: inboundMessageId(msg) },
|
|
307
293
|
content: formatInboundContent(msg),
|
|
@@ -322,29 +308,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
322
308
|
});
|
|
323
309
|
}
|
|
324
310
|
|
|
325
|
-
async sendTypingToUser(userId: string, status: number): Promise<void> {
|
|
326
|
-
if (!this.#creds?.botToken || !this.#configManager) return;
|
|
327
|
-
const contextToken = getContextToken(this.#options.config.id, userId);
|
|
328
|
-
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
329
|
-
if (!cfg.typingTicket) {
|
|
330
|
-
this.#logger.debug(formatCompact({
|
|
331
|
-
op: 'send_typing',
|
|
332
|
-
skip: 'no_typing_ticket',
|
|
333
|
-
userId,
|
|
334
|
-
}));
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
await sendTyping({
|
|
338
|
-
baseUrl: this.apiBaseUrl(),
|
|
339
|
-
token: this.#creds.botToken,
|
|
340
|
-
body: {
|
|
341
|
-
ilink_user_id: userId,
|
|
342
|
-
typing_ticket: cfg.typingTicket,
|
|
343
|
-
status,
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
311
|
async #pollLoop(abortSignal: AbortSignal): Promise<void> {
|
|
349
312
|
if (!this.#creds?.botToken) return;
|
|
350
313
|
|
|
@@ -361,7 +324,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
361
324
|
|
|
362
325
|
try {
|
|
363
326
|
const resp = await this.#getUpdates({
|
|
364
|
-
baseUrl: this.apiBaseUrl
|
|
327
|
+
baseUrl: this.client.apiBaseUrl,
|
|
365
328
|
token: this.#creds.botToken,
|
|
366
329
|
get_updates_buf: getUpdatesBuf,
|
|
367
330
|
timeoutMs: nextTimeoutMs,
|
|
@@ -423,6 +386,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
423
386
|
}
|
|
424
387
|
|
|
425
388
|
async #handleInboundMessage(full: WeixinMessage): Promise<void> {
|
|
389
|
+
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
426
390
|
const fromUserId = full.from_user_id ?? '';
|
|
427
391
|
if (full.context_token && fromUserId) {
|
|
428
392
|
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
@@ -438,6 +402,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
438
402
|
}));
|
|
439
403
|
}
|
|
440
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
|
+
|
|
441
415
|
async #downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
|
|
442
416
|
const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
|
|
443
417
|
Boolean(m?.encrypt_query_param || m?.full_url);
|
|
@@ -459,7 +433,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
459
433
|
if (!mediaItem) return {};
|
|
460
434
|
|
|
461
435
|
return downloadMediaFromItem(mediaItem, {
|
|
462
|
-
cdnBaseUrl: this.cdnBaseUrl
|
|
436
|
+
cdnBaseUrl: this.client.cdnBaseUrl,
|
|
463
437
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
|
|
464
438
|
this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
465
439
|
log: (msg) => this.#logger.debug(msg),
|
|
@@ -539,13 +513,15 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
539
513
|
}
|
|
540
514
|
}
|
|
541
515
|
|
|
542
|
-
function createWeixinIlinkEndpointManagement(
|
|
516
|
+
function createWeixinIlinkEndpointManagement(
|
|
517
|
+
requireClient: () => WeixinIlinkClient,
|
|
518
|
+
): EndpointManagement {
|
|
543
519
|
return Object.freeze<EndpointManagement>({
|
|
544
520
|
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
545
521
|
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
546
522
|
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
547
523
|
async listFriends(): Promise<readonly EndpointFriend[]> {
|
|
548
|
-
return listContextTokenUserIds(
|
|
524
|
+
return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
|
|
549
525
|
user_id: userId,
|
|
550
526
|
nickname: userId,
|
|
551
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
|
}
|