� backup: 2026-03-24 04:00

This commit is contained in:
huan
2026-03-24 04:00:48 +08:00
parent 7e143d3ebc
commit 31786dee08
193 changed files with 73520 additions and 1915 deletions
@@ -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;
/** 缩略图明文 MD5IMAGE/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);
}
}
+25
View File
@@ -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;
}