@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,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { BaseInfo } from "./ilink-types.js";
|
|
5
|
+
|
|
6
|
+
export const ILINK_APP_ID = "bot";
|
|
7
|
+
export const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
8
|
+
export const DEFAULT_API_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BOT_AGENT = "Zhin.js";
|
|
11
|
+
const BOT_AGENT_MAX_LEN = 256;
|
|
12
|
+
|
|
13
|
+
let channelVersion = "0.0.0";
|
|
14
|
+
let configuredBotAgent: string | undefined;
|
|
15
|
+
|
|
16
|
+
function readPackageVersion(): string {
|
|
17
|
+
try {
|
|
18
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
|
|
20
|
+
return pkg.version ?? "0.0.0";
|
|
21
|
+
} catch {
|
|
22
|
+
return "0.0.0";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
channelVersion = readPackageVersion();
|
|
27
|
+
|
|
28
|
+
export function configureIlinkMeta(opts: { botAgent?: string; version?: string }): void {
|
|
29
|
+
if (opts.botAgent) configuredBotAgent = opts.botAgent;
|
|
30
|
+
if (opts.version) channelVersion = opts.version;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildClientVersion(version: string): number {
|
|
34
|
+
const parts = version.split(".").map((p) => parseInt(p, 10));
|
|
35
|
+
const major = parts[0] ?? 0;
|
|
36
|
+
const minor = parts[1] ?? 0;
|
|
37
|
+
const patch = parts[2] ?? 0;
|
|
38
|
+
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ILINK_APP_CLIENT_VERSION = buildClientVersion(channelVersion);
|
|
42
|
+
|
|
43
|
+
export function sanitizeBotAgent(raw: string | undefined): string {
|
|
44
|
+
if (!raw || typeof raw !== "string") return configuredBotAgent ?? DEFAULT_BOT_AGENT;
|
|
45
|
+
const trimmed = raw.trim();
|
|
46
|
+
if (!trimmed) return configuredBotAgent ?? DEFAULT_BOT_AGENT;
|
|
47
|
+
const productRe = /^[A-Za-z0-9_.\-]{1,32}\/[A-Za-z0-9_.+\-]{1,32}$/;
|
|
48
|
+
const tokens = trimmed.split(/\s+/).filter((tok) => productRe.test(tok));
|
|
49
|
+
if (tokens.length === 0) return configuredBotAgent ?? DEFAULT_BOT_AGENT;
|
|
50
|
+
const joined = tokens.join(" ");
|
|
51
|
+
if (Buffer.byteLength(joined, "utf-8") <= BOT_AGENT_MAX_LEN) return joined;
|
|
52
|
+
return (configuredBotAgent ?? DEFAULT_BOT_AGENT);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildBaseInfo(): BaseInfo {
|
|
56
|
+
return {
|
|
57
|
+
channel_version: channelVersion,
|
|
58
|
+
bot_agent: sanitizeBotAgent(configuredBotAgent),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a prefixed unique ID using timestamp + crypto random bytes.
|
|
5
|
+
* Format: `{prefix}:{timestamp}-{8-char hex}`
|
|
6
|
+
*/
|
|
7
|
+
export function generateId(prefix: string): string {
|
|
8
|
+
return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a temporary file name with random suffix.
|
|
13
|
+
* Format: `{prefix}-{timestamp}-{8-char hex}{ext}`
|
|
14
|
+
*/
|
|
15
|
+
export function tempFileName(prefix: string, ext: string): string {
|
|
16
|
+
return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const DEFAULT_BODY_MAX_LEN = 200;
|
|
2
|
+
const DEFAULT_TOKEN_PREFIX_LEN = 6;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Truncate a string, appending a length indicator when trimmed.
|
|
6
|
+
* Returns `""` for empty/undefined input.
|
|
7
|
+
*/
|
|
8
|
+
export function truncate(s: string | undefined, max: number): string {
|
|
9
|
+
if (!s) return "";
|
|
10
|
+
if (s.length <= max) return s;
|
|
11
|
+
return `${s.slice(0, max)}…(len=${s.length})`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Redact a token/secret: show only the first few chars + total length.
|
|
16
|
+
* Returns `"(none)"` when absent.
|
|
17
|
+
*/
|
|
18
|
+
export function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN_PREFIX_LEN): string {
|
|
19
|
+
if (!token) return "(none)";
|
|
20
|
+
if (token.length <= prefixLen) return `****(len=${token.length})`;
|
|
21
|
+
return `${token.slice(0, prefixLen)}…(len=${token.length})`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Field names whose values should be masked in logged JSON bodies. */
|
|
25
|
+
const SENSITIVE_FIELDS = /\b(context_token|bot_token|token|authorization|Authorization)\b/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Truncate a JSON body string to `maxLen` chars for safe logging.
|
|
29
|
+
* Redacts known sensitive fields before truncating.
|
|
30
|
+
*/
|
|
31
|
+
export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string {
|
|
32
|
+
if (!body) return "(empty)";
|
|
33
|
+
// Mask values of known sensitive JSON keys: "key":"value" → "key":"<redacted>"
|
|
34
|
+
const redacted = body.replace(
|
|
35
|
+
/"(context_token|bot_token|token|authorization|Authorization)"\s*:\s*"[^"]*"/g,
|
|
36
|
+
'"$1":"<redacted>"',
|
|
37
|
+
);
|
|
38
|
+
if (redacted.length <= maxLen) return redacted;
|
|
39
|
+
return `${redacted.slice(0, maxLen)}…(truncated, totalLen=${redacted.length})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strip query string (which often contains signatures/tokens) from a URL,
|
|
44
|
+
* keeping only origin + pathname.
|
|
45
|
+
*/
|
|
46
|
+
export function redactUrl(rawUrl: string): string {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(rawUrl);
|
|
49
|
+
const base = `${u.origin}${u.pathname}`;
|
|
50
|
+
return u.search ? `${base}?<redacted>` : base;
|
|
51
|
+
} catch {
|
|
52
|
+
return truncate(rawUrl, 80);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { logger } from "./ilink-logger.js";
|
|
2
|
+
|
|
3
|
+
const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
/** Error code returned by the server when the bot session has expired. */
|
|
6
|
+
export const SESSION_EXPIRED_ERRCODE = -14;
|
|
7
|
+
|
|
8
|
+
const pauseUntilMap = new Map<string, number>();
|
|
9
|
+
|
|
10
|
+
/** Pause all inbound/outbound API calls for `accountId` for one hour. */
|
|
11
|
+
export function pauseSession(accountId: string): void {
|
|
12
|
+
const until = Date.now() + SESSION_PAUSE_DURATION_MS;
|
|
13
|
+
pauseUntilMap.set(accountId, until);
|
|
14
|
+
logger.info(
|
|
15
|
+
`session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Returns `true` when the bot is still within its one-hour cooldown window. */
|
|
20
|
+
export function isSessionPaused(accountId: string): boolean {
|
|
21
|
+
const until = pauseUntilMap.get(accountId);
|
|
22
|
+
if (until === undefined) return false;
|
|
23
|
+
if (Date.now() >= until) {
|
|
24
|
+
pauseUntilMap.delete(accountId);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Milliseconds remaining until the pause expires (0 when not paused). */
|
|
31
|
+
export function getRemainingPauseMs(accountId: string): number {
|
|
32
|
+
const until = pauseUntilMap.get(accountId);
|
|
33
|
+
if (until === undefined) return 0;
|
|
34
|
+
const remaining = until - Date.now();
|
|
35
|
+
if (remaining <= 0) {
|
|
36
|
+
pauseUntilMap.delete(accountId);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
return remaining;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Throw if the session is currently paused. Call before any API request. */
|
|
43
|
+
export function assertSessionActive(accountId: string): void {
|
|
44
|
+
if (isSessionPaused(accountId)) {
|
|
45
|
+
const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
|
|
46
|
+
throw new Error(
|
|
47
|
+
`session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reset internal state — only for tests.
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export function _resetForTest(): void {
|
|
57
|
+
pauseUntilMap.clear();
|
|
58
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq).
|
|
3
|
+
* API uses JSON over HTTP; bytes fields are base64 strings in JSON.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Common request metadata attached to every CGI request. */
|
|
7
|
+
export interface BaseInfo {
|
|
8
|
+
channel_version?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Self-declared identity of the upstream bot/app, analogous to HTTP
|
|
11
|
+
* `User-Agent`. Filled from `channels.openclaw-weixin.botAgent` in
|
|
12
|
+
* openclaw.json; defaults to `"OpenClaw"` when unset.
|
|
13
|
+
*
|
|
14
|
+
* Format: UA-style `Name/Version` tokens, optionally followed by
|
|
15
|
+
* `(comment)`, multiple tokens space-separated. ASCII only, total
|
|
16
|
+
* length <= 256 bytes after sanitization.
|
|
17
|
+
*
|
|
18
|
+
* For observability only (logging, monitoring aggregation); not used
|
|
19
|
+
* for authentication or routing.
|
|
20
|
+
*/
|
|
21
|
+
bot_agent?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** proto: UploadMediaType */
|
|
25
|
+
export const UploadMediaType = {
|
|
26
|
+
IMAGE: 1,
|
|
27
|
+
VIDEO: 2,
|
|
28
|
+
FILE: 3,
|
|
29
|
+
VOICE: 4,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export interface GetUploadUrlReq {
|
|
33
|
+
filekey?: string;
|
|
34
|
+
/** proto field 2: media_type, see UploadMediaType */
|
|
35
|
+
media_type?: number;
|
|
36
|
+
to_user_id?: string;
|
|
37
|
+
/** 原文件明文大小 */
|
|
38
|
+
rawsize?: number;
|
|
39
|
+
/** 原文件明文 MD5 */
|
|
40
|
+
rawfilemd5?: string;
|
|
41
|
+
/** 原文件密文大小(AES-128-ECB 加密后) */
|
|
42
|
+
filesize?: number;
|
|
43
|
+
/** 缩略图明文大小(IMAGE/VIDEO 时必填) */
|
|
44
|
+
thumb_rawsize?: number;
|
|
45
|
+
/** 缩略图明文 MD5(IMAGE/VIDEO 时必填) */
|
|
46
|
+
thumb_rawfilemd5?: string;
|
|
47
|
+
/** 缩略图密文大小(IMAGE/VIDEO 时必填) */
|
|
48
|
+
thumb_filesize?: number;
|
|
49
|
+
/** 不需要缩略图上传 URL,默认 false */
|
|
50
|
+
no_need_thumb?: boolean;
|
|
51
|
+
/** 加密 key */
|
|
52
|
+
aeskey?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GetUploadUrlResp {
|
|
56
|
+
/** 原图上传加密参数 */
|
|
57
|
+
upload_param?: string;
|
|
58
|
+
/** 缩略图上传加密参数,无缩略图时为空 */
|
|
59
|
+
thumb_upload_param?: string;
|
|
60
|
+
/** 完整上传 URL(服务端直接返回,无需客户端拼接) */
|
|
61
|
+
upload_full_url?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const MessageType = {
|
|
65
|
+
NONE: 0,
|
|
66
|
+
USER: 1,
|
|
67
|
+
BOT: 2,
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
export const MessageItemType = {
|
|
71
|
+
NONE: 0,
|
|
72
|
+
TEXT: 1,
|
|
73
|
+
IMAGE: 2,
|
|
74
|
+
VOICE: 3,
|
|
75
|
+
FILE: 4,
|
|
76
|
+
VIDEO: 5,
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export const MessageState = {
|
|
80
|
+
NEW: 0,
|
|
81
|
+
GENERATING: 1,
|
|
82
|
+
FINISH: 2,
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
export interface TextItem {
|
|
86
|
+
text?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** CDN media reference; aes_key is base64-encoded bytes in JSON. */
|
|
90
|
+
export interface CDNMedia {
|
|
91
|
+
encrypt_query_param?: string;
|
|
92
|
+
aes_key?: string;
|
|
93
|
+
/** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */
|
|
94
|
+
encrypt_type?: number;
|
|
95
|
+
/** 完整下载 URL(服务端直接返回,无需客户端拼接) */
|
|
96
|
+
full_url?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ImageItem {
|
|
100
|
+
/** 原图 CDN 引用 */
|
|
101
|
+
media?: CDNMedia;
|
|
102
|
+
/** 缩略图 CDN 引用 */
|
|
103
|
+
thumb_media?: CDNMedia;
|
|
104
|
+
/** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */
|
|
105
|
+
aeskey?: string;
|
|
106
|
+
url?: string;
|
|
107
|
+
mid_size?: number;
|
|
108
|
+
thumb_size?: number;
|
|
109
|
+
thumb_height?: number;
|
|
110
|
+
thumb_width?: number;
|
|
111
|
+
hd_size?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface VoiceItem {
|
|
115
|
+
media?: CDNMedia;
|
|
116
|
+
/** 语音编码类型:1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */
|
|
117
|
+
encode_type?: number;
|
|
118
|
+
bits_per_sample?: number;
|
|
119
|
+
/** 采样率 (Hz) */
|
|
120
|
+
sample_rate?: number;
|
|
121
|
+
/** 语音长度 (毫秒) */
|
|
122
|
+
playtime?: number;
|
|
123
|
+
/** 语音转文字内容 */
|
|
124
|
+
text?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface FileItem {
|
|
128
|
+
media?: CDNMedia;
|
|
129
|
+
file_name?: string;
|
|
130
|
+
md5?: string;
|
|
131
|
+
len?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface VideoItem {
|
|
135
|
+
media?: CDNMedia;
|
|
136
|
+
video_size?: number;
|
|
137
|
+
play_length?: number;
|
|
138
|
+
video_md5?: string;
|
|
139
|
+
thumb_media?: CDNMedia;
|
|
140
|
+
thumb_size?: number;
|
|
141
|
+
thumb_height?: number;
|
|
142
|
+
thumb_width?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface RefMessage {
|
|
146
|
+
message_item?: MessageItem;
|
|
147
|
+
title?: string; // 摘要
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface MessageItem {
|
|
151
|
+
type?: number;
|
|
152
|
+
create_time_ms?: number;
|
|
153
|
+
update_time_ms?: number;
|
|
154
|
+
is_completed?: boolean;
|
|
155
|
+
msg_id?: string;
|
|
156
|
+
ref_msg?: RefMessage;
|
|
157
|
+
text_item?: TextItem;
|
|
158
|
+
image_item?: ImageItem;
|
|
159
|
+
voice_item?: VoiceItem;
|
|
160
|
+
file_item?: FileItem;
|
|
161
|
+
video_item?: VideoItem;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
|
|
165
|
+
export interface WeixinMessage {
|
|
166
|
+
seq?: number;
|
|
167
|
+
message_id?: number;
|
|
168
|
+
from_user_id?: string;
|
|
169
|
+
to_user_id?: string;
|
|
170
|
+
client_id?: string;
|
|
171
|
+
create_time_ms?: number;
|
|
172
|
+
update_time_ms?: number;
|
|
173
|
+
delete_time_ms?: number;
|
|
174
|
+
session_id?: string;
|
|
175
|
+
group_id?: string;
|
|
176
|
+
message_type?: number;
|
|
177
|
+
message_state?: number;
|
|
178
|
+
item_list?: MessageItem[];
|
|
179
|
+
context_token?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** GetUpdates request: bytes fields are base64 strings in JSON. */
|
|
183
|
+
export interface GetUpdatesReq {
|
|
184
|
+
/** @deprecated compat only, will be removed */
|
|
185
|
+
sync_buf?: string;
|
|
186
|
+
/** Full context buf cached locally; send "" when none (first request or after reset). */
|
|
187
|
+
get_updates_buf?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** GetUpdates response: bytes fields are base64 strings in JSON. */
|
|
191
|
+
export interface GetUpdatesResp {
|
|
192
|
+
ret?: number;
|
|
193
|
+
/** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */
|
|
194
|
+
errcode?: number;
|
|
195
|
+
errmsg?: string;
|
|
196
|
+
msgs?: WeixinMessage[];
|
|
197
|
+
/** @deprecated compat only */
|
|
198
|
+
sync_buf?: string;
|
|
199
|
+
/** Full context buf to cache locally and send on next request. */
|
|
200
|
+
get_updates_buf?: string;
|
|
201
|
+
/** Server-suggested timeout (ms) for the next getUpdates long-poll. */
|
|
202
|
+
longpolling_timeout_ms?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** SendMessage request: wraps a single WeixinMessage. */
|
|
206
|
+
export interface SendMessageReq {
|
|
207
|
+
msg?: WeixinMessage;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface SendMessageResp {
|
|
211
|
+
// empty
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Typing status: 1 = typing (default), 2 = cancel typing. */
|
|
215
|
+
export const TypingStatus = {
|
|
216
|
+
TYPING: 1,
|
|
217
|
+
CANCEL: 2,
|
|
218
|
+
} as const;
|
|
219
|
+
|
|
220
|
+
/** SendTyping request: send a typing indicator to a user. */
|
|
221
|
+
export interface SendTypingReq {
|
|
222
|
+
ilink_user_id?: string;
|
|
223
|
+
typing_ticket?: string;
|
|
224
|
+
/** 1=typing (default), 2=cancel typing */
|
|
225
|
+
status?: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface SendTypingResp {
|
|
229
|
+
ret?: number;
|
|
230
|
+
errmsg?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** GetConfig response: bot config including typing_ticket. */
|
|
234
|
+
export interface GetConfigResp {
|
|
235
|
+
ret?: number;
|
|
236
|
+
errmsg?: string;
|
|
237
|
+
/** Base64-encoded typing ticket for sendTyping. */
|
|
238
|
+
typing_ticket?: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** proto: NotifyStopReq — notify server when the channel client is stopping. */
|
|
242
|
+
export interface NotifyStopReq {
|
|
243
|
+
base_info?: BaseInfo;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** proto: NotifyStopResp */
|
|
247
|
+
export interface NotifyStopResp {
|
|
248
|
+
ret?: number;
|
|
249
|
+
errmsg?: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** proto: NotifyStartReq — notify server when the channel client is starting. */
|
|
253
|
+
export interface NotifyStartReq {
|
|
254
|
+
base_info?: BaseInfo;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** proto: NotifyStartResp */
|
|
258
|
+
export interface NotifyStartResp {
|
|
259
|
+
ret?: number;
|
|
260
|
+
errmsg?: string;
|
|
261
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信 iLink(ClawBot)适配器入口
|
|
3
|
+
*/
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { usePlugin, type Plugin } from "zhin.js";
|
|
6
|
+
import type { Router } from "@zhin.js/host-router";
|
|
7
|
+
import { PageManager } from "@zhin.js/host-api";
|
|
8
|
+
import { WeixinIlinkAdapter } from "./adapter.js";
|
|
9
|
+
import { registerRoutes } from "./routes.js";
|
|
10
|
+
|
|
11
|
+
declare module "zhin.js" {
|
|
12
|
+
namespace Plugin {
|
|
13
|
+
interface Contexts {
|
|
14
|
+
web: PageManager;
|
|
15
|
+
router: Router;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
interface Adapters {
|
|
19
|
+
"weixin-ilink": WeixinIlinkAdapter;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export * from "./types.js";
|
|
24
|
+
export { WeixinIlinkBot } from "./bot.js";
|
|
25
|
+
export { WeixinIlinkAdapter } from "./adapter.js";
|
|
26
|
+
export {
|
|
27
|
+
WeixinIlinkTypingIndicatorManager,
|
|
28
|
+
enableTypingIndicator,
|
|
29
|
+
type WeixinIlinkTypingIndicatorConfig,
|
|
30
|
+
} from "./typing-indicator.js";
|
|
31
|
+
export { registerLoginAssistRoutes } from "./login-assist-routes.js";
|
|
32
|
+
|
|
33
|
+
const plugin = usePlugin();
|
|
34
|
+
const { provide, useContext, root } = plugin;
|
|
35
|
+
|
|
36
|
+
provide({
|
|
37
|
+
name: "weixin-ilink",
|
|
38
|
+
description: "WeChat personal account via Tencent iLink Bot API (ClawBot)",
|
|
39
|
+
mounted: async (p: Plugin) => {
|
|
40
|
+
const adapter = new WeixinIlinkAdapter(p);
|
|
41
|
+
await adapter.start();
|
|
42
|
+
return adapter;
|
|
43
|
+
},
|
|
44
|
+
dispose: async (adapter: WeixinIlinkAdapter) => {
|
|
45
|
+
await adapter.stop();
|
|
46
|
+
},
|
|
47
|
+
} as any);
|
|
48
|
+
|
|
49
|
+
useContext("web", (pageManager) => {
|
|
50
|
+
pageManager.addEntry({
|
|
51
|
+
id: "weixin-ilink",
|
|
52
|
+
development: path.resolve(import.meta.dirname, "../client/index.tsx"),
|
|
53
|
+
production: path.resolve(import.meta.dirname, "../dist/index.js"),
|
|
54
|
+
meta: { name: "微信 iLink" },
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
useContext("router", "weixin-ilink", async (router: Router, adapter: WeixinIlinkAdapter) => {
|
|
59
|
+
registerRoutes(router, adapter, root);
|
|
60
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loginAssist HTTP 路由(供 Web 控制台消费扫码等待等待办)
|
|
3
|
+
*/
|
|
4
|
+
import type { Router } from "@zhin.js/host-router";
|
|
5
|
+
import type { Plugin } from "zhin.js";
|
|
6
|
+
|
|
7
|
+
export function registerLoginAssistRoutes(router: Router, root: Plugin): void {
|
|
8
|
+
const loginAssist = root.inject("loginAssist");
|
|
9
|
+
if (!loginAssist) return;
|
|
10
|
+
|
|
11
|
+
router.get("/api/login-assist/pending", async (ctx) => {
|
|
12
|
+
ctx.body = loginAssist.listPending();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
router.post("/api/login-assist/submit", async (ctx: any) => {
|
|
16
|
+
const body = (ctx.request.body ?? {}) as { id?: string; value?: string | Record<string, unknown> };
|
|
17
|
+
if (!body.id) {
|
|
18
|
+
ctx.status = 400;
|
|
19
|
+
ctx.body = { success: false, message: "missing id" };
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const ok = loginAssist.submit(body.id, body.value ?? "");
|
|
23
|
+
ctx.body = { success: ok };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
router.post("/api/login-assist/cancel", async (ctx: any) => {
|
|
27
|
+
const body = (ctx.request.body ?? {}) as { id?: string; reason?: string };
|
|
28
|
+
if (!body.id) {
|
|
29
|
+
ctx.status = 400;
|
|
30
|
+
ctx.body = { success: false, message: "missing id" };
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const ok = loginAssist.cancel(body.id, body.reason);
|
|
34
|
+
ctx.body = { success: ok };
|
|
35
|
+
});
|
|
36
|
+
}
|