@zhin.js/adapter-weixin-ilink 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/client/LoginAssistPanel.tsx +200 -0
- package/client/WeixinIlinkManagement.tsx +139 -0
- package/client/index.tsx +11 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/api.ts +30 -0
- package/dist/index.js +29 -0
- package/lib/adapter.d.ts +11 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +14 -0
- package/lib/adapter.js.map +1 -0
- package/lib/aes-ecb.d.ts +7 -0
- package/lib/aes-ecb.d.ts.map +1 -0
- package/lib/aes-ecb.js +19 -0
- package/lib/aes-ecb.js.map +1 -0
- package/lib/bot.d.ts +37 -0
- package/lib/bot.d.ts.map +1 -0
- package/lib/bot.js +383 -0
- package/lib/bot.js.map +1 -0
- package/lib/cdn-upload.d.ts +18 -0
- package/lib/cdn-upload.d.ts.map +1 -0
- package/lib/cdn-upload.js +73 -0
- package/lib/cdn-upload.js.map +1 -0
- package/lib/cdn-url.d.ts +14 -0
- package/lib/cdn-url.d.ts.map +1 -0
- package/lib/cdn-url.js +14 -0
- package/lib/cdn-url.js.map +1 -0
- package/lib/context-store.d.ts +2 -0
- package/lib/context-store.d.ts.map +1 -0
- package/lib/context-store.js +2 -0
- package/lib/context-store.js.map +1 -0
- package/lib/credentials.d.ts +15 -0
- package/lib/credentials.d.ts.map +1 -0
- package/lib/credentials.js +49 -0
- package/lib/credentials.js.map +1 -0
- package/lib/ilink-api.d.ts +81 -0
- package/lib/ilink-api.d.ts.map +1 -0
- package/lib/ilink-api.js +273 -0
- package/lib/ilink-api.js.map +1 -0
- package/lib/ilink-config-cache.d.ts +19 -0
- package/lib/ilink-config-cache.d.ts.map +1 -0
- package/lib/ilink-config-cache.js +64 -0
- package/lib/ilink-config-cache.js.map +1 -0
- package/lib/ilink-logger.d.ts +10 -0
- package/lib/ilink-logger.d.ts.map +1 -0
- package/lib/ilink-logger.js +27 -0
- package/lib/ilink-logger.js.map +1 -0
- package/lib/ilink-meta.d.ts +13 -0
- package/lib/ilink-meta.d.ts.map +1 -0
- package/lib/ilink-meta.js +57 -0
- package/lib/ilink-meta.js.map +1 -0
- package/lib/ilink-random.d.ts +11 -0
- package/lib/ilink-random.d.ts.map +1 -0
- package/lib/ilink-random.js +16 -0
- package/lib/ilink-random.js.map +1 -0
- package/lib/ilink-redact.d.ts +21 -0
- package/lib/ilink-redact.d.ts.map +1 -0
- package/lib/ilink-redact.js +54 -0
- package/lib/ilink-redact.js.map +1 -0
- package/lib/ilink-session-guard.d.ts +16 -0
- package/lib/ilink-session-guard.d.ts.map +1 -0
- package/lib/ilink-session-guard.js +49 -0
- package/lib/ilink-session-guard.js.map +1 -0
- package/lib/ilink-types.d.ts +233 -0
- package/lib/ilink-types.d.ts.map +1 -0
- package/lib/ilink-types.js +35 -0
- package/lib/ilink-types.js.map +1 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +38 -0
- package/lib/index.js.map +1 -0
- package/lib/login-assist-routes.d.ts +7 -0
- package/lib/login-assist-routes.d.ts.map +1 -0
- package/lib/login-assist-routes.js +29 -0
- package/lib/login-assist-routes.js.map +1 -0
- package/lib/login.d.ts +6 -0
- package/lib/login.d.ts.map +1 -0
- package/lib/login.js +121 -0
- package/lib/login.js.map +1 -0
- package/lib/media-download.d.ts +19 -0
- package/lib/media-download.d.ts.map +1 -0
- package/lib/media-download.js +95 -0
- package/lib/media-download.js.map +1 -0
- package/lib/mime.d.ts +9 -0
- package/lib/mime.d.ts.map +1 -0
- package/lib/mime.js +99 -0
- package/lib/mime.js.map +1 -0
- package/lib/outbound-media.d.ts +5 -0
- package/lib/outbound-media.d.ts.map +1 -0
- package/lib/outbound-media.js +122 -0
- package/lib/outbound-media.js.map +1 -0
- package/lib/pic-decrypt.d.ts +10 -0
- package/lib/pic-decrypt.d.ts.map +1 -0
- package/lib/pic-decrypt.js +89 -0
- package/lib/pic-decrypt.js.map +1 -0
- package/lib/routes.d.ts +8 -0
- package/lib/routes.d.ts.map +1 -0
- package/lib/routes.js +29 -0
- package/lib/routes.js.map +1 -0
- package/lib/silk-transcode.d.ts +9 -0
- package/lib/silk-transcode.d.ts.map +1 -0
- package/lib/silk-transcode.js +64 -0
- package/lib/silk-transcode.js.map +1 -0
- package/lib/types.d.ts +26 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/typing-indicator.d.ts +32 -0
- package/lib/typing-indicator.d.ts.map +1 -0
- package/lib/typing-indicator.js +95 -0
- package/lib/typing-indicator.js.map +1 -0
- package/lib/upload.d.ts +43 -0
- package/lib/upload.d.ts.map +1 -0
- package/lib/upload.js +106 -0
- package/lib/upload.js.map +1 -0
- package/lib/weixin-inbound.d.ts +71 -0
- package/lib/weixin-inbound.d.ts.map +1 -0
- package/lib/weixin-inbound.js +201 -0
- package/lib/weixin-inbound.js.map +1 -0
- package/lib/weixin-send-media.d.ts +22 -0
- package/lib/weixin-send-media.d.ts.map +1 -0
- package/lib/weixin-send-media.js +54 -0
- package/lib/weixin-send-media.js.map +1 -0
- package/lib/weixin-send.d.ts +65 -0
- package/lib/weixin-send.d.ts.map +1 -0
- package/lib/weixin-send.js +181 -0
- package/lib/weixin-send.js.map +1 -0
- package/package.json +91 -0
- package/src/adapter.ts +16 -0
- package/src/aes-ecb.ts +21 -0
- package/src/bot.ts +483 -0
- package/src/cdn-upload.test.ts +177 -0
- package/src/cdn-upload.ts +87 -0
- package/src/cdn-url.ts +20 -0
- package/src/context-store.ts +8 -0
- package/src/credentials.ts +64 -0
- package/src/ilink-api.ts +349 -0
- package/src/ilink-config-cache.ts +79 -0
- package/src/ilink-logger.ts +36 -0
- package/src/ilink-meta.ts +60 -0
- package/src/ilink-random.ts +17 -0
- package/src/ilink-redact.ts +54 -0
- package/src/ilink-session-guard.ts +58 -0
- package/src/ilink-types.ts +261 -0
- package/src/index.ts +60 -0
- package/src/login-assist-routes.ts +36 -0
- package/src/login.ts +166 -0
- package/src/media-download.ts +149 -0
- package/src/mime.test.ts +74 -0
- package/src/mime.ts +105 -0
- package/src/outbound-media.ts +141 -0
- package/src/pic-decrypt.ts +101 -0
- package/src/routes.ts +39 -0
- package/src/silk-transcode.test.ts +58 -0
- package/src/silk-transcode.ts +74 -0
- package/src/silk-wasm.d.ts +6 -0
- package/src/types.ts +26 -0
- package/src/typing-indicator.ts +139 -0
- package/src/upload.test.ts +305 -0
- package/src/upload.ts +157 -0
- package/src/weixin-inbound.ts +257 -0
- package/src/weixin-send-media.ts +72 -0
- package/src/weixin-send.ts +237 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { decryptAesEcb } from "./aes-ecb.js";
|
|
2
|
+
import { buildCdnDownloadUrl, ENABLE_CDN_URL_FALLBACK } from "./cdn-url.js";
|
|
3
|
+
import { logger } from "./ilink-logger.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Download raw bytes from the CDN (no decryption).
|
|
7
|
+
*/
|
|
8
|
+
async function fetchCdnBytes(url: string, label: string): Promise<Buffer> {
|
|
9
|
+
let res: Response;
|
|
10
|
+
try {
|
|
11
|
+
res = await fetch(url);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
const cause =
|
|
14
|
+
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)";
|
|
15
|
+
logger.error(
|
|
16
|
+
`${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`,
|
|
17
|
+
);
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.text().catch(() => "(unreadable)");
|
|
23
|
+
const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;
|
|
24
|
+
logger.error(msg);
|
|
25
|
+
throw new Error(msg);
|
|
26
|
+
}
|
|
27
|
+
return Buffer.from(await res.arrayBuffer());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse CDNMedia.aes_key into a raw 16-byte AES key.
|
|
32
|
+
*
|
|
33
|
+
* Two encodings are seen in the wild:
|
|
34
|
+
* - base64(raw 16 bytes) → images (aes_key from media field)
|
|
35
|
+
* - base64(hex string of 16 bytes) → file / voice / video
|
|
36
|
+
*
|
|
37
|
+
* In the second case, base64-decoding yields 32 ASCII hex chars which must
|
|
38
|
+
* then be parsed as hex to recover the actual 16-byte key.
|
|
39
|
+
*/
|
|
40
|
+
function parseAesKey(aesKeyBase64: string, label: string): Buffer {
|
|
41
|
+
const decoded = Buffer.from(aesKeyBase64, "base64");
|
|
42
|
+
if (decoded.length === 16) {
|
|
43
|
+
return decoded;
|
|
44
|
+
}
|
|
45
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
|
|
46
|
+
// hex-encoded key: base64 → hex string → raw bytes
|
|
47
|
+
return Buffer.from(decoded.toString("ascii"), "hex");
|
|
48
|
+
}
|
|
49
|
+
const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`;
|
|
50
|
+
logger.error(msg);
|
|
51
|
+
throw new Error(msg);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
|
|
56
|
+
* aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).
|
|
57
|
+
*/
|
|
58
|
+
export async function downloadAndDecryptBuffer(
|
|
59
|
+
encryptedQueryParam: string,
|
|
60
|
+
aesKeyBase64: string,
|
|
61
|
+
cdnBaseUrl: string,
|
|
62
|
+
label: string,
|
|
63
|
+
fullUrl?: string,
|
|
64
|
+
): Promise<Buffer> {
|
|
65
|
+
const key = parseAesKey(aesKeyBase64, label);
|
|
66
|
+
let url: string;
|
|
67
|
+
if (fullUrl) {
|
|
68
|
+
url = fullUrl;
|
|
69
|
+
} else if (ENABLE_CDN_URL_FALLBACK) {
|
|
70
|
+
url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error(`${label}: fullUrl is required (CDN URL fallback is disabled)`);
|
|
73
|
+
}
|
|
74
|
+
logger.debug(`${label}: fetching url=${url}`);
|
|
75
|
+
const encrypted = await fetchCdnBytes(url, label);
|
|
76
|
+
logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
|
|
77
|
+
const decrypted = decryptAesEcb(encrypted, key);
|
|
78
|
+
logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
|
|
79
|
+
return decrypted;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
|
|
84
|
+
*/
|
|
85
|
+
export async function downloadPlainCdnBuffer(
|
|
86
|
+
encryptedQueryParam: string,
|
|
87
|
+
cdnBaseUrl: string,
|
|
88
|
+
label: string,
|
|
89
|
+
fullUrl?: string,
|
|
90
|
+
): Promise<Buffer> {
|
|
91
|
+
let url: string;
|
|
92
|
+
if (fullUrl) {
|
|
93
|
+
url = fullUrl;
|
|
94
|
+
} else if (ENABLE_CDN_URL_FALLBACK) {
|
|
95
|
+
url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`${label}: fullUrl is required (CDN URL fallback is disabled)`);
|
|
98
|
+
}
|
|
99
|
+
logger.debug(`${label}: fetching url=${url}`);
|
|
100
|
+
return fetchCdnBytes(url, label);
|
|
101
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* weixin-ilink HTTP 路由
|
|
3
|
+
*/
|
|
4
|
+
import type { Router } from "@zhin.js/host-router";
|
|
5
|
+
import type { Plugin } from "zhin.js";
|
|
6
|
+
import type { WeixinIlinkAdapter } from "./adapter.js";
|
|
7
|
+
import { registerLoginAssistRoutes } from "./login-assist-routes.js";
|
|
8
|
+
|
|
9
|
+
export function registerRoutes(
|
|
10
|
+
router: Router,
|
|
11
|
+
adapter: WeixinIlinkAdapter,
|
|
12
|
+
root: Plugin,
|
|
13
|
+
): void {
|
|
14
|
+
registerLoginAssistRoutes(router, root);
|
|
15
|
+
|
|
16
|
+
router.get("/api/weixin-ilink/bots", async (ctx) => {
|
|
17
|
+
try {
|
|
18
|
+
const bots = Array.from(adapter.bots.values());
|
|
19
|
+
const data = bots.map((bot) => ({
|
|
20
|
+
name: bot.$config.name,
|
|
21
|
+
connected: bot.$connected,
|
|
22
|
+
status: bot.$connected ? "online" : "offline",
|
|
23
|
+
hasCredentials: Boolean(bot.hasCredentials),
|
|
24
|
+
}));
|
|
25
|
+
ctx.body = {
|
|
26
|
+
success: true,
|
|
27
|
+
data,
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
} catch (error) {
|
|
31
|
+
ctx.status = 500;
|
|
32
|
+
ctx.body = {
|
|
33
|
+
success: false,
|
|
34
|
+
error: "WEIXIN_ILINK_API_ERROR",
|
|
35
|
+
message: (error as Error).message,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../util/logger.js", () => ({
|
|
4
|
+
logger: {
|
|
5
|
+
info: vi.fn(),
|
|
6
|
+
debug: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("silkToWav", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null when silk-wasm is unavailable", async () => {
|
|
18
|
+
vi.doMock("silk-wasm", () => {
|
|
19
|
+
throw new Error("Cannot find module 'silk-wasm'");
|
|
20
|
+
});
|
|
21
|
+
const { silkToWav } = await import("./silk-transcode.js");
|
|
22
|
+
const result = await silkToWav(Buffer.from("fake-silk"));
|
|
23
|
+
expect(result).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("transcodes silk to WAV successfully", async () => {
|
|
27
|
+
const fakePcm = new Uint8Array(480); // 10ms at 24kHz mono 16-bit
|
|
28
|
+
vi.doMock("silk-wasm", () => ({
|
|
29
|
+
decode: vi.fn().mockResolvedValue({
|
|
30
|
+
data: fakePcm,
|
|
31
|
+
duration: 10,
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
const { silkToWav } = await import("./silk-transcode.js");
|
|
35
|
+
const result = await silkToWav(Buffer.from("fake-silk"));
|
|
36
|
+
expect(result).not.toBeNull();
|
|
37
|
+
expect(result!.length).toBe(44 + fakePcm.byteLength); // WAV header + PCM data
|
|
38
|
+
|
|
39
|
+
// Verify WAV header
|
|
40
|
+
expect(result!.toString("ascii", 0, 4)).toBe("RIFF");
|
|
41
|
+
expect(result!.toString("ascii", 8, 12)).toBe("WAVE");
|
|
42
|
+
expect(result!.toString("ascii", 12, 16)).toBe("fmt ");
|
|
43
|
+
expect(result!.readUInt16LE(20)).toBe(1); // PCM format
|
|
44
|
+
expect(result!.readUInt16LE(22)).toBe(1); // mono
|
|
45
|
+
expect(result!.readUInt32LE(24)).toBe(24000); // sample rate
|
|
46
|
+
expect(result!.readUInt16LE(34)).toBe(16); // bits per sample
|
|
47
|
+
expect(result!.toString("ascii", 36, 40)).toBe("data");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns null when decode fails", async () => {
|
|
51
|
+
vi.doMock("silk-wasm", () => ({
|
|
52
|
+
decode: vi.fn().mockRejectedValue(new Error("decode error")),
|
|
53
|
+
}));
|
|
54
|
+
const { silkToWav } = await import("./silk-transcode.js");
|
|
55
|
+
const result = await silkToWav(Buffer.from("bad-silk"));
|
|
56
|
+
expect(result).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { logger } from "./ilink-logger.js";
|
|
2
|
+
|
|
3
|
+
/** Default sample rate for Weixin voice messages. */
|
|
4
|
+
const SILK_SAMPLE_RATE = 24_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrap raw pcm_s16le bytes in a WAV container.
|
|
8
|
+
* Mono channel, 16-bit signed little-endian.
|
|
9
|
+
*/
|
|
10
|
+
function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
|
|
11
|
+
const pcmBytes = pcm.byteLength;
|
|
12
|
+
const totalSize = 44 + pcmBytes;
|
|
13
|
+
const buf = Buffer.allocUnsafe(totalSize);
|
|
14
|
+
let offset = 0;
|
|
15
|
+
|
|
16
|
+
buf.write("RIFF", offset);
|
|
17
|
+
offset += 4;
|
|
18
|
+
buf.writeUInt32LE(totalSize - 8, offset);
|
|
19
|
+
offset += 4;
|
|
20
|
+
buf.write("WAVE", offset);
|
|
21
|
+
offset += 4;
|
|
22
|
+
|
|
23
|
+
buf.write("fmt ", offset);
|
|
24
|
+
offset += 4;
|
|
25
|
+
buf.writeUInt32LE(16, offset);
|
|
26
|
+
offset += 4; // fmt chunk size
|
|
27
|
+
buf.writeUInt16LE(1, offset);
|
|
28
|
+
offset += 2; // PCM format
|
|
29
|
+
buf.writeUInt16LE(1, offset);
|
|
30
|
+
offset += 2; // mono
|
|
31
|
+
buf.writeUInt32LE(sampleRate, offset);
|
|
32
|
+
offset += 4;
|
|
33
|
+
buf.writeUInt32LE(sampleRate * 2, offset);
|
|
34
|
+
offset += 4; // byte rate (mono 16-bit)
|
|
35
|
+
buf.writeUInt16LE(2, offset);
|
|
36
|
+
offset += 2; // block align
|
|
37
|
+
buf.writeUInt16LE(16, offset);
|
|
38
|
+
offset += 2; // bits per sample
|
|
39
|
+
|
|
40
|
+
buf.write("data", offset);
|
|
41
|
+
offset += 4;
|
|
42
|
+
buf.writeUInt32LE(pcmBytes, offset);
|
|
43
|
+
offset += 4;
|
|
44
|
+
|
|
45
|
+
Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);
|
|
46
|
+
|
|
47
|
+
return buf;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Try to transcode a SILK audio buffer to WAV using silk-wasm.
|
|
52
|
+
* silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }.
|
|
53
|
+
*
|
|
54
|
+
* Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails.
|
|
55
|
+
* Callers should fall back to passing the raw SILK file when null is returned.
|
|
56
|
+
*/
|
|
57
|
+
export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
|
|
58
|
+
try {
|
|
59
|
+
const { decode } = await import("silk-wasm");
|
|
60
|
+
|
|
61
|
+
logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
|
|
62
|
+
const result = await decode(silkBuf, SILK_SAMPLE_RATE);
|
|
63
|
+
logger.debug(
|
|
64
|
+
`silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
|
|
68
|
+
logger.debug(`silkToWav: WAV size=${wav.length}`);
|
|
69
|
+
return wav;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface WeixinIlinkBotConfig {
|
|
2
|
+
context: "weixin-ilink";
|
|
3
|
+
name: string;
|
|
4
|
+
/** 观测用 bot_agent,默认 Zhin.js/<version> */
|
|
5
|
+
botAgent?: string;
|
|
6
|
+
/** iLink API 根地址,默认 https://ilinkai.weixin.qq.com */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
/** CDN 根地址 */
|
|
9
|
+
cdnBaseUrl?: string;
|
|
10
|
+
/** 长轮询超时(ms),默认 35000 */
|
|
11
|
+
longPollTimeoutMs?: number;
|
|
12
|
+
/** 直接配置 token(优先于侧车凭证文件) */
|
|
13
|
+
botToken?: string;
|
|
14
|
+
typingIndicator?: {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
/** sendTyping 保活间隔(ms) */
|
|
17
|
+
keepaliveIntervalMs?: number;
|
|
18
|
+
privateConfig?: {
|
|
19
|
+
type?: "typing";
|
|
20
|
+
autoRemove?: boolean;
|
|
21
|
+
platformConfig?: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type { WeixinMessage, MessageItem } from "./ilink-types.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信 iLink Typing Indicator(iLink sendTyping API → 微信「正在输入」)
|
|
3
|
+
*/
|
|
4
|
+
import type { WeixinIlinkBot } from "./bot.js";
|
|
5
|
+
import {
|
|
6
|
+
NativeTypingIndicatorAdapter,
|
|
7
|
+
TypingIndicatorManager,
|
|
8
|
+
type TypingIndicator,
|
|
9
|
+
type TypingIndicatorConfig,
|
|
10
|
+
type TypingIndicatorOptions,
|
|
11
|
+
} from "@zhin.js/agent";
|
|
12
|
+
import { TypingStatus } from "./ilink-types.js";
|
|
13
|
+
|
|
14
|
+
export interface WeixinIlinkTypingIndicatorConfig {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
/** sendTyping 保活间隔(ms),长任务期间重复发送;默认 5000 */
|
|
17
|
+
keepaliveIntervalMs?: number;
|
|
18
|
+
privateConfig?: Partial<TypingIndicatorConfig>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG: WeixinIlinkTypingIndicatorConfig = {
|
|
22
|
+
enabled: true,
|
|
23
|
+
keepaliveIntervalMs: 5_000,
|
|
24
|
+
privateConfig: {
|
|
25
|
+
type: "typing",
|
|
26
|
+
autoRemove: true,
|
|
27
|
+
platformConfig: { keepaliveIntervalMs: 5_000 },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class WeixinIlinkTypingIndicatorManager {
|
|
32
|
+
private manager: TypingIndicatorManager;
|
|
33
|
+
private config: WeixinIlinkTypingIndicatorConfig;
|
|
34
|
+
private bot: WeixinIlinkBot;
|
|
35
|
+
|
|
36
|
+
constructor(bot: WeixinIlinkBot, config: WeixinIlinkTypingIndicatorConfig = {}) {
|
|
37
|
+
this.bot = bot;
|
|
38
|
+
this.config = {
|
|
39
|
+
...DEFAULT_CONFIG,
|
|
40
|
+
...config,
|
|
41
|
+
privateConfig: {
|
|
42
|
+
...DEFAULT_CONFIG.privateConfig,
|
|
43
|
+
...config.privateConfig,
|
|
44
|
+
platformConfig: {
|
|
45
|
+
keepaliveIntervalMs: config.keepaliveIntervalMs ?? DEFAULT_CONFIG.keepaliveIntervalMs,
|
|
46
|
+
...config.privateConfig?.platformConfig,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
this.manager = new TypingIndicatorManager({
|
|
52
|
+
type: "typing",
|
|
53
|
+
autoRemove: true,
|
|
54
|
+
});
|
|
55
|
+
this.registerAdapter();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private registerAdapter(): void {
|
|
59
|
+
const adapter = new NativeTypingIndicatorAdapter(
|
|
60
|
+
"weixin-ilink",
|
|
61
|
+
async (options: TypingIndicatorOptions) => {
|
|
62
|
+
const peerId = resolvePeerId(options);
|
|
63
|
+
if (!peerId) return;
|
|
64
|
+
await this.bot.sendTypingToUser(peerId, TypingStatus.TYPING);
|
|
65
|
+
},
|
|
66
|
+
async (options: TypingIndicatorOptions) => {
|
|
67
|
+
const peerId = resolvePeerId(options);
|
|
68
|
+
if (!peerId) return;
|
|
69
|
+
await this.bot.sendTypingToUser(peerId, TypingStatus.CANCEL);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
this.manager.registerAdapter(adapter);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async start(options: {
|
|
76
|
+
messageId?: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
userId?: string;
|
|
79
|
+
groupId?: string;
|
|
80
|
+
sceneType: "private" | "group";
|
|
81
|
+
}): Promise<TypingIndicator> {
|
|
82
|
+
if (!this.config.enabled) {
|
|
83
|
+
return {
|
|
84
|
+
start: async () => {},
|
|
85
|
+
stop: async () => {},
|
|
86
|
+
isActive: () => false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const peerId = resolvePeerId(options);
|
|
91
|
+
const typingOptions: TypingIndicatorOptions = {
|
|
92
|
+
messageId: options.messageId,
|
|
93
|
+
sessionId: options.sessionId,
|
|
94
|
+
userId: peerId,
|
|
95
|
+
groupId: options.groupId,
|
|
96
|
+
platform: "weixin-ilink",
|
|
97
|
+
botId: this.bot.$id,
|
|
98
|
+
sceneType: "private",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return this.manager.start(typingOptions, this.config.privateConfig);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop(options: {
|
|
105
|
+
sessionId: string;
|
|
106
|
+
userId?: string;
|
|
107
|
+
groupId?: string;
|
|
108
|
+
}): Promise<void> {
|
|
109
|
+
const peerId = resolvePeerId(options);
|
|
110
|
+
await this.manager.stop({
|
|
111
|
+
sessionId: options.sessionId,
|
|
112
|
+
userId: peerId,
|
|
113
|
+
groupId: options.groupId,
|
|
114
|
+
platform: "weixin-ilink",
|
|
115
|
+
botId: this.bot.$id,
|
|
116
|
+
sceneType: "private",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolvePeerId(options: {
|
|
122
|
+
userId?: string;
|
|
123
|
+
sessionId?: string;
|
|
124
|
+
}): string | undefined {
|
|
125
|
+
const raw = options.userId?.trim()
|
|
126
|
+
|| (options.sessionId ? options.sessionId.split(":").filter(Boolean).at(-1) : undefined);
|
|
127
|
+
if (!raw) return undefined;
|
|
128
|
+
const hash = raw.indexOf("#");
|
|
129
|
+
return hash >= 0 ? raw.slice(0, hash) : raw;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function enableTypingIndicator(
|
|
133
|
+
bot: WeixinIlinkBot,
|
|
134
|
+
config?: WeixinIlinkTypingIndicatorConfig,
|
|
135
|
+
): WeixinIlinkTypingIndicatorManager {
|
|
136
|
+
const manager = new WeixinIlinkTypingIndicatorManager(bot, config);
|
|
137
|
+
bot.$typingIndicator = manager;
|
|
138
|
+
return manager;
|
|
139
|
+
}
|