� backup: 2026-03-24 04:00
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { loadConfigRouteTag } from "../auth/accounts.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { redactBody, redactUrl } from "../util/redact.js";
|
||||
|
||||
import type {
|
||||
BaseInfo,
|
||||
GetUploadUrlReq,
|
||||
GetUploadUrlResp,
|
||||
GetUpdatesReq,
|
||||
GetUpdatesResp,
|
||||
SendMessageReq,
|
||||
SendTypingReq,
|
||||
GetConfigResp,
|
||||
} from "./types.js";
|
||||
|
||||
export type WeixinApiOptions = {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
/** Long-poll timeout for getUpdates (server may hold the request up to this). */
|
||||
longPollTimeoutMs?: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BaseInfo — attached to every outgoing CGI request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readChannelVersion(): string {
|
||||
try {
|
||||
const dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = path.resolve(dir, "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
|
||||
return pkg.version ?? "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const CHANNEL_VERSION = readChannelVersion();
|
||||
|
||||
/** Build the `base_info` payload included in every API request. */
|
||||
export function buildBaseInfo(): BaseInfo {
|
||||
return { channel_version: CHANNEL_VERSION };
|
||||
}
|
||||
|
||||
/** Default timeout for long-poll getUpdates requests. */
|
||||
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
||||
/** Default timeout for regular API requests (sendMessage, getUploadUrl). */
|
||||
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
||||
/** Default timeout for lightweight API requests (getConfig, sendTyping). */
|
||||
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
||||
|
||||
function ensureTrailingSlash(url: string): string {
|
||||
return url.endsWith("/") ? url : `${url}/`;
|
||||
}
|
||||
|
||||
/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
|
||||
function randomWechatUin(): string {
|
||||
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
||||
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
AuthorizationType: "ilink_bot_token",
|
||||
"Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
|
||||
"X-WECHAT-UIN": randomWechatUin(),
|
||||
};
|
||||
if (opts.token?.trim()) {
|
||||
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
||||
}
|
||||
const routeTag = loadConfigRouteTag();
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
logger.debug(
|
||||
`requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`,
|
||||
);
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort.
|
||||
* Returns the raw response text on success; throws on HTTP error or timeout.
|
||||
*/
|
||||
async function apiFetch(params: {
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
body: string;
|
||||
token?: string;
|
||||
timeoutMs: number;
|
||||
label: string;
|
||||
}): Promise<string> {
|
||||
const base = ensureTrailingSlash(params.baseUrl);
|
||||
const url = new URL(params.endpoint, base);
|
||||
const hdrs = buildHeaders({ token: params.token, body: params.body });
|
||||
logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: hdrs,
|
||||
body: params.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
const rawText = await res.text();
|
||||
logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
||||
}
|
||||
return rawText;
|
||||
} catch (err) {
|
||||
clearTimeout(t);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-poll getUpdates. Server should hold the request until new messages or timeout.
|
||||
*
|
||||
* On client-side timeout (no server response within timeoutMs), returns an empty response
|
||||
* with ret=0 so the caller can simply retry. This is normal for long-poll.
|
||||
*/
|
||||
export async function getUpdates(
|
||||
params: GetUpdatesReq & {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<GetUpdatesResp> {
|
||||
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
||||
try {
|
||||
const rawText = await apiFetch({
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "ilink/bot/getupdates",
|
||||
body: JSON.stringify({
|
||||
get_updates_buf: params.get_updates_buf ?? "",
|
||||
base_info: buildBaseInfo(),
|
||||
}),
|
||||
token: params.token,
|
||||
timeoutMs: timeout,
|
||||
label: "getUpdates",
|
||||
});
|
||||
const resp: GetUpdatesResp = JSON.parse(rawText);
|
||||
return resp;
|
||||
} catch (err) {
|
||||
// Long-poll timeout is normal; return empty response so caller can retry
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
|
||||
return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a pre-signed CDN upload URL for a file. */
|
||||
export async function getUploadUrl(
|
||||
params: GetUploadUrlReq & WeixinApiOptions,
|
||||
): Promise<GetUploadUrlResp> {
|
||||
const rawText = await apiFetch({
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "ilink/bot/getuploadurl",
|
||||
body: JSON.stringify({
|
||||
filekey: params.filekey,
|
||||
media_type: params.media_type,
|
||||
to_user_id: params.to_user_id,
|
||||
rawsize: params.rawsize,
|
||||
rawfilemd5: params.rawfilemd5,
|
||||
filesize: params.filesize,
|
||||
thumb_rawsize: params.thumb_rawsize,
|
||||
thumb_rawfilemd5: params.thumb_rawfilemd5,
|
||||
thumb_filesize: params.thumb_filesize,
|
||||
no_need_thumb: params.no_need_thumb,
|
||||
aeskey: params.aeskey,
|
||||
base_info: buildBaseInfo(),
|
||||
}),
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
||||
label: "getUploadUrl",
|
||||
});
|
||||
const resp: GetUploadUrlResp = JSON.parse(rawText);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Send a single message downstream. */
|
||||
export async function sendMessage(
|
||||
params: WeixinApiOptions & { body: SendMessageReq },
|
||||
): Promise<void> {
|
||||
await apiFetch({
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "ilink/bot/sendmessage",
|
||||
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
||||
label: "sendMessage",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch bot config (includes typing_ticket) for a given user. */
|
||||
export async function getConfig(
|
||||
params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
|
||||
): Promise<GetConfigResp> {
|
||||
const rawText = await apiFetch({
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "ilink/bot/getconfig",
|
||||
body: JSON.stringify({
|
||||
ilink_user_id: params.ilinkUserId,
|
||||
context_token: params.contextToken,
|
||||
base_info: buildBaseInfo(),
|
||||
}),
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
||||
label: "getConfig",
|
||||
});
|
||||
const resp: GetConfigResp = JSON.parse(rawText);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Send a typing indicator to a user. */
|
||||
export async function sendTyping(
|
||||
params: WeixinApiOptions & { body: SendTypingReq },
|
||||
): Promise<void> {
|
||||
await apiFetch({
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "ilink/bot/sendtyping",
|
||||
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
||||
label: "sendTyping",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { getConfig } from "./api.js";
|
||||
|
||||
/** Subset of getConfig fields that we actually need; add new fields here as needed. */
|
||||
export interface CachedConfig {
|
||||
typingTicket: string;
|
||||
}
|
||||
|
||||
const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
|
||||
const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
|
||||
|
||||
interface ConfigCacheEntry {
|
||||
config: CachedConfig;
|
||||
everSucceeded: boolean;
|
||||
nextFetchAt: number;
|
||||
retryDelayMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user getConfig cache with periodic random refresh (within 24h) and
|
||||
* exponential-backoff retry (up to 1h) on failure.
|
||||
*/
|
||||
export class WeixinConfigManager {
|
||||
private cache = new Map<string, ConfigCacheEntry>();
|
||||
|
||||
constructor(
|
||||
private apiOpts: { baseUrl: string; token?: string },
|
||||
private log: (msg: string) => void,
|
||||
) {}
|
||||
|
||||
async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
|
||||
const now = Date.now();
|
||||
const entry = this.cache.get(userId);
|
||||
const shouldFetch = !entry || now >= entry.nextFetchAt;
|
||||
|
||||
if (shouldFetch) {
|
||||
let fetchOk = false;
|
||||
try {
|
||||
const resp = await getConfig({
|
||||
baseUrl: this.apiOpts.baseUrl,
|
||||
token: this.apiOpts.token,
|
||||
ilinkUserId: userId,
|
||||
contextToken,
|
||||
});
|
||||
if (resp.ret === 0) {
|
||||
this.cache.set(userId, {
|
||||
config: { typingTicket: resp.typing_ticket ?? "" },
|
||||
everSucceeded: true,
|
||||
nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
|
||||
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
|
||||
});
|
||||
this.log(
|
||||
`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`,
|
||||
);
|
||||
fetchOk = true;
|
||||
}
|
||||
} catch (err) {
|
||||
this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
|
||||
}
|
||||
if (!fetchOk) {
|
||||
const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
|
||||
const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
|
||||
if (entry) {
|
||||
entry.nextFetchAt = now + nextDelay;
|
||||
entry.retryDelayMs = nextDelay;
|
||||
} else {
|
||||
this.cache.set(userId, {
|
||||
config: { typingTicket: "" },
|
||||
everSucceeded: false,
|
||||
nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
|
||||
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.cache.get(userId)?.config ?? { typingTicket: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Error code returned by the server when the bot session has expired. */
|
||||
export const SESSION_EXPIRED_ERRCODE = -14;
|
||||
|
||||
const pauseUntilMap = new Map<string, number>();
|
||||
|
||||
/** Pause all inbound/outbound API calls for `accountId` for one hour. */
|
||||
export function pauseSession(accountId: string): void {
|
||||
const until = Date.now() + SESSION_PAUSE_DURATION_MS;
|
||||
pauseUntilMap.set(accountId, until);
|
||||
logger.info(
|
||||
`session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns `true` when the bot is still within its one-hour cooldown window. */
|
||||
export function isSessionPaused(accountId: string): boolean {
|
||||
const until = pauseUntilMap.get(accountId);
|
||||
if (until === undefined) return false;
|
||||
if (Date.now() >= until) {
|
||||
pauseUntilMap.delete(accountId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Milliseconds remaining until the pause expires (0 when not paused). */
|
||||
export function getRemainingPauseMs(accountId: string): number {
|
||||
const until = pauseUntilMap.get(accountId);
|
||||
if (until === undefined) return 0;
|
||||
const remaining = until - Date.now();
|
||||
if (remaining <= 0) {
|
||||
pauseUntilMap.delete(accountId);
|
||||
return 0;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
/** Throw if the session is currently paused. Call before any API request. */
|
||||
export function assertSessionActive(accountId: string): void {
|
||||
if (isSessionPaused(accountId)) {
|
||||
const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
|
||||
throw new Error(
|
||||
`session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state — only for tests.
|
||||
* @internal
|
||||
*/
|
||||
export function _resetForTest(): void {
|
||||
pauseUntilMap.clear();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq).
|
||||
* API uses JSON over HTTP; bytes fields are base64 strings in JSON.
|
||||
*/
|
||||
|
||||
/** Common request metadata attached to every CGI request. */
|
||||
export interface BaseInfo {
|
||||
channel_version?: string;
|
||||
}
|
||||
|
||||
/** proto: UploadMediaType */
|
||||
export const UploadMediaType = {
|
||||
IMAGE: 1,
|
||||
VIDEO: 2,
|
||||
FILE: 3,
|
||||
VOICE: 4,
|
||||
} as const;
|
||||
|
||||
export interface GetUploadUrlReq {
|
||||
filekey?: string;
|
||||
/** proto field 2: media_type, see UploadMediaType */
|
||||
media_type?: number;
|
||||
to_user_id?: string;
|
||||
/** 原文件明文大小 */
|
||||
rawsize?: number;
|
||||
/** 原文件明文 MD5 */
|
||||
rawfilemd5?: string;
|
||||
/** 原文件密文大小(AES-128-ECB 加密后) */
|
||||
filesize?: number;
|
||||
/** 缩略图明文大小(IMAGE/VIDEO 时必填) */
|
||||
thumb_rawsize?: number;
|
||||
/** 缩略图明文 MD5(IMAGE/VIDEO 时必填) */
|
||||
thumb_rawfilemd5?: string;
|
||||
/** 缩略图密文大小(IMAGE/VIDEO 时必填) */
|
||||
thumb_filesize?: number;
|
||||
/** 不需要缩略图上传 URL,默认 false */
|
||||
no_need_thumb?: boolean;
|
||||
/** 加密 key */
|
||||
aeskey?: string;
|
||||
}
|
||||
|
||||
export interface GetUploadUrlResp {
|
||||
/** 原图上传加密参数 */
|
||||
upload_param?: string;
|
||||
/** 缩略图上传加密参数,无缩略图时为空 */
|
||||
thumb_upload_param?: string;
|
||||
}
|
||||
|
||||
export const MessageType = {
|
||||
NONE: 0,
|
||||
USER: 1,
|
||||
BOT: 2,
|
||||
} as const;
|
||||
|
||||
export const MessageItemType = {
|
||||
NONE: 0,
|
||||
TEXT: 1,
|
||||
IMAGE: 2,
|
||||
VOICE: 3,
|
||||
FILE: 4,
|
||||
VIDEO: 5,
|
||||
} as const;
|
||||
|
||||
export const MessageState = {
|
||||
NEW: 0,
|
||||
GENERATING: 1,
|
||||
FINISH: 2,
|
||||
} as const;
|
||||
|
||||
export interface TextItem {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** CDN media reference; aes_key is base64-encoded bytes in JSON. */
|
||||
export interface CDNMedia {
|
||||
encrypt_query_param?: string;
|
||||
aes_key?: string;
|
||||
/** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */
|
||||
encrypt_type?: number;
|
||||
}
|
||||
|
||||
export interface ImageItem {
|
||||
/** 原图 CDN 引用 */
|
||||
media?: CDNMedia;
|
||||
/** 缩略图 CDN 引用 */
|
||||
thumb_media?: CDNMedia;
|
||||
/** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */
|
||||
aeskey?: string;
|
||||
url?: string;
|
||||
mid_size?: number;
|
||||
thumb_size?: number;
|
||||
thumb_height?: number;
|
||||
thumb_width?: number;
|
||||
hd_size?: number;
|
||||
}
|
||||
|
||||
export interface VoiceItem {
|
||||
media?: CDNMedia;
|
||||
/** 语音编码类型:1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */
|
||||
encode_type?: number;
|
||||
bits_per_sample?: number;
|
||||
/** 采样率 (Hz) */
|
||||
sample_rate?: number;
|
||||
/** 语音长度 (毫秒) */
|
||||
playtime?: number;
|
||||
/** 语音转文字内容 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
media?: CDNMedia;
|
||||
file_name?: string;
|
||||
md5?: string;
|
||||
len?: string;
|
||||
}
|
||||
|
||||
export interface VideoItem {
|
||||
media?: CDNMedia;
|
||||
video_size?: number;
|
||||
play_length?: number;
|
||||
video_md5?: string;
|
||||
thumb_media?: CDNMedia;
|
||||
thumb_size?: number;
|
||||
thumb_height?: number;
|
||||
thumb_width?: number;
|
||||
}
|
||||
|
||||
export interface RefMessage {
|
||||
message_item?: MessageItem;
|
||||
title?: string; // 摘要
|
||||
}
|
||||
|
||||
export interface MessageItem {
|
||||
type?: number;
|
||||
create_time_ms?: number;
|
||||
update_time_ms?: number;
|
||||
is_completed?: boolean;
|
||||
msg_id?: string;
|
||||
ref_msg?: RefMessage;
|
||||
text_item?: TextItem;
|
||||
image_item?: ImageItem;
|
||||
voice_item?: VoiceItem;
|
||||
file_item?: FileItem;
|
||||
video_item?: VideoItem;
|
||||
}
|
||||
|
||||
/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
|
||||
export interface WeixinMessage {
|
||||
seq?: number;
|
||||
message_id?: number;
|
||||
from_user_id?: string;
|
||||
to_user_id?: string;
|
||||
client_id?: string;
|
||||
create_time_ms?: number;
|
||||
update_time_ms?: number;
|
||||
delete_time_ms?: number;
|
||||
session_id?: string;
|
||||
group_id?: string;
|
||||
message_type?: number;
|
||||
message_state?: number;
|
||||
item_list?: MessageItem[];
|
||||
context_token?: string;
|
||||
}
|
||||
|
||||
/** GetUpdates request: bytes fields are base64 strings in JSON. */
|
||||
export interface GetUpdatesReq {
|
||||
/** @deprecated compat only, will be removed */
|
||||
sync_buf?: string;
|
||||
/** Full context buf cached locally; send "" when none (first request or after reset). */
|
||||
get_updates_buf?: string;
|
||||
}
|
||||
|
||||
/** GetUpdates response: bytes fields are base64 strings in JSON. */
|
||||
export interface GetUpdatesResp {
|
||||
ret?: number;
|
||||
/** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
msgs?: WeixinMessage[];
|
||||
/** @deprecated compat only */
|
||||
sync_buf?: string;
|
||||
/** Full context buf to cache locally and send on next request. */
|
||||
get_updates_buf?: string;
|
||||
/** Server-suggested timeout (ms) for the next getUpdates long-poll. */
|
||||
longpolling_timeout_ms?: number;
|
||||
}
|
||||
|
||||
/** SendMessage request: wraps a single WeixinMessage. */
|
||||
export interface SendMessageReq {
|
||||
msg?: WeixinMessage;
|
||||
}
|
||||
|
||||
export interface SendMessageResp {
|
||||
// empty
|
||||
}
|
||||
|
||||
/** Typing status: 1 = typing (default), 2 = cancel typing. */
|
||||
export const TypingStatus = {
|
||||
TYPING: 1,
|
||||
CANCEL: 2,
|
||||
} as const;
|
||||
|
||||
/** SendTyping request: send a typing indicator to a user. */
|
||||
export interface SendTypingReq {
|
||||
ilink_user_id?: string;
|
||||
typing_ticket?: string;
|
||||
/** 1=typing (default), 2=cancel typing */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface SendTypingResp {
|
||||
ret?: number;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
/** GetConfig response: bot config including typing_ticket. */
|
||||
export interface GetConfigResp {
|
||||
ret?: number;
|
||||
errmsg?: string;
|
||||
/** Base64-encoded typing ticket for sendTyping. */
|
||||
typing_ticket?: string;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
import { getWeixinRuntime } from "../runtime.js";
|
||||
import { resolveStateDir } from "../storage/state-dir.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
||||
export const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account ID compatibility (legacy raw ID → normalized ID)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes.
|
||||
* Used only as a compatibility fallback when loading accounts / sync bufs stored
|
||||
* under the old raw ID.
|
||||
* e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot"
|
||||
*/
|
||||
export function deriveRawAccountId(normalizedId: string): string | undefined {
|
||||
if (normalizedId.endsWith("-im-bot")) {
|
||||
return `${normalizedId.slice(0, -7)}@im.bot`;
|
||||
}
|
||||
if (normalizedId.endsWith("-im-wechat")) {
|
||||
return `${normalizedId.slice(0, -10)}@im.wechat`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account index (persistent list of registered account IDs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveWeixinStateDir(): string {
|
||||
return path.join(resolveStateDir(), "openclaw-weixin");
|
||||
}
|
||||
|
||||
function resolveAccountIndexPath(): string {
|
||||
return path.join(resolveWeixinStateDir(), "accounts.json");
|
||||
}
|
||||
|
||||
/** Returns all accountIds registered via QR login. */
|
||||
export function listIndexedWeixinAccountIds(): string[] {
|
||||
const filePath = resolveAccountIndexPath();
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Add accountId to the persistent index (no-op if already present). */
|
||||
export function registerWeixinAccountId(accountId: string): void {
|
||||
const dir = resolveWeixinStateDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const existing = listIndexedWeixinAccountIds();
|
||||
if (existing.includes(accountId)) return;
|
||||
|
||||
const updated = [...existing, accountId];
|
||||
fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account store (per-account credential files)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unified per-account data: token + baseUrl in one file. */
|
||||
export type WeixinAccountData = {
|
||||
token?: string;
|
||||
savedAt?: string;
|
||||
baseUrl?: string;
|
||||
/** Last linked Weixin user id from QR login (optional). */
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
function resolveAccountsDir(): string {
|
||||
return path.join(resolveWeixinStateDir(), "accounts");
|
||||
}
|
||||
|
||||
function resolveAccountPath(accountId: string): string {
|
||||
return path.join(resolveAccountsDir(), `${accountId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files).
|
||||
*/
|
||||
function loadLegacyToken(): string | undefined {
|
||||
const legacyPath = path.join(resolveStateDir(), "credentials", "openclaw-weixin", "credentials.json");
|
||||
try {
|
||||
if (!fs.existsSync(legacyPath)) return undefined;
|
||||
const raw = fs.readFileSync(legacyPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { token?: string };
|
||||
return typeof parsed.token === "string" ? parsed.token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readAccountFile(filePath: string): WeixinAccountData | null {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as WeixinAccountData;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Load account data by ID, with compatibility fallbacks. */
|
||||
export function loadWeixinAccount(accountId: string): WeixinAccountData | null {
|
||||
// Primary: try given accountId (normalized IDs written after this change).
|
||||
const primary = readAccountFile(resolveAccountPath(accountId));
|
||||
if (primary) return primary;
|
||||
|
||||
// Compatibility: if the given ID is normalized, derive the old raw filename
|
||||
// (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs.
|
||||
const rawId = deriveRawAccountId(accountId);
|
||||
if (rawId) {
|
||||
const compat = readAccountFile(resolveAccountPath(rawId));
|
||||
if (compat) return compat;
|
||||
}
|
||||
|
||||
// Legacy fallback: read token from old single-account credentials file.
|
||||
const token = loadLegacyToken();
|
||||
if (token) return { token };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist account data after QR login (merges into existing file).
|
||||
* - token: overwritten when provided.
|
||||
* - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL.
|
||||
* - userId: set when `update.userId` is provided; omitted from file when cleared to empty.
|
||||
*/
|
||||
export function saveWeixinAccount(
|
||||
accountId: string,
|
||||
update: { token?: string; baseUrl?: string; userId?: string },
|
||||
): void {
|
||||
const dir = resolveAccountsDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const existing = loadWeixinAccount(accountId) ?? {};
|
||||
|
||||
const token = update.token?.trim() || existing.token;
|
||||
const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
|
||||
const userId =
|
||||
update.userId !== undefined
|
||||
? update.userId.trim() || undefined
|
||||
: existing.userId?.trim() || undefined;
|
||||
|
||||
const data: WeixinAccountData = {
|
||||
...(token ? { token, savedAt: new Date().toISOString() } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
|
||||
const filePath = resolveAccountPath(accountId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove account data file. */
|
||||
export function clearWeixinAccount(accountId: string): void {
|
||||
try {
|
||||
fs.unlinkSync(resolveAccountPath(accountId));
|
||||
} catch {
|
||||
// ignore if not found
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the openclaw.json config file path.
|
||||
* Checks OPENCLAW_CONFIG env var, then state dir.
|
||||
*/
|
||||
function resolveConfigPath(): string {
|
||||
const envPath = process.env.OPENCLAW_CONFIG?.trim();
|
||||
if (envPath) return envPath;
|
||||
return path.join(resolveStateDir(), "openclaw.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).
|
||||
* Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level
|
||||
* `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`.
|
||||
*/
|
||||
export function loadConfigRouteTag(accountId?: string): string | undefined {
|
||||
try {
|
||||
const configPath = resolveConfigPath();
|
||||
if (!fs.existsSync(configPath)) return undefined;
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const cfg = JSON.parse(raw) as Record<string, unknown>;
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const section = channels?.["openclaw-weixin"] as Record<string, unknown> | undefined;
|
||||
if (!section) return undefined;
|
||||
if (accountId) {
|
||||
const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
const tag = accounts?.[accountId]?.routeTag;
|
||||
if (typeof tag === "number") return String(tag);
|
||||
if (typeof tag === "string" && tag.trim()) return tag.trim();
|
||||
}
|
||||
if (typeof section.routeTag === "number") return String(section.routeTag);
|
||||
return typeof section.routeTag === "string" && section.routeTag.trim()
|
||||
? section.routeTag.trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op stub — config reload is now handled externally via `openclaw gateway restart`.
|
||||
*/
|
||||
export async function triggerWeixinChannelReload(): Promise<void> {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account resolution (merge config + stored credentials)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ResolvedWeixinAccount = {
|
||||
accountId: string;
|
||||
baseUrl: string;
|
||||
cdnBaseUrl: string;
|
||||
token?: string;
|
||||
enabled: boolean;
|
||||
/** true when a token has been obtained via QR login. */
|
||||
configured: boolean;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type WeixinAccountConfig = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
cdnBaseUrl?: string;
|
||||
/** Optional SKRouteTag source; read from openclaw.json when `accountId` is passed to `loadConfigRouteTag`. */
|
||||
routeTag?: number | string;
|
||||
};
|
||||
|
||||
type WeixinSectionConfig = WeixinAccountConfig & {
|
||||
accounts?: Record<string, WeixinAccountConfig>;
|
||||
};
|
||||
|
||||
/** List accountIds from the index file (written at QR login). */
|
||||
export function listWeixinAccountIds(_cfg: OpenClawConfig): string[] {
|
||||
return listIndexedWeixinAccountIds();
|
||||
}
|
||||
|
||||
/** Resolve a weixin account by ID, merging config and stored credentials. */
|
||||
export function resolveWeixinAccount(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): ResolvedWeixinAccount {
|
||||
const raw = accountId?.trim();
|
||||
if (!raw) {
|
||||
throw new Error("weixin: accountId is required (no default account)");
|
||||
}
|
||||
const id = normalizeAccountId(raw);
|
||||
const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined;
|
||||
const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? section ?? {};
|
||||
|
||||
const accountData = loadWeixinAccount(id);
|
||||
const token = accountData?.token?.trim() || undefined;
|
||||
const stateBaseUrl = accountData?.baseUrl?.trim() || "";
|
||||
|
||||
return {
|
||||
accountId: id,
|
||||
baseUrl: stateBaseUrl || DEFAULT_BASE_URL,
|
||||
cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || CDN_BASE_URL,
|
||||
token,
|
||||
enabled: accountCfg.enabled !== false,
|
||||
configured: Boolean(token),
|
||||
name: accountCfg.name?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { loadConfigRouteTag } from "./accounts.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { redactToken } from "../util/redact.js";
|
||||
|
||||
type ActiveLogin = {
|
||||
sessionKey: string;
|
||||
id: string;
|
||||
qrcode: string;
|
||||
qrcodeUrl: string;
|
||||
startedAt: number;
|
||||
botToken?: string;
|
||||
status?: "wait" | "scaned" | "confirmed" | "expired";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
|
||||
/** Client-side timeout for the long-poll get_qrcode_status request. */
|
||||
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
||||
|
||||
/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */
|
||||
export const DEFAULT_ILINK_BOT_TYPE = "3";
|
||||
|
||||
const activeLogins = new Map<string, ActiveLogin>();
|
||||
|
||||
interface QRCodeResponse {
|
||||
qrcode: string;
|
||||
qrcode_img_content: string;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
status: "wait" | "scaned" | "confirmed" | "expired";
|
||||
bot_token?: string;
|
||||
ilink_bot_id?: string;
|
||||
baseurl?: string;
|
||||
/** The user ID of the person who scanned the QR code. */
|
||||
ilink_user_id?: string;
|
||||
}
|
||||
|
||||
function isLoginFresh(login: ActiveLogin): boolean {
|
||||
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
||||
}
|
||||
|
||||
/** Remove all expired entries from the activeLogins map to prevent memory leaks. */
|
||||
function purgeExpiredLogins(): void {
|
||||
for (const [id, login] of activeLogins) {
|
||||
if (!isLoginFresh(login)) {
|
||||
activeLogins.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
|
||||
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
|
||||
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
||||
logger.info(`Fetching QR code from: ${url.toString()}`);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const routeTag = loadConfigRouteTag();
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "(unreadable)");
|
||||
logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
|
||||
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
|
||||
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
|
||||
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
||||
logger.debug(`Long-poll QR status from: ${url.toString()}`);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"iLink-App-ClientVersion": "1",
|
||||
};
|
||||
const routeTag = loadConfigRouteTag();
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
|
||||
const rawText = await response.text();
|
||||
logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
|
||||
if (!response.ok) {
|
||||
logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
|
||||
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return JSON.parse(rawText) as StatusResponse;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
|
||||
return { status: "wait" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type WeixinQrStartResult = {
|
||||
qrcodeUrl?: string;
|
||||
message: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
export type WeixinQrWaitResult = {
|
||||
connected: boolean;
|
||||
botToken?: string;
|
||||
accountId?: string;
|
||||
baseUrl?: string;
|
||||
/** The user ID of the person who scanned the QR code; add to allowFrom. */
|
||||
userId?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function startWeixinLoginWithQr(opts: {
|
||||
verbose?: boolean;
|
||||
timeoutMs?: number;
|
||||
force?: boolean;
|
||||
accountId?: string;
|
||||
apiBaseUrl: string;
|
||||
botType?: string;
|
||||
}): Promise<WeixinQrStartResult> {
|
||||
const sessionKey = opts.accountId || randomUUID();
|
||||
|
||||
purgeExpiredLogins();
|
||||
|
||||
const existing = activeLogins.get(sessionKey);
|
||||
if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
|
||||
return {
|
||||
qrcodeUrl: existing.qrcodeUrl,
|
||||
message: "二维码已就绪,请使用微信扫描。",
|
||||
sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
||||
logger.info(`Starting Weixin login with bot_type=${botType}`);
|
||||
|
||||
if (!opts.apiBaseUrl) {
|
||||
return {
|
||||
message:
|
||||
"No baseUrl configured. Add channels.openclaw-weixin.baseUrl to your config before logging in.",
|
||||
sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
|
||||
logger.info(
|
||||
`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`,
|
||||
);
|
||||
logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`);
|
||||
|
||||
const login: ActiveLogin = {
|
||||
sessionKey,
|
||||
id: randomUUID(),
|
||||
qrcode: qrResponse.qrcode,
|
||||
qrcodeUrl: qrResponse.qrcode_img_content,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
activeLogins.set(sessionKey, login);
|
||||
|
||||
return {
|
||||
qrcodeUrl: qrResponse.qrcode_img_content,
|
||||
message: "使用微信扫描以下二维码,以完成连接。",
|
||||
sessionKey,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(`Failed to start Weixin login: ${String(err)}`);
|
||||
return {
|
||||
message: `Failed to start login: ${String(err)}`,
|
||||
sessionKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_QR_REFRESH_COUNT = 3;
|
||||
|
||||
export async function waitForWeixinLogin(opts: {
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
sessionKey: string;
|
||||
apiBaseUrl: string;
|
||||
botType?: string;
|
||||
}): Promise<WeixinQrWaitResult> {
|
||||
let activeLogin = activeLogins.get(opts.sessionKey);
|
||||
|
||||
if (!activeLogin) {
|
||||
logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);
|
||||
return {
|
||||
connected: false,
|
||||
message: "当前没有进行中的登录,请先发起登录。",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isLoginFresh(activeLogin)) {
|
||||
logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: "二维码已过期,请重新生成。",
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let scannedPrinted = false;
|
||||
let qrRefreshCount = 1;
|
||||
|
||||
logger.info("Starting to poll QR code status...");
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const statusResponse = await pollQRStatus(opts.apiBaseUrl, activeLogin.qrcode);
|
||||
logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
|
||||
activeLogin.status = statusResponse.status;
|
||||
|
||||
switch (statusResponse.status) {
|
||||
case "wait":
|
||||
if (opts.verbose) {
|
||||
process.stdout.write(".");
|
||||
}
|
||||
break;
|
||||
case "scaned":
|
||||
if (!scannedPrinted) {
|
||||
process.stdout.write("\n👀 已扫码,在微信继续操作...\n");
|
||||
scannedPrinted = true;
|
||||
}
|
||||
break;
|
||||
case "expired": {
|
||||
qrRefreshCount++;
|
||||
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
||||
logger.warn(
|
||||
`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,
|
||||
);
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: "登录超时:二维码多次过期,请重新开始登录流程。",
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
|
||||
logger.info(
|
||||
`waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`,
|
||||
);
|
||||
|
||||
try {
|
||||
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
||||
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
|
||||
activeLogin.qrcode = qrResponse.qrcode;
|
||||
activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
|
||||
activeLogin.startedAt = Date.now();
|
||||
scannedPrinted = false;
|
||||
logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
|
||||
process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
|
||||
try {
|
||||
const qrterm = await import("qrcode-terminal");
|
||||
qrterm.default.generate(qrResponse.qrcode_img_content, { small: true });
|
||||
} catch {
|
||||
process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`);
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: `刷新二维码失败: ${String(refreshErr)}`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "confirmed": {
|
||||
if (!statusResponse.ilink_bot_id) {
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
logger.error("Login confirmed but ilink_bot_id missing from response");
|
||||
return {
|
||||
connected: false,
|
||||
message: "登录失败:服务器未返回 ilink_bot_id。",
|
||||
};
|
||||
}
|
||||
|
||||
activeLogin.botToken = statusResponse.bot_token;
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
|
||||
logger.info(
|
||||
`✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
botToken: statusResponse.bot_token,
|
||||
accountId: statusResponse.ilink_bot_id,
|
||||
baseUrl: statusResponse.baseurl,
|
||||
userId: statusResponse.ilink_user_id,
|
||||
message: "✅ 与微信连接成功!",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`Error polling QR status: ${String(err)}`);
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: `Login failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`,
|
||||
);
|
||||
activeLogins.delete(opts.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: "登录超时,请重试。",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { withFileLock } from "openclaw/plugin-sdk";
|
||||
|
||||
import { resolveStateDir } from "../storage/state-dir.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
/**
|
||||
* Resolve the framework credentials directory (mirrors core resolveOAuthDir).
|
||||
* Path: $OPENCLAW_OAUTH_DIR || $OPENCLAW_STATE_DIR/credentials || ~/.openclaw/credentials
|
||||
*/
|
||||
function resolveCredentialsDir(): string {
|
||||
const override = process.env.OPENCLAW_OAUTH_DIR?.trim();
|
||||
if (override) return override;
|
||||
return path.join(resolveStateDir(), "credentials");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a channel/account key for safe use in filenames (mirrors core safeChannelKey).
|
||||
*/
|
||||
function safeKey(raw: string): string {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) throw new Error("invalid key for allowFrom path");
|
||||
const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
|
||||
if (!safe || safe === "_") throw new Error("invalid key for allowFrom path");
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the framework allowFrom file path for a given account.
|
||||
* Mirrors: `resolveAllowFromPath(channel, env, accountId)` from core.
|
||||
* Path: `<credDir>/openclaw-weixin-<accountId>-allowFrom.json`
|
||||
*/
|
||||
export function resolveFrameworkAllowFromPath(accountId: string): string {
|
||||
const base = safeKey("openclaw-weixin");
|
||||
const safeAccount = safeKey(accountId);
|
||||
return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);
|
||||
}
|
||||
|
||||
type AllowFromFileContent = {
|
||||
version: number;
|
||||
allowFrom: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the framework allowFrom list for an account (user IDs authorized via pairing).
|
||||
* Returns an empty array when the file is missing or unreadable.
|
||||
*/
|
||||
export function readFrameworkAllowFromList(accountId: string): string[] {
|
||||
const filePath = resolveFrameworkAllowFromPath(accountId);
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as AllowFromFileContent;
|
||||
if (Array.isArray(parsed.allowFrom)) {
|
||||
return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** File lock options matching the framework's pairing store lock settings. */
|
||||
const LOCK_OPTIONS = {
|
||||
retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 },
|
||||
stale: 10_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a user ID in the framework's channel allowFrom store.
|
||||
* This writes directly to the same JSON file that `readChannelAllowFromStore` reads,
|
||||
* making the user visible to the framework authorization pipeline.
|
||||
*
|
||||
* Uses file locking to avoid races with concurrent readers/writers.
|
||||
*/
|
||||
export async function registerUserInFrameworkStore(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}): Promise<{ changed: boolean }> {
|
||||
const { accountId, userId } = params;
|
||||
const trimmedUserId = userId.trim();
|
||||
if (!trimmedUserId) return { changed: false };
|
||||
|
||||
const filePath = resolveFrameworkAllowFromPath(accountId);
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Ensure the file exists before locking
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const initial: AllowFromFileContent = { version: 1, allowFrom: [] };
|
||||
fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
return await withFileLock(filePath, LOCK_OPTIONS, async () => {
|
||||
let content: AllowFromFileContent = { version: 1, allowFrom: [] };
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as AllowFromFileContent;
|
||||
if (Array.isArray(parsed.allowFrom)) {
|
||||
content = parsed;
|
||||
}
|
||||
} catch {
|
||||
// If read/parse fails, start fresh
|
||||
}
|
||||
|
||||
if (content.allowFrom.includes(trimmedUserId)) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
content.allowFrom.push(trimmedUserId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
|
||||
logger.info(
|
||||
`registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`,
|
||||
);
|
||||
return { changed: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared AES-128-ECB crypto utilities for CDN upload and download.
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv } from "node:crypto";
|
||||
|
||||
/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
|
||||
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
||||
const cipher = createCipheriv("aes-128-ecb", key, null);
|
||||
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
}
|
||||
|
||||
/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
|
||||
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
|
||||
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
|
||||
export function aesEcbPaddedSize(plaintextSize: number): number {
|
||||
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { encryptAesEcb } from "./aes-ecb.js";
|
||||
import { buildCdnUploadUrl } from "./cdn-url.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { redactUrl } from "../util/redact.js";
|
||||
|
||||
/** Maximum retry attempts for CDN upload. */
|
||||
const UPLOAD_MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Upload one buffer to the Weixin CDN with AES-128-ECB encryption.
|
||||
* Returns the download encrypted_query_param from the CDN response.
|
||||
* Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.
|
||||
*/
|
||||
export async function uploadBufferToCdn(params: {
|
||||
buf: Buffer;
|
||||
uploadParam: string;
|
||||
filekey: string;
|
||||
cdnBaseUrl: string;
|
||||
label: string;
|
||||
aeskey: Buffer;
|
||||
}): Promise<{ downloadParam: string }> {
|
||||
const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
|
||||
const ciphertext = encryptAesEcb(buf, aeskey);
|
||||
const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
|
||||
logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
|
||||
|
||||
let downloadParam: string | undefined;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await fetch(cdnUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: new Uint8Array(ciphertext),
|
||||
});
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
const errMsg = res.headers.get("x-error-message") ?? (await res.text());
|
||||
logger.error(
|
||||
`${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
|
||||
);
|
||||
throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
|
||||
logger.error(
|
||||
`${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
|
||||
);
|
||||
throw new Error(`CDN upload server error: ${errMsg}`);
|
||||
}
|
||||
downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
|
||||
if (!downloadParam) {
|
||||
logger.error(
|
||||
`${label}: CDN response missing x-encrypted-param header attempt=${attempt}`,
|
||||
);
|
||||
throw new Error("CDN upload response missing x-encrypted-param header");
|
||||
}
|
||||
logger.debug(`${label}: CDN upload success attempt=${attempt}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (err instanceof Error && err.message.includes("client error")) throw err;
|
||||
if (attempt < UPLOAD_MAX_RETRIES) {
|
||||
logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
|
||||
} else {
|
||||
logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadParam) {
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
|
||||
}
|
||||
return { downloadParam };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Unified CDN URL construction for Weixin CDN upload/download.
|
||||
*/
|
||||
|
||||
/** Build a CDN download URL from encrypt_query_param. */
|
||||
export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
|
||||
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
|
||||
}
|
||||
|
||||
/** Build a CDN upload URL from upload_param and filekey. */
|
||||
export function buildCdnUploadUrl(params: {
|
||||
cdnBaseUrl: string;
|
||||
uploadParam: string;
|
||||
filekey: string;
|
||||
}): string {
|
||||
return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { decryptAesEcb } from "./aes-ecb.js";
|
||||
import { buildCdnDownloadUrl } from "./cdn-url.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
/**
|
||||
* Download raw bytes from the CDN (no decryption).
|
||||
*/
|
||||
async function fetchCdnBytes(url: string, label: string): Promise<Buffer> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch (err) {
|
||||
const cause =
|
||||
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)";
|
||||
logger.error(
|
||||
`${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "(unreadable)");
|
||||
const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;
|
||||
logger.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CDNMedia.aes_key into a raw 16-byte AES key.
|
||||
*
|
||||
* Two encodings are seen in the wild:
|
||||
* - base64(raw 16 bytes) → images (aes_key from media field)
|
||||
* - base64(hex string of 16 bytes) → file / voice / video
|
||||
*
|
||||
* In the second case, base64-decoding yields 32 ASCII hex chars which must
|
||||
* then be parsed as hex to recover the actual 16-byte key.
|
||||
*/
|
||||
function parseAesKey(aesKeyBase64: string, label: string): Buffer {
|
||||
const decoded = Buffer.from(aesKeyBase64, "base64");
|
||||
if (decoded.length === 16) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
|
||||
// hex-encoded key: base64 → hex string → raw bytes
|
||||
return Buffer.from(decoded.toString("ascii"), "hex");
|
||||
}
|
||||
const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`;
|
||||
logger.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
|
||||
* aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).
|
||||
*/
|
||||
export async function downloadAndDecryptBuffer(
|
||||
encryptedQueryParam: string,
|
||||
aesKeyBase64: string,
|
||||
cdnBaseUrl: string,
|
||||
label: string,
|
||||
): Promise<Buffer> {
|
||||
const key = parseAesKey(aesKeyBase64, label);
|
||||
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
||||
logger.debug(`${label}: fetching url=${url}`);
|
||||
const encrypted = await fetchCdnBytes(url, label);
|
||||
logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
|
||||
const decrypted = decryptAesEcb(encrypted, key);
|
||||
logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
|
||||
*/
|
||||
export async function downloadPlainCdnBuffer(
|
||||
encryptedQueryParam: string,
|
||||
cdnBaseUrl: string,
|
||||
label: string,
|
||||
): Promise<Buffer> {
|
||||
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
||||
logger.debug(`${label}: fetching url=${url}`);
|
||||
return fetchCdnBytes(url, label);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { getUploadUrl } from "../api/api.js";
|
||||
import type { WeixinApiOptions } from "../api/api.js";
|
||||
import { aesEcbPaddedSize } from "./aes-ecb.js";
|
||||
import { uploadBufferToCdn } from "./cdn-upload.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
|
||||
import { tempFileName } from "../util/random.js";
|
||||
import { UploadMediaType } from "../api/types.js";
|
||||
|
||||
export type UploadedFileInfo = {
|
||||
filekey: string;
|
||||
/** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
|
||||
downloadEncryptedQueryParam: string;
|
||||
/** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
|
||||
aeskey: string;
|
||||
/** Plaintext file size in bytes */
|
||||
fileSize: number;
|
||||
/** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
|
||||
fileSizeCiphertext: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a remote media URL (image, video, file) to a local temp file in destDir.
|
||||
* Returns the local file path; extension is inferred from Content-Type / URL.
|
||||
*/
|
||||
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
|
||||
logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
|
||||
logger.error(`downloadRemoteImageToTemp: ${msg}`);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
|
||||
const name = tempFileName("weixin-remote", ext);
|
||||
const filePath = path.join(destDir, name);
|
||||
await fs.writeFile(filePath, buf);
|
||||
logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
|
||||
*/
|
||||
async function uploadMediaToCdn(params: {
|
||||
filePath: string;
|
||||
toUserId: string;
|
||||
opts: WeixinApiOptions;
|
||||
cdnBaseUrl: string;
|
||||
mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
|
||||
label: string;
|
||||
}): Promise<UploadedFileInfo> {
|
||||
const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
|
||||
|
||||
const plaintext = await fs.readFile(filePath);
|
||||
const rawsize = plaintext.length;
|
||||
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
||||
const filesize = aesEcbPaddedSize(rawsize);
|
||||
const filekey = crypto.randomBytes(16).toString("hex");
|
||||
const aeskey = crypto.randomBytes(16);
|
||||
|
||||
logger.debug(
|
||||
`${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
|
||||
);
|
||||
|
||||
const uploadUrlResp = await getUploadUrl({
|
||||
...opts,
|
||||
filekey,
|
||||
media_type: mediaType,
|
||||
to_user_id: toUserId,
|
||||
rawsize,
|
||||
rawfilemd5,
|
||||
filesize,
|
||||
no_need_thumb: true,
|
||||
aeskey: aeskey.toString("hex"),
|
||||
});
|
||||
|
||||
const uploadParam = uploadUrlResp.upload_param;
|
||||
if (!uploadParam) {
|
||||
logger.error(
|
||||
`${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`,
|
||||
);
|
||||
throw new Error(`${label}: getUploadUrl returned no upload_param`);
|
||||
}
|
||||
|
||||
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
|
||||
buf: plaintext,
|
||||
uploadParam,
|
||||
filekey,
|
||||
cdnBaseUrl,
|
||||
aeskey,
|
||||
label: `${label}[orig filekey=${filekey}]`,
|
||||
});
|
||||
|
||||
return {
|
||||
filekey,
|
||||
downloadEncryptedQueryParam,
|
||||
aeskey: aeskey.toString("hex"),
|
||||
fileSize: rawsize,
|
||||
fileSizeCiphertext: filesize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
|
||||
export async function uploadFileToWeixin(params: {
|
||||
filePath: string;
|
||||
toUserId: string;
|
||||
opts: WeixinApiOptions;
|
||||
cdnBaseUrl: string;
|
||||
}): Promise<UploadedFileInfo> {
|
||||
return uploadMediaToCdn({
|
||||
...params,
|
||||
mediaType: UploadMediaType.IMAGE,
|
||||
label: "uploadFileToWeixin",
|
||||
});
|
||||
}
|
||||
|
||||
/** Upload a local video file to the Weixin CDN. */
|
||||
export async function uploadVideoToWeixin(params: {
|
||||
filePath: string;
|
||||
toUserId: string;
|
||||
opts: WeixinApiOptions;
|
||||
cdnBaseUrl: string;
|
||||
}): Promise<UploadedFileInfo> {
|
||||
return uploadMediaToCdn({
|
||||
...params,
|
||||
mediaType: UploadMediaType.VIDEO,
|
||||
label: "uploadVideoToWeixin",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file attachment (non-image, non-video) to the Weixin CDN.
|
||||
* Uses media_type=FILE; no thumbnail required.
|
||||
*/
|
||||
export async function uploadFileAttachmentToWeixin(params: {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
toUserId: string;
|
||||
opts: WeixinApiOptions;
|
||||
cdnBaseUrl: string;
|
||||
}): Promise<UploadedFileInfo> {
|
||||
return uploadMediaToCdn({
|
||||
...params,
|
||||
mediaType: UploadMediaType.FILE,
|
||||
label: "uploadFileAttachmentToWeixin",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
|
||||
import {
|
||||
registerWeixinAccountId,
|
||||
loadWeixinAccount,
|
||||
saveWeixinAccount,
|
||||
listWeixinAccountIds,
|
||||
resolveWeixinAccount,
|
||||
triggerWeixinChannelReload,
|
||||
DEFAULT_BASE_URL,
|
||||
} from "./auth/accounts.js";
|
||||
import type { ResolvedWeixinAccount } from "./auth/accounts.js";
|
||||
import { assertSessionActive } from "./api/session-guard.js";
|
||||
import { getContextToken } from "./messaging/inbound.js";
|
||||
import { logger } from "./util/logger.js";
|
||||
import {
|
||||
DEFAULT_ILINK_BOT_TYPE,
|
||||
startWeixinLoginWithQr,
|
||||
waitForWeixinLogin,
|
||||
} from "./auth/login-qr.js";
|
||||
import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js";
|
||||
import { monitorWeixinProvider } from "./monitor/monitor.js";
|
||||
import { sendWeixinMediaFile } from "./messaging/send-media.js";
|
||||
import { sendMessageWeixin } from "./messaging/send.js";
|
||||
import { downloadRemoteImageToTemp } from "./cdn/upload.js";
|
||||
|
||||
/** Returns true when mediaUrl refers to a local filesystem path (absolute or relative). */
|
||||
function isLocalFilePath(mediaUrl: string): boolean {
|
||||
// Treat anything without a URL scheme (no "://") as a local path.
|
||||
return !mediaUrl.includes("://");
|
||||
}
|
||||
|
||||
function isRemoteUrl(mediaUrl: string): boolean {
|
||||
return mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
||||
}
|
||||
|
||||
const MEDIA_OUTBOUND_TEMP_DIR = "/tmp/openclaw/weixin/media/outbound-temp";
|
||||
|
||||
/** Resolve any local path scheme to an absolute filesystem path. */
|
||||
function resolveLocalPath(mediaUrl: string): string {
|
||||
if (mediaUrl.startsWith("file://")) return new URL(mediaUrl).pathname;
|
||||
// Resolve any relative path (./foo, ../foo, .openclaw/foo, foo/bar) against cwd
|
||||
if (!path.isAbsolute(mediaUrl)) return path.resolve(mediaUrl);
|
||||
return mediaUrl;
|
||||
}
|
||||
|
||||
async function sendWeixinOutbound(params: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
contextToken?: string;
|
||||
mediaUrl?: string;
|
||||
}): Promise<{ channel: string; messageId: string }> {
|
||||
const account = resolveWeixinAccount(params.cfg, params.accountId);
|
||||
const aLog = logger.withAccount(account.accountId);
|
||||
assertSessionActive(account.accountId);
|
||||
if (!account.configured) {
|
||||
aLog.error(`sendWeixinOutbound: account not configured`);
|
||||
throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
|
||||
}
|
||||
if (!params.contextToken) {
|
||||
aLog.error(`sendWeixinOutbound: contextToken missing, refusing to send to=${params.to}`);
|
||||
throw new Error("sendWeixinOutbound: contextToken is required");
|
||||
}
|
||||
const result = await sendMessageWeixin({ to: params.to, text: params.text, opts: {
|
||||
baseUrl: account.baseUrl,
|
||||
token: account.token,
|
||||
contextToken: params.contextToken,
|
||||
}});
|
||||
return { channel: "openclaw-weixin", messageId: result.messageId };
|
||||
}
|
||||
|
||||
export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
|
||||
id: "openclaw-weixin",
|
||||
meta: {
|
||||
id: "openclaw-weixin",
|
||||
label: "openclaw-weixin",
|
||||
selectionLabel: "openclaw-weixin (long-poll)",
|
||||
docsPath: "/channels/openclaw-weixin",
|
||||
docsLabel: "openclaw-weixin",
|
||||
blurb: "getUpdates long-poll upstream, sendMessage downstream; token auth.",
|
||||
order: 75,
|
||||
},
|
||||
configSchema: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: true,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
// Weixin user IDs always end with @im.wechat; treat as direct IDs, skip directory lookup.
|
||||
looksLikeId: (raw) => raw.endsWith("@im.wechat"),
|
||||
},
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.",
|
||||
"When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.",
|
||||
"IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.",
|
||||
"IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation). Without an explicit 'to', the cron delivery will fail with 'requires target'. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '<current_user_id@im.wechat>' }.",
|
||||
],
|
||||
},
|
||||
reload: { configPrefixes: ["channels.openclaw-weixin"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listWeixinAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveWeixinAccount(cfg, accountId),
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
}),
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async (ctx) => {
|
||||
const result = await sendWeixinOutbound({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.text,
|
||||
accountId: ctx.accountId,
|
||||
contextToken: getContextToken(ctx.accountId!, ctx.to),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
sendMedia: async (ctx) => {
|
||||
const account = resolveWeixinAccount(ctx.cfg, ctx.accountId);
|
||||
const aLog = logger.withAccount(account.accountId);
|
||||
assertSessionActive(account.accountId);
|
||||
if (!account.configured) {
|
||||
aLog.error(`sendMedia: account not configured`);
|
||||
throw new Error(
|
||||
"weixin not configured: please run `openclaw channels login --channel openclaw-weixin`",
|
||||
);
|
||||
}
|
||||
|
||||
const mediaUrl = ctx.mediaUrl;
|
||||
|
||||
if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) {
|
||||
let filePath: string;
|
||||
if (isLocalFilePath(mediaUrl)) {
|
||||
filePath = resolveLocalPath(mediaUrl);
|
||||
aLog.debug(`sendMedia: uploading local file ${filePath}`);
|
||||
} else {
|
||||
aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
|
||||
filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
|
||||
aLog.debug(`sendMedia: remote image downloaded to ${filePath}`);
|
||||
}
|
||||
const contextToken = getContextToken(account.accountId, ctx.to);
|
||||
const result = await sendWeixinMediaFile({
|
||||
filePath,
|
||||
to: ctx.to,
|
||||
text: ctx.text ?? "",
|
||||
opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
|
||||
cdnBaseUrl: account.cdnBaseUrl,
|
||||
});
|
||||
return { channel: "openclaw-weixin", messageId: result.messageId };
|
||||
}
|
||||
|
||||
const result = await sendWeixinOutbound({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.text ?? "",
|
||||
accountId: ctx.accountId,
|
||||
contextToken: getContextToken(ctx.accountId!, ctx.to),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: "",
|
||||
lastError: null,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
},
|
||||
collectStatusIssues: () => [],
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
lastInboundAt: snapshot.lastInboundAt ?? null,
|
||||
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||
...runtime,
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
login: async ({ cfg, accountId, verbose, runtime }) => {
|
||||
const account = resolveWeixinAccount(cfg, accountId);
|
||||
|
||||
const log = (msg: string) => {
|
||||
runtime?.log?.(msg);
|
||||
};
|
||||
|
||||
log(`正在启动微信扫码登录...`);
|
||||
const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({
|
||||
accountId: account.accountId,
|
||||
apiBaseUrl: account.baseUrl,
|
||||
botType: DEFAULT_ILINK_BOT_TYPE,
|
||||
verbose: Boolean(verbose),
|
||||
});
|
||||
|
||||
if (!startResult.qrcodeUrl) {
|
||||
logger.warn(
|
||||
`auth.login: failed to get QR code accountId=${account.accountId} message=${startResult.message}`,
|
||||
);
|
||||
log(startResult.message);
|
||||
throw new Error(startResult.message);
|
||||
}
|
||||
|
||||
log(`\n使用微信扫描以下二维码,以完成连接:\n`);
|
||||
try {
|
||||
const qrcodeterminal = await import("qrcode-terminal");
|
||||
await new Promise<void>((resolve) => {
|
||||
qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
|
||||
console.log(qr);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`auth.login: qrcode-terminal unavailable, falling back to URL err=${String(err)}`,
|
||||
);
|
||||
log(`二维码链接: ${startResult.qrcodeUrl}`);
|
||||
}
|
||||
|
||||
const loginTimeoutMs = 480_000;
|
||||
log(`\n等待连接结果...\n`);
|
||||
|
||||
const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({
|
||||
sessionKey: startResult.sessionKey,
|
||||
apiBaseUrl: account.baseUrl,
|
||||
timeoutMs: loginTimeoutMs,
|
||||
verbose: Boolean(verbose),
|
||||
botType: DEFAULT_ILINK_BOT_TYPE,
|
||||
});
|
||||
|
||||
if (waitResult.connected && waitResult.botToken && waitResult.accountId) {
|
||||
try {
|
||||
// Normalize the raw ilink_bot_id (e.g. "hex@im.bot") to a filesystem-safe
|
||||
// key (e.g. "hex-im-bot") so account files have no special chars.
|
||||
const normalizedId = normalizeAccountId(waitResult.accountId);
|
||||
saveWeixinAccount(normalizedId, {
|
||||
token: waitResult.botToken,
|
||||
baseUrl: waitResult.baseUrl,
|
||||
userId: waitResult.userId,
|
||||
});
|
||||
registerWeixinAccountId(normalizedId);
|
||||
void triggerWeixinChannelReload();
|
||||
log(`\n✅ 与微信连接成功!`);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`auth.login: failed to save account data accountId=${waitResult.accountId} err=${String(err)}`,
|
||||
);
|
||||
log(`⚠️ 保存账号数据失败: ${String(err)}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`auth.login: login did not complete accountId=${account.accountId} message=${waitResult.message}`,
|
||||
);
|
||||
// log(waitResult.message);
|
||||
throw new Error(waitResult.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
logger.debug(`startAccount entry`);
|
||||
if (!ctx) {
|
||||
logger.warn(`gateway.startAccount: called with undefined ctx, skipping`);
|
||||
return;
|
||||
}
|
||||
const account = ctx.account;
|
||||
const aLog = logger.withAccount(account.accountId);
|
||||
aLog.debug(`about to call monitorWeixinProvider`);
|
||||
aLog.info(`starting weixin webhook`);
|
||||
|
||||
ctx.setStatus?.({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
lastEventAt: Date.now(),
|
||||
});
|
||||
|
||||
if (!account.configured) {
|
||||
aLog.error(`account not configured`);
|
||||
ctx.log?.error?.(
|
||||
`[${account.accountId}] weixin not logged in — run: openclaw channels login --channel openclaw-weixin`,
|
||||
);
|
||||
ctx.setStatus?.({ accountId: account.accountId, running: false });
|
||||
throw new Error("weixin not configured: missing token");
|
||||
}
|
||||
|
||||
ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`);
|
||||
|
||||
const logPath = aLog.getLogFilePath();
|
||||
ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`);
|
||||
|
||||
return monitorWeixinProvider({
|
||||
baseUrl: account.baseUrl,
|
||||
cdnBaseUrl: account.cdnBaseUrl,
|
||||
token: account.token,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
},
|
||||
loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
|
||||
// For re-login: use saved baseUrl from account data; fall back to default for new accounts.
|
||||
const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
|
||||
const result: WeixinQrStartResult = await startWeixinLoginWithQr({
|
||||
accountId: accountId ?? undefined,
|
||||
apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
|
||||
botType: DEFAULT_ILINK_BOT_TYPE,
|
||||
force,
|
||||
timeoutMs,
|
||||
verbose,
|
||||
});
|
||||
// Return sessionKey so the client can pass it back in loginWithQrWait.
|
||||
return {
|
||||
qrDataUrl: result.qrcodeUrl,
|
||||
message: result.message,
|
||||
sessionKey: result.sessionKey,
|
||||
} as { qrDataUrl?: string; message: string };
|
||||
},
|
||||
loginWithQrWait: async (params) => {
|
||||
// sessionKey is forwarded by the client after loginWithQrStart (runtime param extension).
|
||||
const sessionKey = (params as { sessionKey?: string }).sessionKey || params.accountId || "";
|
||||
const savedBaseUrl = params.accountId
|
||||
? loadWeixinAccount(params.accountId)?.baseUrl?.trim()
|
||||
: "";
|
||||
const result: WeixinQrWaitResult = await waitForWeixinLogin({
|
||||
sessionKey,
|
||||
apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
|
||||
if (result.connected && result.botToken && result.accountId) {
|
||||
try {
|
||||
const normalizedId = normalizeAccountId(result.accountId);
|
||||
saveWeixinAccount(normalizedId, {
|
||||
token: result.botToken,
|
||||
baseUrl: result.baseUrl,
|
||||
userId: result.userId,
|
||||
});
|
||||
registerWeixinAccountId(normalizedId);
|
||||
triggerWeixinChannelReload();
|
||||
logger.info(`loginWithQrWait: saved account data for accountId=${normalizedId}`);
|
||||
} catch (err) {
|
||||
logger.error(`loginWithQrWait: failed to save account data err=${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: result.connected,
|
||||
message: result.message,
|
||||
accountId: result.accountId,
|
||||
} as { connected: boolean; message: string };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CDN_BASE_URL, DEFAULT_BASE_URL } from "../auth/accounts.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod config schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const weixinAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
baseUrl: z.string().default(DEFAULT_BASE_URL),
|
||||
cdnBaseUrl: z.string().default(CDN_BASE_URL),
|
||||
routeTag: z.number().optional(),
|
||||
});
|
||||
|
||||
/** Top-level weixin config schema (token is stored in credentials file, not config). */
|
||||
export const WeixinConfigSchema = weixinAccountSchema.extend({
|
||||
accounts: z.record(z.string(), weixinAccountSchema).optional(),
|
||||
/** Default URL for `openclaw openclaw-weixin logs-upload`. Set via `openclaw config set channels.openclaw-weixin.logUploadUrl <url>`. */
|
||||
logUploadUrl: z.string().optional(),
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
|
||||
/** Minimal subset of commander's Command used by registerWeixinCli. */
|
||||
type CliCommand = {
|
||||
command(name: string): CliCommand;
|
||||
description(str: string): CliCommand;
|
||||
option(flags: string, description: string): CliCommand;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
action(fn: (...args: any[]) => void | Promise<void>): CliCommand;
|
||||
};
|
||||
|
||||
function currentDayLogFileName(): string {
|
||||
const now = new Date();
|
||||
const offsetMs = -now.getTimezoneOffset() * 60_000;
|
||||
const dateKey = new Date(now.getTime() + offsetMs).toISOString().slice(0, 10);
|
||||
return `openclaw-${dateKey}.log`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse --file argument: accepts a short 8-digit date (YYYYMMDD)
|
||||
* like "20260316", a full filename like "openclaw-2026-03-16.log",
|
||||
* or a legacy 10-digit hour timestamp "2026031614".
|
||||
*/
|
||||
function resolveLogFileName(file: string): string {
|
||||
if (/^\d{8}$/.test(file)) {
|
||||
const yyyy = file.slice(0, 4);
|
||||
const mm = file.slice(4, 6);
|
||||
const dd = file.slice(6, 8);
|
||||
return `openclaw-${yyyy}-${mm}-${dd}.log`;
|
||||
}
|
||||
if (/^\d{10}$/.test(file)) {
|
||||
const yyyy = file.slice(0, 4);
|
||||
const mm = file.slice(4, 6);
|
||||
const dd = file.slice(6, 8);
|
||||
return `openclaw-${yyyy}-${mm}-${dd}.log`;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function mainLogDir(): string {
|
||||
return path.join("/tmp", "openclaw");
|
||||
}
|
||||
|
||||
function getConfiguredUploadUrl(config: OpenClawConfig): string | undefined {
|
||||
const section = config.channels?.["openclaw-weixin"] as { logUploadUrl?: string } | undefined;
|
||||
return section?.logUploadUrl;
|
||||
}
|
||||
|
||||
/** Register the `openclaw openclaw-weixin logs-upload` CLI subcommand. */
|
||||
export function registerWeixinCli(params: { program: CliCommand; config: OpenClawConfig }): void {
|
||||
const { program, config } = params;
|
||||
|
||||
const root = program.command("openclaw-weixin").description("Weixin channel utilities");
|
||||
|
||||
root
|
||||
.command("logs-upload")
|
||||
.description("Upload a Weixin log file to a remote URL via HTTP POST")
|
||||
.option("--url <url>", "Remote URL to POST the log file to (overrides config)")
|
||||
.option(
|
||||
"--file <file>",
|
||||
"Log file to upload: full filename or 8-digit date YYYYMMDD (default: today)",
|
||||
)
|
||||
.action(async (options: { url?: string; file?: string }) => {
|
||||
const uploadUrl = options.url ?? getConfiguredUploadUrl(config);
|
||||
if (!uploadUrl) {
|
||||
console.error(
|
||||
`[weixin] No upload URL specified. Pass --url or set it with:\n openclaw config set channels.openclaw-weixin.logUploadUrl <url>`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const logDir = mainLogDir();
|
||||
const rawFile = options.file ?? currentDayLogFileName();
|
||||
const fileName = resolveLogFileName(rawFile);
|
||||
const filePath = path.isAbsolute(fileName) ? fileName : path.join(logDir, fileName);
|
||||
|
||||
let content: Buffer;
|
||||
try {
|
||||
content = await fs.readFile(filePath);
|
||||
} catch (err) {
|
||||
console.error(`[weixin] Failed to read log file: ${filePath}\n ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[weixin] Uploading ${filePath} (${content.length} bytes) to ${uploadUrl} ...`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", new Blob([new Uint8Array(content)], { type: "text/plain" }), fileName);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(uploadUrl, { method: "POST", body: formData });
|
||||
} catch (err) {
|
||||
console.error(`[weixin] Upload request failed: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const responseBody = await res.text().catch(() => "");
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`[weixin] Upload failed: HTTP ${res.status} ${res.statusText}\n ${responseBody}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[weixin] Upload succeeded (HTTP ${res.status})`);
|
||||
const fileid = res.headers.get("fileid");
|
||||
if (fileid) {
|
||||
console.log(`fileid: ${fileid}`);
|
||||
} else {
|
||||
// fileid not found; dump all headers for diagnosis
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
console.log("headers:", JSON.stringify(headers, null, 2));
|
||||
}
|
||||
if (responseBody) {
|
||||
console.log("body:", responseBody);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { WeixinInboundMediaOpts } from "../messaging/inbound.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { getMimeFromFilename } from "./mime.js";
|
||||
import {
|
||||
downloadAndDecryptBuffer,
|
||||
downloadPlainCdnBuffer,
|
||||
} from "../cdn/pic-decrypt.js";
|
||||
import { silkToWav } from "./silk-transcode.js";
|
||||
import type { WeixinMessage } from "../api/types.js";
|
||||
import { MessageItemType } from "../api/types.js";
|
||||
|
||||
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Persist a buffer via the framework's unified media store. */
|
||||
type SaveMediaFn = (
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
subdir?: string,
|
||||
maxBytes?: number,
|
||||
originalFilename?: string,
|
||||
) => Promise<{ path: string }>;
|
||||
|
||||
/**
|
||||
* Download and decrypt media from a single MessageItem.
|
||||
* Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure.
|
||||
*/
|
||||
export async function downloadMediaFromItem(
|
||||
item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never,
|
||||
deps: {
|
||||
cdnBaseUrl: string;
|
||||
saveMedia: SaveMediaFn;
|
||||
log: (msg: string) => void;
|
||||
errLog: (msg: string) => void;
|
||||
label: string;
|
||||
},
|
||||
): Promise<WeixinInboundMediaOpts> {
|
||||
const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;
|
||||
const result: WeixinInboundMediaOpts = {};
|
||||
|
||||
if (item.type === MessageItemType.IMAGE) {
|
||||
const img = item.image_item;
|
||||
if (!img?.media?.encrypt_query_param) return result;
|
||||
const aesKeyBase64 = img.aeskey
|
||||
? Buffer.from(img.aeskey, "hex").toString("base64")
|
||||
: img.media.aes_key;
|
||||
logger.debug(
|
||||
`${label} image: encrypt_query_param=${img.media.encrypt_query_param.slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"}`,
|
||||
);
|
||||
try {
|
||||
const buf = aesKeyBase64
|
||||
? await downloadAndDecryptBuffer(
|
||||
img.media.encrypt_query_param,
|
||||
aesKeyBase64,
|
||||
cdnBaseUrl,
|
||||
`${label} image`,
|
||||
)
|
||||
: await downloadPlainCdnBuffer(
|
||||
img.media.encrypt_query_param,
|
||||
cdnBaseUrl,
|
||||
`${label} image-plain`,
|
||||
);
|
||||
const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES);
|
||||
result.decryptedPicPath = saved.path;
|
||||
logger.debug(`${label} image saved: ${saved.path}`);
|
||||
} catch (err) {
|
||||
logger.error(`${label} image download/decrypt failed: ${String(err)}`);
|
||||
errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`);
|
||||
}
|
||||
} else if (item.type === MessageItemType.VOICE) {
|
||||
const voice = item.voice_item;
|
||||
if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result;
|
||||
try {
|
||||
const silkBuf = await downloadAndDecryptBuffer(
|
||||
voice.media.encrypt_query_param,
|
||||
voice.media.aes_key,
|
||||
cdnBaseUrl,
|
||||
`${label} voice`,
|
||||
);
|
||||
logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`);
|
||||
const wavBuf = await silkToWav(silkBuf);
|
||||
if (wavBuf) {
|
||||
const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES);
|
||||
result.decryptedVoicePath = saved.path;
|
||||
result.voiceMediaType = "audio/wav";
|
||||
logger.debug(`${label} voice: saved WAV to ${saved.path}`);
|
||||
} else {
|
||||
const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES);
|
||||
result.decryptedVoicePath = saved.path;
|
||||
result.voiceMediaType = "audio/silk";
|
||||
logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`${label} voice download/transcode failed: ${String(err)}`);
|
||||
errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`);
|
||||
}
|
||||
} else if (item.type === MessageItemType.FILE) {
|
||||
const fileItem = item.file_item;
|
||||
if (!fileItem?.media?.encrypt_query_param || !fileItem.media.aes_key) return result;
|
||||
try {
|
||||
const buf = await downloadAndDecryptBuffer(
|
||||
fileItem.media.encrypt_query_param,
|
||||
fileItem.media.aes_key,
|
||||
cdnBaseUrl,
|
||||
`${label} file`,
|
||||
);
|
||||
const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin");
|
||||
const saved = await saveMedia(
|
||||
buf,
|
||||
mime,
|
||||
"inbound",
|
||||
WEIXIN_MEDIA_MAX_BYTES,
|
||||
fileItem.file_name ?? undefined,
|
||||
);
|
||||
result.decryptedFilePath = saved.path;
|
||||
result.fileMediaType = mime;
|
||||
logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`);
|
||||
} catch (err) {
|
||||
logger.error(`${label} file download failed: ${String(err)}`);
|
||||
errLog(`weixin ${label} file download failed: ${String(err)}`);
|
||||
}
|
||||
} else if (item.type === MessageItemType.VIDEO) {
|
||||
const videoItem = item.video_item;
|
||||
if (!videoItem?.media?.encrypt_query_param || !videoItem.media.aes_key) return result;
|
||||
try {
|
||||
const buf = await downloadAndDecryptBuffer(
|
||||
videoItem.media.encrypt_query_param,
|
||||
videoItem.media.aes_key,
|
||||
cdnBaseUrl,
|
||||
`${label} video`,
|
||||
);
|
||||
const saved = await saveMedia(buf, "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES);
|
||||
result.decryptedVideoPath = saved.path;
|
||||
logger.debug(`${label} video: saved to ${saved.path}`);
|
||||
} catch (err) {
|
||||
logger.error(`${label} video download failed: ${String(err)}`);
|
||||
errLog(`weixin ${label} video download failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import path from "node:path";
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".txt": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".wav": "audio/wav",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
const MIME_TO_EXTENSION: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"video/mp4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
"video/webm": ".webm",
|
||||
"video/x-matroska": ".mkv",
|
||||
"video/x-msvideo": ".avi",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"application/pdf": ".pdf",
|
||||
"application/zip": ".zip",
|
||||
"application/x-tar": ".tar",
|
||||
"application/gzip": ".gz",
|
||||
"text/plain": ".txt",
|
||||
"text/csv": ".csv",
|
||||
};
|
||||
|
||||
/** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */
|
||||
export function getMimeFromFilename(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Get file extension from MIME type. Returns ".bin" for unknown types. */
|
||||
export function getExtensionFromMime(mimeType: string): string {
|
||||
const ct = mimeType.split(";")[0].trim().toLowerCase();
|
||||
return MIME_TO_EXTENSION[ct] ?? ".bin";
|
||||
}
|
||||
|
||||
/** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */
|
||||
export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {
|
||||
if (contentType) {
|
||||
const ext = getExtensionFromMime(contentType);
|
||||
if (ext !== ".bin") return ext;
|
||||
}
|
||||
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
||||
const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));
|
||||
return knownExts.has(ext) ? ext : ".bin";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
/** Default sample rate for Weixin voice messages. */
|
||||
const SILK_SAMPLE_RATE = 24_000;
|
||||
|
||||
/**
|
||||
* Wrap raw pcm_s16le bytes in a WAV container.
|
||||
* Mono channel, 16-bit signed little-endian.
|
||||
*/
|
||||
function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
|
||||
const pcmBytes = pcm.byteLength;
|
||||
const totalSize = 44 + pcmBytes;
|
||||
const buf = Buffer.allocUnsafe(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
buf.write("RIFF", offset);
|
||||
offset += 4;
|
||||
buf.writeUInt32LE(totalSize - 8, offset);
|
||||
offset += 4;
|
||||
buf.write("WAVE", offset);
|
||||
offset += 4;
|
||||
|
||||
buf.write("fmt ", offset);
|
||||
offset += 4;
|
||||
buf.writeUInt32LE(16, offset);
|
||||
offset += 4; // fmt chunk size
|
||||
buf.writeUInt16LE(1, offset);
|
||||
offset += 2; // PCM format
|
||||
buf.writeUInt16LE(1, offset);
|
||||
offset += 2; // mono
|
||||
buf.writeUInt32LE(sampleRate, offset);
|
||||
offset += 4;
|
||||
buf.writeUInt32LE(sampleRate * 2, offset);
|
||||
offset += 4; // byte rate (mono 16-bit)
|
||||
buf.writeUInt16LE(2, offset);
|
||||
offset += 2; // block align
|
||||
buf.writeUInt16LE(16, offset);
|
||||
offset += 2; // bits per sample
|
||||
|
||||
buf.write("data", offset);
|
||||
offset += 4;
|
||||
buf.writeUInt32LE(pcmBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to transcode a SILK audio buffer to WAV using silk-wasm.
|
||||
* silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }.
|
||||
*
|
||||
* Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails.
|
||||
* Callers should fall back to passing the raw SILK file when null is returned.
|
||||
*/
|
||||
export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
|
||||
try {
|
||||
const { decode } = await import("silk-wasm");
|
||||
|
||||
logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
|
||||
const result = await decode(silkBuf, SILK_SAMPLE_RATE);
|
||||
logger.debug(
|
||||
`silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,
|
||||
);
|
||||
|
||||
const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
|
||||
logger.debug(`silkToWav: WAV size=${wav.length}`);
|
||||
return wav;
|
||||
} catch (err) {
|
||||
logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.
|
||||
*
|
||||
* State file: `<stateDir>/openclaw-weixin/debug-mode.json`
|
||||
* Format: `{ "accounts": { "<accountId>": true, ... } }`
|
||||
*
|
||||
* When enabled, processOneMessage appends a timing summary after each
|
||||
* AI reply is delivered to the user.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../storage/state-dir.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
interface DebugModeState {
|
||||
accounts: Record<string, boolean>;
|
||||
}
|
||||
|
||||
function resolveDebugModePath(): string {
|
||||
return path.join(resolveStateDir(), "openclaw-weixin", "debug-mode.json");
|
||||
}
|
||||
|
||||
function loadState(): DebugModeState {
|
||||
try {
|
||||
const raw = fs.readFileSync(resolveDebugModePath(), "utf-8");
|
||||
const parsed = JSON.parse(raw) as DebugModeState;
|
||||
if (parsed && typeof parsed.accounts === "object") return parsed;
|
||||
} catch {
|
||||
// missing or corrupt — start fresh
|
||||
}
|
||||
return { accounts: {} };
|
||||
}
|
||||
|
||||
function saveState(state: DebugModeState): void {
|
||||
const filePath = resolveDebugModePath();
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/** Toggle debug mode for a bot account. Returns the new state. */
|
||||
export function toggleDebugMode(accountId: string): boolean {
|
||||
const state = loadState();
|
||||
const next = !state.accounts[accountId];
|
||||
state.accounts[accountId] = next;
|
||||
try {
|
||||
saveState(state);
|
||||
} catch (err) {
|
||||
logger.error(`debug-mode: failed to persist state: ${String(err)}`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Check whether debug mode is active for a bot account. */
|
||||
export function isDebugMode(accountId: string): boolean {
|
||||
return loadState().accounts[accountId] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state — only for tests.
|
||||
* @internal
|
||||
*/
|
||||
export function _resetForTest(): void {
|
||||
try {
|
||||
fs.unlinkSync(resolveDebugModePath());
|
||||
} catch {
|
||||
// ignore if not present
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { logger } from "../util/logger.js";
|
||||
import { sendMessageWeixin } from "./send.js";
|
||||
|
||||
/**
|
||||
* Send a plain-text error notice back to the user.
|
||||
* Fire-and-forget: errors are logged but never thrown, so callers stay unaffected.
|
||||
* No-op when contextToken is absent (we have no conversation reference to reply into).
|
||||
*/
|
||||
export async function sendWeixinErrorNotice(params: {
|
||||
to: string;
|
||||
contextToken: string | undefined;
|
||||
message: string;
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
errLog: (m: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!params.contextToken) {
|
||||
logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendMessageWeixin({ to: params.to, text: params.message, opts: {
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
contextToken: params.contextToken,
|
||||
}});
|
||||
logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
|
||||
} catch (err) {
|
||||
params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { logger } from "../util/logger.js";
|
||||
import { generateId } from "../util/random.js";
|
||||
import type { WeixinMessage, MessageItem } from "../api/types.js";
|
||||
import { MessageItemType } from "../api/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context token store (in-process cache: accountId+userId → contextToken)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* contextToken is issued per-message by the Weixin getupdates API and must
|
||||
* be echoed verbatim in every outbound send. It is not persisted: the monitor
|
||||
* loop populates this map on each inbound message, and the outbound adapter
|
||||
* reads it back when the agent sends a reply.
|
||||
*/
|
||||
const contextTokenStore = new Map<string, string>();
|
||||
|
||||
function contextTokenKey(accountId: string, userId: string): string {
|
||||
return `${accountId}:${userId}`;
|
||||
}
|
||||
|
||||
/** Store a context token for a given account+user pair. */
|
||||
export function setContextToken(accountId: string, userId: string, token: string): void {
|
||||
const k = contextTokenKey(accountId, userId);
|
||||
logger.debug(`setContextToken: key=${k}`);
|
||||
contextTokenStore.set(k, token);
|
||||
}
|
||||
|
||||
/** Retrieve the cached context token for a given account+user pair. */
|
||||
export function getContextToken(accountId: string, userId: string): string | undefined {
|
||||
const k = contextTokenKey(accountId, userId);
|
||||
const val = contextTokenStore.get(k);
|
||||
logger.debug(
|
||||
`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
|
||||
);
|
||||
return val;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message ID generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateMessageSid(): string {
|
||||
return generateId("openclaw-weixin");
|
||||
}
|
||||
|
||||
/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */
|
||||
export type WeixinMsgContext = {
|
||||
Body: string;
|
||||
From: string;
|
||||
To: string;
|
||||
AccountId: string;
|
||||
OriginatingChannel: "openclaw-weixin";
|
||||
OriginatingTo: string;
|
||||
MessageSid: string;
|
||||
Timestamp?: number;
|
||||
Provider: "openclaw-weixin";
|
||||
ChatType: "direct";
|
||||
/** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */
|
||||
SessionKey?: string;
|
||||
context_token?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
/** Raw message body for framework command authorization. */
|
||||
CommandBody?: string;
|
||||
/** Whether the sender is authorized to execute slash commands. */
|
||||
CommandAuthorized?: boolean;
|
||||
};
|
||||
|
||||
/** Returns true if the message item is a media type (image, video, file, or voice). */
|
||||
export function isMediaItem(item: MessageItem): boolean {
|
||||
return (
|
||||
item.type === MessageItemType.IMAGE ||
|
||||
item.type === MessageItemType.VIDEO ||
|
||||
item.type === MessageItemType.FILE ||
|
||||
item.type === MessageItemType.VOICE
|
||||
);
|
||||
}
|
||||
|
||||
function bodyFromItemList(itemList?: MessageItem[]): string {
|
||||
if (!itemList?.length) return "";
|
||||
for (const item of itemList) {
|
||||
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
|
||||
const text = String(item.text_item.text);
|
||||
const ref = item.ref_msg;
|
||||
if (!ref) return text;
|
||||
// Quoted media is passed as MediaPath; only include the current text as body.
|
||||
if (ref.message_item && isMediaItem(ref.message_item)) return text;
|
||||
// Build quoted context from both title and message_item content.
|
||||
const parts: string[] = [];
|
||||
if (ref.title) parts.push(ref.title);
|
||||
if (ref.message_item) {
|
||||
const refBody = bodyFromItemList([ref.message_item]);
|
||||
if (refBody) parts.push(refBody);
|
||||
}
|
||||
if (!parts.length) return text;
|
||||
return `[引用: ${parts.join(" | ")}]\n${text}`;
|
||||
}
|
||||
// 语音转文字:如果语音消息有 text 字段,直接使用文字内容
|
||||
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
|
||||
return item.voice_item.text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export type WeixinInboundMediaOpts = {
|
||||
/** Local path to decrypted image file. */
|
||||
decryptedPicPath?: string;
|
||||
/** Local path to transcoded/raw voice file (.wav or .silk). */
|
||||
decryptedVoicePath?: string;
|
||||
/** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */
|
||||
voiceMediaType?: string;
|
||||
/** Local path to decrypted file attachment. */
|
||||
decryptedFilePath?: string;
|
||||
/** MIME type for the file attachment (guessed from file_name). */
|
||||
fileMediaType?: string;
|
||||
/** Local path to decrypted video file. */
|
||||
decryptedVideoPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
|
||||
* Media: only pass MediaPath (local file, after CDN download + decrypt).
|
||||
* We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
|
||||
* Priority when multiple media types present: image > video > file > voice.
|
||||
*/
|
||||
export function weixinMessageToMsgContext(
|
||||
msg: WeixinMessage,
|
||||
accountId: string,
|
||||
opts?: WeixinInboundMediaOpts,
|
||||
): WeixinMsgContext {
|
||||
const from_user_id = msg.from_user_id ?? "";
|
||||
const ctx: WeixinMsgContext = {
|
||||
Body: bodyFromItemList(msg.item_list),
|
||||
From: from_user_id,
|
||||
To: from_user_id,
|
||||
AccountId: accountId,
|
||||
OriginatingChannel: "openclaw-weixin",
|
||||
OriginatingTo: from_user_id,
|
||||
MessageSid: generateMessageSid(),
|
||||
Timestamp: msg.create_time_ms,
|
||||
Provider: "openclaw-weixin",
|
||||
ChatType: "direct",
|
||||
};
|
||||
if (msg.context_token) {
|
||||
ctx.context_token = msg.context_token;
|
||||
}
|
||||
|
||||
if (opts?.decryptedPicPath) {
|
||||
ctx.MediaPath = opts.decryptedPicPath;
|
||||
ctx.MediaType = "image/*";
|
||||
} else if (opts?.decryptedVideoPath) {
|
||||
ctx.MediaPath = opts.decryptedVideoPath;
|
||||
ctx.MediaType = "video/mp4";
|
||||
} else if (opts?.decryptedFilePath) {
|
||||
ctx.MediaPath = opts.decryptedFilePath;
|
||||
ctx.MediaType = opts.fileMediaType ?? "application/octet-stream";
|
||||
} else if (opts?.decryptedVoicePath) {
|
||||
ctx.MediaPath = opts.decryptedVoicePath;
|
||||
ctx.MediaType = opts.voiceMediaType ?? "audio/wav";
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Extract the context_token from an inbound WeixinMsgContext. */
|
||||
export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {
|
||||
return ctx.context_token;
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createTypingCallbacks,
|
||||
resolveSenderCommandAuthorizationWithRuntime,
|
||||
resolveDirectDmAuthorizationOutcome,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
import { sendTyping } from "../api/api.js";
|
||||
import type { WeixinMessage } from "../api/types.js";
|
||||
import { MessageItemType, TypingStatus } from "../api/types.js";
|
||||
import { loadWeixinAccount } from "../auth/accounts.js";
|
||||
import { readFrameworkAllowFromList } from "../auth/pairing.js";
|
||||
import { downloadRemoteImageToTemp } from "../cdn/upload.js";
|
||||
import { downloadMediaFromItem } from "../media/media-download.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { redactBody, redactToken } from "../util/redact.js";
|
||||
|
||||
import { isDebugMode } from "./debug-mode.js";
|
||||
import { sendWeixinErrorNotice } from "./error-notice.js";
|
||||
import {
|
||||
setContextToken,
|
||||
weixinMessageToMsgContext,
|
||||
getContextTokenFromMsgContext,
|
||||
isMediaItem,
|
||||
} from "./inbound.js";
|
||||
import type { WeixinInboundMediaOpts } from "./inbound.js";
|
||||
import { sendWeixinMediaFile } from "./send-media.js";
|
||||
import { markdownToPlainText, sendMessageWeixin } from "./send.js";
|
||||
import { handleSlashCommand } from "./slash-commands.js";
|
||||
|
||||
const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
|
||||
|
||||
/** Dependencies for processOneMessage, injected by the monitor loop. */
|
||||
export type ProcessMessageDeps = {
|
||||
accountId: string;
|
||||
config: import("openclaw/plugin-sdk/core").OpenClawConfig;
|
||||
channelRuntime: PluginRuntime["channel"];
|
||||
baseUrl: string;
|
||||
cdnBaseUrl: string;
|
||||
token?: string;
|
||||
typingTicket?: string;
|
||||
log: (msg: string) => void;
|
||||
errLog: (m: string) => void;
|
||||
};
|
||||
|
||||
/** Extract text body from item_list (for slash command detection). */
|
||||
function extractTextBody(itemList?: import("../api/types.js").MessageItem[]): string {
|
||||
if (!itemList?.length) return "";
|
||||
for (const item of itemList) {
|
||||
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
|
||||
return String(item.text_item.text);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single inbound message: route → download media → dispatch reply.
|
||||
* Extracted from the monitor loop to keep monitoring and message handling separate.
|
||||
*/
|
||||
export async function processOneMessage(
|
||||
full: WeixinMessage,
|
||||
deps: ProcessMessageDeps,
|
||||
): Promise<void> {
|
||||
if (!deps?.channelRuntime) {
|
||||
logger.error(
|
||||
`processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`,
|
||||
);
|
||||
deps.errLog("processOneMessage: channelRuntime is undefined, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const receivedAt = Date.now();
|
||||
const debug = isDebugMode(deps.accountId);
|
||||
const debugTrace: string[] = [];
|
||||
const debugTs: Record<string, number> = { received: receivedAt };
|
||||
|
||||
const textBody = extractTextBody(full.item_list);
|
||||
if (textBody.startsWith("/")) {
|
||||
const slashResult = await handleSlashCommand(textBody, {
|
||||
to: full.from_user_id ?? "",
|
||||
contextToken: full.context_token,
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
accountId: deps.accountId,
|
||||
log: deps.log,
|
||||
errLog: deps.errLog,
|
||||
}, receivedAt, full.create_time_ms);
|
||||
if (slashResult.handled) {
|
||||
logger.info(`[weixin] Slash command handled, skipping AI pipeline`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none";
|
||||
debugTrace.push(
|
||||
"── 收消息 ──",
|
||||
`│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`,
|
||||
`│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`,
|
||||
`│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const mediaOpts: WeixinInboundMediaOpts = {};
|
||||
|
||||
// Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
|
||||
// When none found in the main item_list, fall back to media referenced via a quoted message.
|
||||
const mainMediaItem =
|
||||
full.item_list?.find(
|
||||
(i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param,
|
||||
) ??
|
||||
full.item_list?.find(
|
||||
(i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param,
|
||||
) ??
|
||||
full.item_list?.find(
|
||||
(i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param,
|
||||
) ??
|
||||
full.item_list?.find(
|
||||
(i) =>
|
||||
i.type === MessageItemType.VOICE &&
|
||||
i.voice_item?.media?.encrypt_query_param &&
|
||||
!i.voice_item.text,
|
||||
);
|
||||
const refMediaItem = !mainMediaItem
|
||||
? full.item_list?.find(
|
||||
(i) =>
|
||||
i.type === MessageItemType.TEXT &&
|
||||
i.ref_msg?.message_item &&
|
||||
isMediaItem(i.ref_msg.message_item!),
|
||||
)?.ref_msg?.message_item
|
||||
: undefined;
|
||||
|
||||
const mediaDownloadStart = Date.now();
|
||||
const mediaItem = mainMediaItem ?? refMediaItem;
|
||||
if (mediaItem) {
|
||||
const label = refMediaItem ? "ref" : "inbound";
|
||||
const downloaded = await downloadMediaFromItem(mediaItem, {
|
||||
cdnBaseUrl: deps.cdnBaseUrl,
|
||||
saveMedia: deps.channelRuntime.media.saveMediaBuffer,
|
||||
log: deps.log,
|
||||
errLog: deps.errLog,
|
||||
label,
|
||||
});
|
||||
Object.assign(mediaOpts, downloaded);
|
||||
}
|
||||
const mediaDownloadMs = Date.now() - mediaDownloadStart;
|
||||
|
||||
if (debug) {
|
||||
debugTrace.push(mediaItem
|
||||
? `│ mediaDownload: type=${mediaItem.type} cost=${mediaDownloadMs}ms`
|
||||
: "│ mediaDownload: none",
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = weixinMessageToMsgContext(full, deps.accountId, mediaOpts);
|
||||
|
||||
// --- Framework command authorization ---
|
||||
const rawBody = ctx.Body?.trim() ?? "";
|
||||
ctx.CommandBody = rawBody;
|
||||
|
||||
const senderId = full.from_user_id ?? "";
|
||||
|
||||
const { senderAllowedForCommands, commandAuthorized } =
|
||||
await resolveSenderCommandAuthorizationWithRuntime({
|
||||
cfg: deps.config,
|
||||
rawBody,
|
||||
isGroup: false,
|
||||
dmPolicy: "pairing",
|
||||
configuredAllowFrom: [],
|
||||
configuredGroupAllowFrom: [],
|
||||
senderId,
|
||||
isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
|
||||
/** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */
|
||||
readAllowFromStore: async () => {
|
||||
const fromStore = readFrameworkAllowFromList(deps.accountId);
|
||||
if (fromStore.length > 0) return fromStore;
|
||||
const uid = loadWeixinAccount(deps.accountId)?.userId?.trim();
|
||||
return uid ? [uid] : [];
|
||||
},
|
||||
runtime: deps.channelRuntime.commands,
|
||||
});
|
||||
|
||||
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
|
||||
isGroup: false,
|
||||
dmPolicy: "pairing",
|
||||
senderAllowedForCommands,
|
||||
});
|
||||
|
||||
if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
|
||||
logger.info(
|
||||
`authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.CommandAuthorized = commandAuthorized;
|
||||
logger.debug(
|
||||
`authorization: senderId=${senderId} commandAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
debugTrace.push(
|
||||
"── 鉴权 & 路由 ──",
|
||||
`│ auth: cmdAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const route = deps.channelRuntime.routing.resolveAgentRoute({
|
||||
cfg: deps.config,
|
||||
channel: "openclaw-weixin",
|
||||
accountId: deps.accountId,
|
||||
peer: { kind: "direct", id: ctx.To },
|
||||
});
|
||||
logger.debug(
|
||||
`resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
|
||||
);
|
||||
if (!route.agentId) {
|
||||
logger.error(
|
||||
`resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
debugTrace.push(
|
||||
`│ route: agent=${route.agentId ?? "none"} session=${route.sessionKey ?? "none"}`,
|
||||
);
|
||||
debugTs.preDispatch = Date.now();
|
||||
}
|
||||
// Propagate the resolved session key into ctx so dispatchReplyFromConfig uses
|
||||
// the correct session (matching the dmScope from config) instead of falling back
|
||||
// to agent:main:main.
|
||||
ctx.SessionKey = route.sessionKey;
|
||||
const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const finalized = deps.channelRuntime.reply.finalizeInboundContext(
|
||||
ctx as Parameters<typeof deps.channelRuntime.reply.finalizeInboundContext>[0],
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length} hasMedia=${Boolean(finalized.MediaPath ?? finalized.MediaUrl)}`,
|
||||
);
|
||||
logger.debug(`inbound context: ${redactBody(JSON.stringify(finalized))}`);
|
||||
|
||||
await deps.channelRuntime.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"],
|
||||
updateLastRoute: {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "openclaw-weixin",
|
||||
to: ctx.To,
|
||||
accountId: deps.accountId,
|
||||
},
|
||||
onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
|
||||
});
|
||||
logger.debug(
|
||||
`recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`,
|
||||
);
|
||||
|
||||
const contextToken = getContextTokenFromMsgContext(ctx);
|
||||
if (contextToken) {
|
||||
setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
|
||||
}
|
||||
const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
|
||||
|
||||
const hasTypingTicket = Boolean(deps.typingTicket);
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: hasTypingTicket
|
||||
? () =>
|
||||
sendTyping({
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
body: {
|
||||
ilink_user_id: ctx.To,
|
||||
typing_ticket: deps.typingTicket!,
|
||||
status: TypingStatus.TYPING,
|
||||
},
|
||||
})
|
||||
: async () => {},
|
||||
stop: hasTypingTicket
|
||||
? () =>
|
||||
sendTyping({
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
body: {
|
||||
ilink_user_id: ctx.To,
|
||||
typing_ticket: deps.typingTicket!,
|
||||
status: TypingStatus.CANCEL,
|
||||
},
|
||||
})
|
||||
: async () => {},
|
||||
onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`),
|
||||
onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`),
|
||||
keepaliveIntervalMs: 5000,
|
||||
});
|
||||
|
||||
/** Delivery records populated synchronously at deliver() entry, safe to read in finally. */
|
||||
const debugDeliveries: Array<{ textLen: number; media: string; preview: string; ts: number }> = [];
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
deps.channelRuntime.reply.createReplyDispatcherWithTyping({
|
||||
humanDelay,
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
const text = markdownToPlainText(payload.text ?? "");
|
||||
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
||||
logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
|
||||
logger.info(
|
||||
`outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
debugDeliveries.push({
|
||||
textLen: text.length,
|
||||
media: mediaUrl ? "present" : "none",
|
||||
preview: `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (mediaUrl) {
|
||||
let filePath: string;
|
||||
if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
|
||||
// Local path: absolute, relative, or file:// URL
|
||||
if (mediaUrl.startsWith("file://")) {
|
||||
filePath = new URL(mediaUrl).pathname;
|
||||
} else if (!path.isAbsolute(mediaUrl)) {
|
||||
filePath = path.resolve(mediaUrl);
|
||||
logger.debug(`outbound: resolved relative path ${mediaUrl} -> ${filePath}`);
|
||||
} else {
|
||||
filePath = mediaUrl;
|
||||
}
|
||||
logger.debug(`outbound: local file path resolved filePath=${filePath}`);
|
||||
} else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
|
||||
logger.debug(`outbound: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
|
||||
filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
|
||||
logger.debug(`outbound: remote image downloaded to filePath=${filePath}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`outbound: unrecognized mediaUrl scheme, sending text only mediaUrl=${mediaUrl.slice(0, 80)}`,
|
||||
);
|
||||
await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
}});
|
||||
logger.info(`outbound: text sent to=${ctx.To}`);
|
||||
return;
|
||||
}
|
||||
await sendWeixinMediaFile({
|
||||
filePath,
|
||||
to: ctx.To,
|
||||
text,
|
||||
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
|
||||
cdnBaseUrl: deps.cdnBaseUrl,
|
||||
});
|
||||
logger.info(`outbound: media sent OK to=${ctx.To}`);
|
||||
} else {
|
||||
logger.debug(`outbound: sending text message to=${ctx.To}`);
|
||||
await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
}});
|
||||
logger.info(`outbound: text sent OK to=${ctx.To}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
let notice: string;
|
||||
if (errMsg.includes("contextToken is required")) {
|
||||
// No contextToken means we cannot send a notice either; just log.
|
||||
logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`);
|
||||
return;
|
||||
} else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
|
||||
notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
|
||||
} else if (
|
||||
errMsg.includes("getUploadUrl") ||
|
||||
errMsg.includes("CDN upload") ||
|
||||
errMsg.includes("upload_param")
|
||||
) {
|
||||
notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
|
||||
} else {
|
||||
notice = `⚠️ 消息发送失败:${errMsg}`;
|
||||
}
|
||||
void sendWeixinErrorNotice({
|
||||
to: ctx.To,
|
||||
contextToken,
|
||||
message: notice,
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
errLog: deps.errLog,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);
|
||||
try {
|
||||
await deps.channelRuntime.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
run: () =>
|
||||
deps.channelRuntime.reply.dispatchReplyFromConfig({
|
||||
ctx: finalized,
|
||||
cfg: deps.config,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
});
|
||||
logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`dispatchReplyFromConfig: error agentId=${route.agentId ?? "(none)"} err=${String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
markDispatchIdle();
|
||||
|
||||
logger.info(
|
||||
`debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)} stateDir=${process.env.OPENCLAW_STATE_DIR ?? "(unset)"}`,
|
||||
);
|
||||
|
||||
if (debug && contextToken) {
|
||||
const dispatchDoneAt = Date.now();
|
||||
const eventTs = full.create_time_ms ?? 0;
|
||||
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
|
||||
const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
|
||||
const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
|
||||
const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`;
|
||||
|
||||
if (debugDeliveries.length > 0) {
|
||||
debugTrace.push("── 回复 ──");
|
||||
for (const d of debugDeliveries) {
|
||||
debugTrace.push(
|
||||
`│ textLen=${d.textLen} media=${d.media}`,
|
||||
`│ text="${d.preview}"`,
|
||||
);
|
||||
}
|
||||
const firstTs = debugDeliveries[0].ts;
|
||||
debugTrace.push(`│ deliver耗时: ${dispatchDoneAt - firstTs}ms`);
|
||||
} else {
|
||||
debugTrace.push("── 回复 ──", "│ (deliver未捕获)");
|
||||
}
|
||||
|
||||
debugTrace.push(
|
||||
"── 耗时 ──",
|
||||
`├ 平台→插件: ${platformDelay}`,
|
||||
`├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`,
|
||||
`├ AI生成+回复: ${aiMs}ms`,
|
||||
`├ 总耗时: ${totalTime}`,
|
||||
`└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
|
||||
);
|
||||
|
||||
const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
|
||||
|
||||
logger.info(`debug-timing: sending to=${ctx.To}`);
|
||||
try {
|
||||
await sendMessageWeixin({
|
||||
to: ctx.To,
|
||||
text: timingText,
|
||||
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
|
||||
});
|
||||
logger.info(`debug-timing: sent OK`);
|
||||
} catch (debugErr) {
|
||||
logger.error(`debug-timing: send FAILED err=${String(debugErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import path from "node:path";
|
||||
import type { WeixinApiOptions } from "../api/api.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { getMimeFromFilename } from "../media/mime.js";
|
||||
import { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from "./send.js";
|
||||
import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from "../cdn/upload.js";
|
||||
|
||||
/**
|
||||
* Upload a local file and send it as a weixin message, routing by MIME type:
|
||||
* video/* → uploadVideoToWeixin + sendVideoMessageWeixin
|
||||
* image/* → uploadFileToWeixin + sendImageMessageWeixin
|
||||
* else → uploadFileAttachmentToWeixin + sendFileMessageWeixin
|
||||
*
|
||||
* Used by both the auto-reply deliver path (monitor.ts) and the outbound
|
||||
* sendMedia path (channel.ts) so they stay in sync.
|
||||
*/
|
||||
export async function sendWeixinMediaFile(params: {
|
||||
filePath: string;
|
||||
to: string;
|
||||
text: string;
|
||||
opts: WeixinApiOptions & { contextToken?: string };
|
||||
cdnBaseUrl: string;
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { filePath, to, text, opts, cdnBaseUrl } = params;
|
||||
const mime = getMimeFromFilename(filePath);
|
||||
const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
|
||||
const uploaded = await uploadVideoToWeixin({
|
||||
filePath,
|
||||
toUserId: to,
|
||||
opts: uploadOpts,
|
||||
cdnBaseUrl,
|
||||
});
|
||||
logger.info(
|
||||
`[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
||||
);
|
||||
return sendVideoMessageWeixin({ to, text, uploaded, opts });
|
||||
}
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`);
|
||||
const uploaded = await uploadFileToWeixin({
|
||||
filePath,
|
||||
toUserId: to,
|
||||
opts: uploadOpts,
|
||||
cdnBaseUrl,
|
||||
});
|
||||
logger.info(
|
||||
`[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
||||
);
|
||||
return sendImageMessageWeixin({ to, text, uploaded, opts });
|
||||
}
|
||||
|
||||
// File attachment: pdf, doc, zip, etc.
|
||||
const fileName = path.basename(filePath);
|
||||
logger.info(
|
||||
`[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`,
|
||||
);
|
||||
const uploaded = await uploadFileAttachmentToWeixin({
|
||||
filePath,
|
||||
fileName,
|
||||
toUserId: to,
|
||||
opts: uploadOpts,
|
||||
cdnBaseUrl,
|
||||
});
|
||||
logger.info(
|
||||
`[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
||||
);
|
||||
return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk";
|
||||
|
||||
import { sendMessage as sendMessageApi } from "../api/api.js";
|
||||
import type { WeixinApiOptions } from "../api/api.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import { generateId } from "../util/random.js";
|
||||
import type { MessageItem, SendMessageReq } from "../api/types.js";
|
||||
import { MessageItemType, MessageState, MessageType } from "../api/types.js";
|
||||
import type { UploadedFileInfo } from "../cdn/upload.js";
|
||||
|
||||
function generateClientId(): string {
|
||||
return generateId("openclaw-weixin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown-formatted model reply to plain text for Weixin delivery.
|
||||
* Preserves newlines; strips markdown syntax.
|
||||
*/
|
||||
export function markdownToPlainText(text: string): string {
|
||||
let result = text;
|
||||
// Code blocks: strip fences, keep code content
|
||||
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
|
||||
// Images: remove entirely
|
||||
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
|
||||
// Links: keep display text only
|
||||
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
||||
// Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces
|
||||
result = result.replace(/^\|[\s:|-]+\|$/gm, "");
|
||||
result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
|
||||
inner.split("|").map((cell) => cell.trim()).join(" "),
|
||||
);
|
||||
result = stripMarkdown(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Build a SendMessageReq containing a single text message. */
|
||||
function buildTextMessageReq(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
contextToken?: string;
|
||||
clientId: string;
|
||||
}): SendMessageReq {
|
||||
const { to, text, contextToken, clientId } = params;
|
||||
const item_list: MessageItem[] = text
|
||||
? [{ type: MessageItemType.TEXT, text_item: { text } }]
|
||||
: [];
|
||||
return {
|
||||
msg: {
|
||||
from_user_id: "",
|
||||
to_user_id: to,
|
||||
client_id: clientId,
|
||||
message_type: MessageType.BOT,
|
||||
message_state: MessageState.FINISH,
|
||||
item_list: item_list.length ? item_list : undefined,
|
||||
context_token: contextToken ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */
|
||||
function buildSendMessageReq(params: {
|
||||
to: string;
|
||||
contextToken?: string;
|
||||
payload: ReplyPayload;
|
||||
clientId: string;
|
||||
}): SendMessageReq {
|
||||
const { to, contextToken, payload, clientId } = params;
|
||||
return buildTextMessageReq({
|
||||
to,
|
||||
text: payload.text ?? "",
|
||||
contextToken,
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a plain text message downstream.
|
||||
* contextToken is required for all reply sends; missing it breaks conversation association.
|
||||
*/
|
||||
export async function sendMessageWeixin(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
opts: WeixinApiOptions & { contextToken?: string };
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { to, text, opts } = params;
|
||||
if (!opts.contextToken) {
|
||||
logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
|
||||
throw new Error("sendMessageWeixin: contextToken is required");
|
||||
}
|
||||
const clientId = generateClientId();
|
||||
const req = buildSendMessageReq({
|
||||
to,
|
||||
contextToken: opts.contextToken,
|
||||
payload: { text },
|
||||
clientId,
|
||||
});
|
||||
try {
|
||||
await sendMessageApi({
|
||||
baseUrl: opts.baseUrl,
|
||||
token: opts.token,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
body: req,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one or more MessageItems (optionally preceded by a text caption) downstream.
|
||||
* Each item is sent as its own request so that item_list always has exactly one entry.
|
||||
*/
|
||||
async function sendMediaItems(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
mediaItem: MessageItem;
|
||||
opts: WeixinApiOptions & { contextToken?: string };
|
||||
label: string;
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { to, text, mediaItem, opts, label } = params;
|
||||
|
||||
const items: MessageItem[] = [];
|
||||
if (text) {
|
||||
items.push({ type: MessageItemType.TEXT, text_item: { text } });
|
||||
}
|
||||
items.push(mediaItem);
|
||||
|
||||
let lastClientId = "";
|
||||
for (const item of items) {
|
||||
lastClientId = generateClientId();
|
||||
const req: SendMessageReq = {
|
||||
msg: {
|
||||
from_user_id: "",
|
||||
to_user_id: to,
|
||||
client_id: lastClientId,
|
||||
message_type: MessageType.BOT,
|
||||
message_state: MessageState.FINISH,
|
||||
item_list: [item],
|
||||
context_token: opts.contextToken ?? undefined,
|
||||
},
|
||||
};
|
||||
try {
|
||||
await sendMessageApi({
|
||||
baseUrl: opts.baseUrl,
|
||||
token: opts.token,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
body: req,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);
|
||||
return { messageId: lastClientId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an image message downstream using a previously uploaded file.
|
||||
* Optionally include a text caption as a separate TEXT item before the image.
|
||||
*
|
||||
* ImageItem fields:
|
||||
* - media.encrypt_query_param: CDN download param
|
||||
* - media.aes_key: AES key, base64-encoded
|
||||
* - mid_size: original ciphertext file size
|
||||
*/
|
||||
export async function sendImageMessageWeixin(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
uploaded: UploadedFileInfo;
|
||||
opts: WeixinApiOptions & { contextToken?: string };
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { to, text, uploaded, opts } = params;
|
||||
if (!opts.contextToken) {
|
||||
logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);
|
||||
throw new Error("sendImageMessageWeixin: contextToken is required");
|
||||
}
|
||||
logger.debug(
|
||||
`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
|
||||
);
|
||||
|
||||
const imageItem: MessageItem = {
|
||||
type: MessageItemType.IMAGE,
|
||||
image_item: {
|
||||
media: {
|
||||
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
||||
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
mid_size: uploaded.fileSizeCiphertext,
|
||||
},
|
||||
};
|
||||
|
||||
return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a video message downstream using a previously uploaded file.
|
||||
* VideoItem: media (CDN ref), video_size (ciphertext bytes).
|
||||
* Includes an optional text caption sent as a separate TEXT item first.
|
||||
*/
|
||||
export async function sendVideoMessageWeixin(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
uploaded: UploadedFileInfo;
|
||||
opts: WeixinApiOptions & { contextToken?: string };
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { to, text, uploaded, opts } = params;
|
||||
if (!opts.contextToken) {
|
||||
logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);
|
||||
throw new Error("sendVideoMessageWeixin: contextToken is required");
|
||||
}
|
||||
|
||||
const videoItem: MessageItem = {
|
||||
type: MessageItemType.VIDEO,
|
||||
video_item: {
|
||||
media: {
|
||||
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
||||
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
video_size: uploaded.fileSizeCiphertext,
|
||||
},
|
||||
};
|
||||
|
||||
return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file attachment downstream using a previously uploaded file.
|
||||
* FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
|
||||
* Includes an optional text caption sent as a separate TEXT item first.
|
||||
*/
|
||||
export async function sendFileMessageWeixin(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
fileName: string;
|
||||
uploaded: UploadedFileInfo;
|
||||
opts: WeixinApiOptions & { contextToken?: string };
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { to, text, fileName, uploaded, opts } = params;
|
||||
if (!opts.contextToken) {
|
||||
logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);
|
||||
throw new Error("sendFileMessageWeixin: contextToken is required");
|
||||
}
|
||||
const fileItem: MessageItem = {
|
||||
type: MessageItemType.FILE,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
||||
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: fileName,
|
||||
len: String(uploaded.fileSize),
|
||||
},
|
||||
};
|
||||
|
||||
return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Weixin 斜杠指令处理模块
|
||||
*
|
||||
* 支持的指令:
|
||||
* - /echo <message> 直接回复消息(不经过 AI),并附带通道耗时统计
|
||||
* - /toggle-debug 开关 debug 模式,启用后每条 AI 回复追加全链路耗时
|
||||
*/
|
||||
import type { WeixinApiOptions } from "../api/api.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
|
||||
import { toggleDebugMode, isDebugMode } from "./debug-mode.js";
|
||||
import { sendMessageWeixin } from "./send.js";
|
||||
|
||||
export interface SlashCommandResult {
|
||||
/** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */
|
||||
handled: boolean;
|
||||
}
|
||||
|
||||
export interface SlashCommandContext {
|
||||
to: string;
|
||||
contextToken?: string;
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
accountId: string;
|
||||
log: (msg: string) => void;
|
||||
errLog: (msg: string) => void;
|
||||
}
|
||||
|
||||
/** 发送回复消息 */
|
||||
async function sendReply(ctx: SlashCommandContext, text: string): Promise<void> {
|
||||
const opts: WeixinApiOptions & { contextToken?: string } = {
|
||||
baseUrl: ctx.baseUrl,
|
||||
token: ctx.token,
|
||||
contextToken: ctx.contextToken,
|
||||
};
|
||||
await sendMessageWeixin({ to: ctx.to, text, opts });
|
||||
}
|
||||
|
||||
/** 处理 /echo 指令 */
|
||||
async function handleEcho(
|
||||
ctx: SlashCommandContext,
|
||||
args: string,
|
||||
receivedAt: number,
|
||||
eventTimestamp?: number,
|
||||
): Promise<void> {
|
||||
const message = args.trim();
|
||||
if (message) {
|
||||
await sendReply(ctx, message);
|
||||
}
|
||||
const eventTs = eventTimestamp ?? 0;
|
||||
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
|
||||
const timing = [
|
||||
"⏱ 通道耗时",
|
||||
`├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
|
||||
`├ 平台→插件: ${platformDelay}`,
|
||||
`└ 插件处理: ${Date.now() - receivedAt}ms`,
|
||||
].join("\n");
|
||||
await sendReply(ctx, timing);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试处理斜杠指令
|
||||
*
|
||||
* @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道
|
||||
*/
|
||||
export async function handleSlashCommand(
|
||||
content: string,
|
||||
ctx: SlashCommandContext,
|
||||
receivedAt: number,
|
||||
eventTimestamp?: number,
|
||||
): Promise<SlashCommandResult> {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const spaceIdx = trimmed.indexOf(" ");
|
||||
const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
|
||||
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
|
||||
|
||||
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "/echo":
|
||||
await handleEcho(ctx, args, receivedAt, eventTimestamp);
|
||||
return { handled: true };
|
||||
case "/toggle-debug": {
|
||||
const enabled = toggleDebugMode(ctx.accountId);
|
||||
await sendReply(
|
||||
ctx,
|
||||
enabled
|
||||
? "Debug 模式已开启"
|
||||
: "Debug 模式已关闭",
|
||||
);
|
||||
return { handled: true };
|
||||
}
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[weixin] Slash command error: ${String(err)}`);
|
||||
try {
|
||||
await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
|
||||
} catch {
|
||||
// 发送错误消息也失败了,只能记日志
|
||||
}
|
||||
return { handled: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ChannelAccountSnapshot, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
import { getUpdates } from "../api/api.js";
|
||||
import { WeixinConfigManager } from "../api/config-cache.js";
|
||||
import { SESSION_EXPIRED_ERRCODE, pauseSession, getRemainingPauseMs } from "../api/session-guard.js";
|
||||
import { processOneMessage } from "../messaging/process-message.js";
|
||||
import { getWeixinRuntime, waitForWeixinRuntime } from "../runtime.js";
|
||||
import { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js";
|
||||
import { logger } from "../util/logger.js";
|
||||
import type { Logger } from "../util/logger.js";
|
||||
import { redactBody } from "../util/redact.js";
|
||||
|
||||
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
const BACKOFF_DELAY_MS = 30_000;
|
||||
const RETRY_DELAY_MS = 2_000;
|
||||
|
||||
export type MonitorWeixinOpts = {
|
||||
baseUrl: string;
|
||||
cdnBaseUrl: string;
|
||||
token?: string;
|
||||
accountId: string;
|
||||
/** When non-empty, only messages whose from_user_id is in this list are processed. */
|
||||
allowFrom?: string[];
|
||||
config: import("openclaw/plugin-sdk/core").OpenClawConfig;
|
||||
runtime?: { log?: (msg: string) => void; error?: (msg: string) => void };
|
||||
abortSignal?: AbortSignal;
|
||||
longPollTimeoutMs?: number;
|
||||
/** Gateway status callback — called on each successful poll and inbound message. */
|
||||
setStatus?: (next: ChannelAccountSnapshot) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.
|
||||
* Runs until abort.
|
||||
*/
|
||||
export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
|
||||
const {
|
||||
baseUrl,
|
||||
cdnBaseUrl,
|
||||
token,
|
||||
accountId,
|
||||
config,
|
||||
abortSignal,
|
||||
longPollTimeoutMs,
|
||||
setStatus,
|
||||
} = opts;
|
||||
const log = opts.runtime?.log ?? (() => {});
|
||||
const errLog = opts.runtime?.error ?? ((m: string) => log(m));
|
||||
const aLog: Logger = logger.withAccount(accountId);
|
||||
|
||||
aLog.info(`waiting for Weixin runtime...`);
|
||||
let channelRuntime: PluginRuntime["channel"];
|
||||
try {
|
||||
const pluginRuntime = await waitForWeixinRuntime();
|
||||
channelRuntime = pluginRuntime.channel;
|
||||
aLog.info(`Weixin runtime acquired, channelRuntime type: ${typeof channelRuntime}`);
|
||||
} catch (err) {
|
||||
aLog.error(`waitForWeixinRuntime() failed: ${String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
log(`weixin monitor started (${baseUrl}, account=${accountId})`);
|
||||
aLog.info(
|
||||
`Monitor started: baseUrl=${baseUrl} timeoutMs=${longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS}`,
|
||||
);
|
||||
|
||||
const syncFilePath = getSyncBufFilePath(accountId);
|
||||
aLog.debug(`syncFilePath: ${syncFilePath}`);
|
||||
|
||||
const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
|
||||
let getUpdatesBuf = previousGetUpdatesBuf ?? "";
|
||||
|
||||
if (previousGetUpdatesBuf) {
|
||||
log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
|
||||
aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`);
|
||||
} else {
|
||||
log(`[weixin] no previous sync buf, starting fresh`);
|
||||
aLog.info(`No previous get_updates_buf found, starting fresh`);
|
||||
}
|
||||
|
||||
const configManager = new WeixinConfigManager({ baseUrl, token }, log);
|
||||
|
||||
let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
while (!abortSignal?.aborted) {
|
||||
try {
|
||||
aLog.debug(
|
||||
`getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`,
|
||||
);
|
||||
const resp = await getUpdates({
|
||||
baseUrl,
|
||||
token,
|
||||
get_updates_buf: getUpdatesBuf,
|
||||
timeoutMs: nextTimeoutMs,
|
||||
});
|
||||
aLog.debug(
|
||||
`getUpdates response: ret=${resp.ret}, msgs=${resp.msgs?.length ?? 0}, get_updates_buf_length=${resp.get_updates_buf?.length ?? 0}`,
|
||||
);
|
||||
|
||||
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
||||
nextTimeoutMs = resp.longpolling_timeout_ms;
|
||||
aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`);
|
||||
}
|
||||
const isApiError =
|
||||
(resp.ret !== undefined && resp.ret !== 0) ||
|
||||
(resp.errcode !== undefined && resp.errcode !== 0);
|
||||
if (isApiError) {
|
||||
const isSessionExpired =
|
||||
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
||||
|
||||
if (isSessionExpired) {
|
||||
pauseSession(accountId);
|
||||
const pauseMs = getRemainingPauseMs(accountId);
|
||||
errLog(
|
||||
`weixin getUpdates: session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing bot for ${Math.ceil(pauseMs / 60_000)} min`,
|
||||
);
|
||||
aLog.error(
|
||||
`getUpdates: session expired (errcode=${resp.errcode} ret=${resp.ret}), pausing all requests for ${Math.ceil(pauseMs / 60_000)} min`,
|
||||
);
|
||||
consecutiveFailures = 0;
|
||||
await sleep(pauseMs, abortSignal);
|
||||
continue;
|
||||
}
|
||||
|
||||
consecutiveFailures += 1;
|
||||
errLog(
|
||||
`weixin getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`,
|
||||
);
|
||||
aLog.error(
|
||||
`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg} response=${redactBody(JSON.stringify(resp))}`,
|
||||
);
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
errLog(
|
||||
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
||||
);
|
||||
aLog.error(
|
||||
`getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
||||
);
|
||||
consecutiveFailures = 0;
|
||||
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
||||
} else {
|
||||
await sleep(RETRY_DELAY_MS, abortSignal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
consecutiveFailures = 0;
|
||||
setStatus?.({ accountId, lastEventAt: Date.now() });
|
||||
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
|
||||
saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
|
||||
getUpdatesBuf = resp.get_updates_buf;
|
||||
aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`);
|
||||
}
|
||||
const list = resp.msgs ?? [];
|
||||
for (const full of list) {
|
||||
aLog.info(
|
||||
`inbound message: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`,
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
setStatus?.({ accountId, lastEventAt: now, lastInboundAt: now });
|
||||
|
||||
// allowFrom filtering is delegated to processOneMessage via the framework
|
||||
// authorization pipeline (resolveSenderCommandAuthorizationWithRuntime).
|
||||
|
||||
const fromUserId = full.from_user_id ?? "";
|
||||
const cachedConfig = await configManager.getForUser(fromUserId, full.context_token);
|
||||
|
||||
await processOneMessage(full, {
|
||||
accountId,
|
||||
config,
|
||||
channelRuntime,
|
||||
baseUrl,
|
||||
cdnBaseUrl,
|
||||
token,
|
||||
typingTicket: cachedConfig.typingTicket,
|
||||
log: opts.runtime?.log ?? (() => {}),
|
||||
errLog,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (abortSignal?.aborted) {
|
||||
aLog.info(`Monitor stopped (aborted)`);
|
||||
return;
|
||||
}
|
||||
consecutiveFailures += 1;
|
||||
errLog(
|
||||
`weixin getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}`,
|
||||
);
|
||||
aLog.error(`getUpdates error: ${String(err)}, stack=${(err as Error).stack}`);
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
errLog(
|
||||
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
||||
);
|
||||
aLog.error(
|
||||
`getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
||||
);
|
||||
consecutiveFailures = 0;
|
||||
await sleep(30_000, abortSignal);
|
||||
} else {
|
||||
await sleep(2000, abortSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
aLog.info(`Monitor ended`);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new Error("aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
import { logger } from "./util/logger.js";
|
||||
|
||||
let pluginRuntime: PluginRuntime | null = null;
|
||||
|
||||
export type PluginChannelRuntime = PluginRuntime["channel"];
|
||||
|
||||
/**
|
||||
* Sets the global Weixin runtime (called from plugin register).
|
||||
*/
|
||||
export function setWeixinRuntime(next: PluginRuntime): void {
|
||||
pluginRuntime = next;
|
||||
logger.info(`[runtime] setWeixinRuntime called, runtime set successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the global Weixin runtime (throws if not initialized).
|
||||
*/
|
||||
export function getWeixinRuntime(): PluginRuntime {
|
||||
if (!pluginRuntime) {
|
||||
throw new Error("Weixin runtime not initialized");
|
||||
}
|
||||
return pluginRuntime;
|
||||
}
|
||||
|
||||
const WAIT_INTERVAL_MS = 100;
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Waits for the Weixin runtime to be initialized (async polling).
|
||||
*/
|
||||
export async function waitForWeixinRuntime(
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<PluginRuntime> {
|
||||
const start = Date.now();
|
||||
while (!pluginRuntime) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("Weixin runtime initialization timeout");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
|
||||
}
|
||||
return pluginRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `PluginRuntime["channel"]` for the long-poll monitor.
|
||||
*
|
||||
* Prefer the gateway-injected `channelRuntime` on `ChannelGatewayContext` when present (avoids
|
||||
* races with the module-global from `register()`). Fall back to the global set by `setWeixinRuntime()`,
|
||||
* then to a short wait for legacy hosts.
|
||||
*/
|
||||
export async function resolveWeixinChannelRuntime(params: {
|
||||
channelRuntime?: PluginChannelRuntime;
|
||||
waitTimeoutMs?: number;
|
||||
}): Promise<PluginChannelRuntime> {
|
||||
if (params.channelRuntime) {
|
||||
logger.debug("[runtime] channelRuntime from gateway context");
|
||||
return params.channelRuntime;
|
||||
}
|
||||
if (pluginRuntime) {
|
||||
logger.debug("[runtime] channelRuntime from register() global");
|
||||
return pluginRuntime.channel;
|
||||
}
|
||||
logger.warn(
|
||||
"[runtime] no channelRuntime on ctx and no global runtime yet; waiting for register()",
|
||||
);
|
||||
const pr = await waitForWeixinRuntime(params.waitTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
return pr.channel;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/** Resolve the OpenClaw state directory (mirrors core logic in src/infra). */
|
||||
export function resolveStateDir(): string {
|
||||
return (
|
||||
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
||||
path.join(os.homedir(), ".openclaw")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { deriveRawAccountId } from "../auth/accounts.js";
|
||||
|
||||
import { resolveStateDir } from "./state-dir.js";
|
||||
|
||||
function resolveAccountsDir(): string {
|
||||
return path.join(resolveStateDir(), "openclaw-weixin", "accounts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the persistent get_updates_buf file for an account.
|
||||
* Stored alongside account data: ~/.openclaw/openclaw-weixin/accounts/{accountId}.sync.json
|
||||
*/
|
||||
export function getSyncBufFilePath(accountId: string): string {
|
||||
return path.join(resolveAccountsDir(), `${accountId}.sync.json`);
|
||||
}
|
||||
|
||||
/** Legacy single-account syncbuf (pre multi-account): `.openclaw-weixin-sync/default.json`. */
|
||||
function getLegacySyncBufDefaultJsonPath(): string {
|
||||
return path.join(
|
||||
resolveStateDir(),
|
||||
"agents",
|
||||
"default",
|
||||
"sessions",
|
||||
".openclaw-weixin-sync",
|
||||
"default.json",
|
||||
);
|
||||
}
|
||||
|
||||
export type SyncBufData = {
|
||||
get_updates_buf: string;
|
||||
};
|
||||
|
||||
function readSyncBufFile(filePath: string): string | undefined {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const data = JSON.parse(raw) as { get_updates_buf?: string };
|
||||
if (typeof data.get_updates_buf === "string") {
|
||||
return data.get_updates_buf;
|
||||
}
|
||||
} catch {
|
||||
// file not found or invalid
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted get_updates_buf.
|
||||
* Falls back in order:
|
||||
* 1. Primary path (normalized accountId, new installs)
|
||||
* 2. Compat path (raw accountId derived from pattern, old installs)
|
||||
* 3. Legacy single-account path (very old installs without multi-account support)
|
||||
*/
|
||||
export function loadGetUpdatesBuf(filePath: string): string | undefined {
|
||||
const value = readSyncBufFile(filePath);
|
||||
if (value !== undefined) return value;
|
||||
|
||||
// Compat: if given path uses a normalized accountId (e.g. "b0f5860fdecb-im-bot.sync.json"),
|
||||
// also try the old raw-ID filename (e.g. "b0f5860fdecb@im.bot.sync.json").
|
||||
const accountId = path.basename(filePath, ".sync.json");
|
||||
const rawId = deriveRawAccountId(accountId);
|
||||
if (rawId) {
|
||||
const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`);
|
||||
const compatValue = readSyncBufFile(compatPath);
|
||||
if (compatValue !== undefined) return compatValue;
|
||||
}
|
||||
|
||||
// Legacy fallback: old single-account installs stored syncbuf without accountId.
|
||||
return readSyncBufFile(getLegacySyncBufDefaultJsonPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist get_updates_buf. Creates parent dir if needed.
|
||||
*/
|
||||
export function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Plugin logger — writes JSON lines to the main openclaw log file:
|
||||
* /tmp/openclaw/openclaw-YYYY-MM-DD.log
|
||||
* Same file and format used by all other channels.
|
||||
*/
|
||||
|
||||
const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
|
||||
const SUBSYSTEM = "gateway/channels/openclaw-weixin";
|
||||
const RUNTIME = "node";
|
||||
const RUNTIME_VERSION = process.versions.node;
|
||||
const HOSTNAME = os.hostname() || "unknown";
|
||||
const PARENT_NAMES = ["openclaw"];
|
||||
|
||||
/** tslog-compatible level IDs (higher = more severe). */
|
||||
const LEVEL_IDS: Record<string, number> = {
|
||||
TRACE: 1,
|
||||
DEBUG: 2,
|
||||
INFO: 3,
|
||||
WARN: 4,
|
||||
ERROR: 5,
|
||||
FATAL: 6,
|
||||
};
|
||||
|
||||
const DEFAULT_LOG_LEVEL = "INFO";
|
||||
|
||||
function resolveMinLevel(): number {
|
||||
const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase();
|
||||
if (env && env in LEVEL_IDS) return LEVEL_IDS[env];
|
||||
return LEVEL_IDS[DEFAULT_LOG_LEVEL];
|
||||
}
|
||||
|
||||
let minLevelId = resolveMinLevel();
|
||||
|
||||
/** Dynamically change the minimum log level at runtime. */
|
||||
export function setLogLevel(level: string): void {
|
||||
const upper = level.toUpperCase();
|
||||
if (!(upper in LEVEL_IDS)) {
|
||||
throw new Error(`Invalid log level: ${level}. Valid levels: ${Object.keys(LEVEL_IDS).join(", ")}`);
|
||||
}
|
||||
minLevelId = LEVEL_IDS[upper];
|
||||
}
|
||||
|
||||
/** Shift a Date into local time so toISOString() renders local clock digits. */
|
||||
function toLocalISO(now: Date): string {
|
||||
const offsetMs = -now.getTimezoneOffset() * 60_000;
|
||||
const sign = offsetMs >= 0 ? "+" : "-";
|
||||
const abs = Math.abs(now.getTimezoneOffset());
|
||||
const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
||||
return new Date(now.getTime() + offsetMs).toISOString().replace("Z", offStr);
|
||||
}
|
||||
|
||||
function localDateKey(now: Date): string {
|
||||
return toLocalISO(now).slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveMainLogPath(): string {
|
||||
const dateKey = localDateKey(new Date());
|
||||
return path.join(MAIN_LOG_DIR, `openclaw-${dateKey}.log`);
|
||||
}
|
||||
|
||||
let logDirEnsured = false;
|
||||
|
||||
export type Logger = {
|
||||
info(message: string): void;
|
||||
debug(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string): void;
|
||||
/** Returns a child logger whose messages are prefixed with `[accountId]`. */
|
||||
withAccount(accountId: string): Logger;
|
||||
/** Returns the current main log file path. */
|
||||
getLogFilePath(): string;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
function buildLoggerName(accountId?: string): string {
|
||||
return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM;
|
||||
}
|
||||
|
||||
function writeLog(level: string, message: string, accountId?: string): void {
|
||||
const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;
|
||||
if (levelId < minLevelId) return;
|
||||
|
||||
const now = new Date();
|
||||
const loggerName = buildLoggerName(accountId);
|
||||
const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
|
||||
const entry = JSON.stringify({
|
||||
"0": loggerName,
|
||||
"1": prefixedMessage,
|
||||
_meta: {
|
||||
runtime: RUNTIME,
|
||||
runtimeVersion: RUNTIME_VERSION,
|
||||
hostname: HOSTNAME,
|
||||
name: loggerName,
|
||||
parentNames: PARENT_NAMES,
|
||||
date: now.toISOString(),
|
||||
logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
|
||||
logLevelName: level,
|
||||
},
|
||||
time: toLocalISO(now),
|
||||
});
|
||||
try {
|
||||
if (!logDirEnsured) {
|
||||
fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
|
||||
logDirEnsured = true;
|
||||
}
|
||||
fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
|
||||
} catch {
|
||||
// Best-effort; never block on logging failures.
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a logger instance, optionally bound to a specific account. */
|
||||
function createLogger(accountId?: string): Logger {
|
||||
return {
|
||||
info(message: string): void {
|
||||
writeLog("INFO", message, accountId);
|
||||
},
|
||||
debug(message: string): void {
|
||||
writeLog("DEBUG", message, accountId);
|
||||
},
|
||||
warn(message: string): void {
|
||||
writeLog("WARN", message, accountId);
|
||||
},
|
||||
error(message: string): void {
|
||||
writeLog("ERROR", message, accountId);
|
||||
},
|
||||
withAccount(id: string): Logger {
|
||||
return createLogger(id);
|
||||
},
|
||||
getLogFilePath(): string {
|
||||
return resolveMainLogPath();
|
||||
},
|
||||
close(): void {
|
||||
// No-op: appendFileSync has no persistent handle to close.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const logger: Logger = createLogger();
|
||||
@@ -0,0 +1,17 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Generate a prefixed unique ID using timestamp + crypto random bytes.
|
||||
* Format: `{prefix}:{timestamp}-{8-char hex}`
|
||||
*/
|
||||
export function generateId(prefix: string): string {
|
||||
return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporary file name with random suffix.
|
||||
* Format: `{prefix}-{timestamp}-{8-char hex}{ext}`
|
||||
*/
|
||||
export function tempFileName(prefix: string, ext: string): string {
|
||||
return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const DEFAULT_BODY_MAX_LEN = 200;
|
||||
const DEFAULT_TOKEN_PREFIX_LEN = 6;
|
||||
|
||||
/**
|
||||
* Truncate a string, appending a length indicator when trimmed.
|
||||
* Returns `""` for empty/undefined input.
|
||||
*/
|
||||
export function truncate(s: string | undefined, max: number): string {
|
||||
if (!s) return "";
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}…(len=${s.length})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact a token/secret: show only the first few chars + total length.
|
||||
* Returns `"(none)"` when absent.
|
||||
*/
|
||||
export function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN_PREFIX_LEN): string {
|
||||
if (!token) return "(none)";
|
||||
if (token.length <= prefixLen) return `****(len=${token.length})`;
|
||||
return `${token.slice(0, prefixLen)}…(len=${token.length})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a JSON body string to `maxLen` chars for safe logging.
|
||||
* Appends original length so the reader knows how much was dropped.
|
||||
*/
|
||||
export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string {
|
||||
if (!body) return "(empty)";
|
||||
if (body.length <= maxLen) return body;
|
||||
return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip query string (which often contains signatures/tokens) from a URL,
|
||||
* keeping only origin + pathname.
|
||||
*/
|
||||
export function redactUrl(rawUrl: string): string {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
const base = `${u.origin}${u.pathname}`;
|
||||
return u.search ? `${base}?<redacted>` : base;
|
||||
} catch {
|
||||
return truncate(rawUrl, 80);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
declare module "qrcode-terminal" {
|
||||
const qrcodeTerminal: {
|
||||
generate(
|
||||
text: string,
|
||||
options?: { small?: boolean },
|
||||
callback?: (qr: string) => void,
|
||||
): void;
|
||||
};
|
||||
export default qrcodeTerminal;
|
||||
}
|
||||
|
||||
declare module "fluent-ffmpeg" {
|
||||
interface FfmpegCommand {
|
||||
setFfmpegPath(path: string): FfmpegCommand;
|
||||
seekInput(time: number): FfmpegCommand;
|
||||
frames(n: number): FfmpegCommand;
|
||||
outputOptions(opts: string[]): FfmpegCommand;
|
||||
output(path: string): FfmpegCommand;
|
||||
on(event: "end", cb: () => void): FfmpegCommand;
|
||||
on(event: "error", cb: (err: Error) => void): FfmpegCommand;
|
||||
run(): void;
|
||||
}
|
||||
function ffmpeg(input: string): FfmpegCommand;
|
||||
export default ffmpeg;
|
||||
}
|
||||
Reference in New Issue
Block a user