Files
aios/fixes/patch_openclaw_weixin_hermes.py
T

2727 lines
113 KiB
Python
Executable File

#!/usr/bin/env python3
"""Patch openclaw-weixin so /hermes and real image ingress route to Hermes."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
DEFAULT_PLUGIN_ROOT = pathlib.Path(
"/var/lib/aios/openclaw-session/npm/projects/"
"tencent-weixin-openclaw-weixin-7783ac86ba/node_modules/"
"@tencent-weixin/openclaw-weixin"
)
TS_HERMES_HELPERS = r'''
const DEFAULT_HERMES_ROUTER_URL = "http://127.0.0.1:18791/internal/hermes/route";
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
function makeHermesRouteId(): string {
const now = new Date();
const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
return `wx-hermes-${stamp}-${randomBytes(4).toString("hex")}`;
}
function hermesRouterUrl(): string {
return (process.env.AIOS_HERMES_ROUTER_URL || DEFAULT_HERMES_ROUTER_URL).trim();
}
function maxHermesImageBytes(): number {
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_MAX_IMAGE_BYTES ?? "10485760"), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10485760;
}
const SUPPORTED_HERMES_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
function hermesImageMimeFromPath(filePath: string, fallback?: string): string {
const normalizedFallback = String(fallback ?? "").split(";", 1)[0].trim().toLowerCase();
if (normalizedFallback === "image/jpg") return "image/jpeg";
if (SUPPORTED_HERMES_IMAGE_MIME.has(normalizedFallback)) return normalizedFallback;
const lower = filePath.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".webp")) return "image/webp";
return "image/jpeg";
}
function localHermesImageToDataUrl(filePath: string, fallbackMime?: string): string | null {
const trimmed = String(filePath ?? "").trim();
if (!trimmed) return null;
try {
if (!existsSync(trimmed)) return null;
const stat = statSync(trimmed);
if (!stat.isFile() || stat.size <= 0 || stat.size > maxHermesImageBytes()) return null;
const mime = hermesImageMimeFromPath(trimmed, fallbackMime);
if (!mime.startsWith("image/")) return null;
return `data:${mime};base64,${readFileSync(trimmed).toString("base64")}`;
} catch (err) {
logger.warn(`[weixin] hermes local image read skipped err=${String(err).slice(0, 160)}`);
return null;
}
}
function normalizeHermesImageReference(value: unknown, fallbackMime?: string): string | null {
const text = String(value ?? "").trim();
if (!text) return null;
const lower = text.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("data:image/")) return text;
return localHermesImageToDataUrl(text, fallbackMime);
}
function collectHermesImageUrls(value: unknown, urls: string[], depth = 0, fallbackMime?: string): void {
if (depth > 6 || urls.length >= 4 || value == null) return;
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
collectHermesImageUrls(JSON.parse(trimmed), urls, depth + 1, fallbackMime);
return;
} catch {
// Keep treating the value as a plain string.
}
}
const url = normalizeHermesImageReference(trimmed, fallbackMime);
if (url && !urls.includes(url)) urls.push(url);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectHermesImageUrls(item, urls, depth + 1, fallbackMime);
return;
}
if (typeof value !== "object") return;
const obj = value as Record<string, unknown>;
const objectMime = String(obj.MediaType ?? obj.mediaType ?? obj.mimeType ?? obj.mimetype ?? fallbackMime ?? "");
for (const [key, item] of Object.entries(obj)) {
const normalized = key.replace(/-/g, "_").toLowerCase();
if (
["image_url", "imageurl", "image_urls", "media_url", "mediaurl", "media_urls", "pic_url", "picurl", "thumb_url", "thumburl", "file_url", "fileurl", "url"].includes(normalized) ||
["media_path", "mediapath", "image_path", "imagepath", "local_path", "localpath"].includes(normalized) ||
(normalized.includes("image") && (normalized.includes("url") || normalized.includes("path")))
) {
collectHermesImageUrls(item, urls, depth + 1, objectMime);
}
}
for (const key of ["image", "images", "media", "message", "msg", "event", "payload", "rawMessage", "raw_message", "inboundContext", "ctx"]) {
if (key in obj) collectHermesImageUrls(obj[key], urls, depth + 1, objectMime);
}
}
function hermesMessageType(ctx: SlashCommandContext): string {
const anyCtx = ctx as any;
const raw = String(anyCtx.messageType ?? anyCtx.msgType ?? anyCtx.type ?? anyCtx.message?.type ?? anyCtx.event?.type ?? "").toLowerCase();
const mediaType = String(anyCtx.mediaType ?? anyCtx.MediaType ?? anyCtx.inboundContext?.MediaType ?? "").toLowerCase();
if (raw.includes("image") || raw.includes("photo") || raw.includes("picture") || mediaType.startsWith("image/")) return "image";
return "text";
}
function hermesAutoRouteImagesEnabled(): boolean {
const value = String(process.env.AIOS_HERMES_AUTO_ROUTE_IMAGES ?? "true").trim().toLowerCase();
return !["0", "false", "no", "off"].includes(value);
}
function buildHermesImageUrls(ctx: SlashCommandContext): string[] {
const imageUrls: string[] = [];
collectHermesImageUrls(ctx as any, imageUrls);
return imageUrls;
}
function shouldAutoRouteHermesImage(ctx: SlashCommandContext): boolean {
if (!hermesAutoRouteImagesEnabled()) return false;
return buildHermesImageUrls(ctx).length > 0 || hermesMessageType(ctx) === "image";
}
async function handleHermes(
ctx: SlashCommandContext,
args: string,
receivedAt: number,
eventTimestamp?: number,
routeReason = "explicit_user_prefix",
): Promise<void> {
const userPrompt = String(args ?? "").trim();
const imageUrls = buildHermesImageUrls(ctx);
const messageType = imageUrls.length > 0 || hermesMessageType(ctx) === "image" ? "image" : "text";
const visionRequired = messageType === "image";
const imageSource = String((ctx as any).imageSource ?? "");
const imageFollowupReason = String((ctx as any).imageFollowupReason ?? "");
const imageFollowupStrategy = String((ctx as any).imageFollowupStrategy ?? "");
const quotedMessageId = String((ctx as any).quotedMessageId ?? "");
const visionMissingReason = visionRequired && imageUrls.length === 0
? (imageSource === "quoted_unresolved" ? "referenced_image_unresolved" : (imageFollowupReason || "missing_image_url"))
: "";
const effectivePrompt = userPrompt || (visionRequired ? "请识别这张图片的主要内容,并说明是否需要进一步处理。" : "Report the injected Hermes route metadata and current memory readback.");
const routeId = makeHermesRouteId();
logger.info(`[weixin] hermes route submit route_id=${routeId} route_target=hermes route_reason=${routeReason} channel=openclaw-weixin source=personal_wechat message_type=${messageType} image_url_count=${imageUrls.length} image_source=${imageSource || "none"} cache_strategy=${imageFollowupStrategy || "none"} quoted_message_id=${quotedMessageId || "none"} missing_reason=${visionMissingReason || "none"} accountId=${ctx.accountId} to=${ctx.to}`);
const failText = `ROUTE_TARGET=FAIL\nFAIL_REASON=Hermes unavailable or route not configured\nAIOS_ROUTE_ID=${routeId}`;
const payload = {
route_id: routeId,
route_target: "hermes",
route_reason: routeReason,
channel: "openclaw-weixin",
source: "personal_wechat",
user_prompt: effectivePrompt,
message_type: messageType,
vision_required: visionRequired,
image_urls: imageUrls,
image_url_count: imageUrls.length,
image_source: imageSource,
image_followup_reason: imageFollowupReason,
image_followup_strategy: imageFollowupStrategy,
quoted_message_id: quotedMessageId,
vision_missing_reason: visionMissingReason,
wechat_user_id: ctx.to,
account_id: ctx.accountId,
context_token_present: Boolean(ctx.contextToken),
received_at: receivedAt,
event_timestamp: eventTimestamp ?? null,
};
try {
const resp = await fetch(hermesRouterUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(120000),
});
let data: any = {};
try {
data = await resp.json();
} catch {
data = {};
}
if (!resp.ok || !data?.ok) {
logger.error(`[weixin] hermes route failed route_id=${routeId} status=${resp.status}`);
await sendReply(ctx, data?.reply_text || failText);
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
return;
}
await sendReply(ctx, String(data.reply_text || failText));
logger.info(`[weixin] hermes reply delivered route_id=${routeId} route_target=hermes executor=hermes message_type=${messageType} image_url_count=${imageUrls.length} memory_write_id=${data.memory_write_id || "none"} memory_read_id=${data.memory_read_id || "none"}`);
} catch (err) {
logger.error(`[weixin] hermes route exception route_id=${routeId} err=${String(err)}`);
await sendReply(ctx, failText);
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
}
}
export async function handleHermesImplicitImage(
content: string,
ctx: SlashCommandContext,
receivedAt: number,
eventTimestamp?: number,
): Promise<SlashCommandResult> {
if (!shouldAutoRouteHermesImage(ctx)) return { handled: false };
await handleHermes(ctx, content, receivedAt, eventTimestamp, "implicit_image_message");
return { handled: true };
}
'''.strip()
JS_HERMES_HELPERS = r'''
const DEFAULT_HERMES_ROUTER_URL = "http://127.0.0.1:18791/internal/hermes/route";
function pad2(value) {
return String(value).padStart(2, "0");
}
function makeHermesRouteId() {
const now = new Date();
const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
return `wx-hermes-${stamp}-${randomBytes(4).toString("hex")}`;
}
function hermesRouterUrl() {
return (process.env.AIOS_HERMES_ROUTER_URL || DEFAULT_HERMES_ROUTER_URL).trim();
}
function maxHermesImageBytes() {
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_MAX_IMAGE_BYTES ?? "10485760"), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10485760;
}
const SUPPORTED_HERMES_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
function hermesImageMimeFromPath(filePath, fallback) {
const normalizedFallback = String(fallback ?? "").split(";", 1)[0].trim().toLowerCase();
if (normalizedFallback === "image/jpg")
return "image/jpeg";
if (SUPPORTED_HERMES_IMAGE_MIME.has(normalizedFallback))
return normalizedFallback;
const lower = filePath.toLowerCase();
if (lower.endsWith(".png"))
return "image/png";
if (lower.endsWith(".webp"))
return "image/webp";
return "image/jpeg";
}
function localHermesImageToDataUrl(filePath, fallbackMime) {
const trimmed = String(filePath ?? "").trim();
if (!trimmed)
return null;
try {
if (!existsSync(trimmed))
return null;
const stat = statSync(trimmed);
if (!stat.isFile() || stat.size <= 0 || stat.size > maxHermesImageBytes())
return null;
const mime = hermesImageMimeFromPath(trimmed, fallbackMime);
if (!mime.startsWith("image/"))
return null;
return `data:${mime};base64,${readFileSync(trimmed).toString("base64")}`;
}
catch (err) {
logger.warn(`[weixin] hermes local image read skipped err=${String(err).slice(0, 160)}`);
return null;
}
}
function normalizeHermesImageReference(value, fallbackMime) {
const text = String(value ?? "").trim();
if (!text)
return null;
const lower = text.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("data:image/"))
return text;
return localHermesImageToDataUrl(text, fallbackMime);
}
function collectHermesImageUrls(value, urls, depth = 0, fallbackMime) {
if (depth > 6 || urls.length >= 4 || value == null)
return;
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
collectHermesImageUrls(JSON.parse(trimmed), urls, depth + 1, fallbackMime);
return;
}
catch {
}
}
const url = normalizeHermesImageReference(trimmed, fallbackMime);
if (url && !urls.includes(url))
urls.push(url);
return;
}
if (Array.isArray(value)) {
for (const item of value)
collectHermesImageUrls(item, urls, depth + 1, fallbackMime);
return;
}
if (typeof value !== "object")
return;
const obj = value;
const objectMime = String(obj.MediaType ?? obj.mediaType ?? obj.mimeType ?? obj.mimetype ?? fallbackMime ?? "");
for (const [key, item] of Object.entries(obj)) {
const normalized = key.replace(/-/g, "_").toLowerCase();
if (["image_url", "imageurl", "image_urls", "media_url", "mediaurl", "media_urls", "pic_url", "picurl", "thumb_url", "thumburl", "file_url", "fileurl", "url"].includes(normalized) ||
["media_path", "mediapath", "image_path", "imagepath", "local_path", "localpath"].includes(normalized) ||
(normalized.includes("image") && (normalized.includes("url") || normalized.includes("path")))) {
collectHermesImageUrls(item, urls, depth + 1, objectMime);
}
}
for (const key of ["image", "images", "media", "message", "msg", "event", "payload", "rawMessage", "raw_message", "inboundContext", "ctx"]) {
if (key in obj)
collectHermesImageUrls(obj[key], urls, depth + 1, objectMime);
}
}
function hermesMessageType(ctx) {
const raw = String(ctx.messageType ?? ctx.msgType ?? ctx.type ?? ctx.message?.type ?? ctx.event?.type ?? "").toLowerCase();
const mediaType = String(ctx.mediaType ?? ctx.MediaType ?? ctx.inboundContext?.MediaType ?? "").toLowerCase();
if (raw.includes("image") || raw.includes("photo") || raw.includes("picture") || mediaType.startsWith("image/"))
return "image";
return "text";
}
function hermesAutoRouteImagesEnabled() {
const value = String(process.env.AIOS_HERMES_AUTO_ROUTE_IMAGES ?? "true").trim().toLowerCase();
return !["0", "false", "no", "off"].includes(value);
}
function buildHermesImageUrls(ctx) {
const imageUrls = [];
collectHermesImageUrls(ctx, imageUrls);
return imageUrls;
}
function shouldAutoRouteHermesImage(ctx) {
if (!hermesAutoRouteImagesEnabled())
return false;
return buildHermesImageUrls(ctx).length > 0 || hermesMessageType(ctx) === "image";
}
async function handleHermes(ctx, args, receivedAt, eventTimestamp, routeReason = "explicit_user_prefix") {
const userPrompt = String(args ?? "").trim();
const imageUrls = buildHermesImageUrls(ctx);
const messageType = imageUrls.length > 0 || hermesMessageType(ctx) === "image" ? "image" : "text";
const visionRequired = messageType === "image";
const imageSource = String(ctx.imageSource ?? "");
const imageFollowupReason = String(ctx.imageFollowupReason ?? "");
const imageFollowupStrategy = String(ctx.imageFollowupStrategy ?? "");
const quotedMessageId = String(ctx.quotedMessageId ?? "");
const visionMissingReason = visionRequired && imageUrls.length === 0
? (imageSource === "quoted_unresolved" ? "referenced_image_unresolved" : (imageFollowupReason || "missing_image_url"))
: "";
const effectivePrompt = userPrompt || (visionRequired ? "请识别这张图片的主要内容,并说明是否需要进一步处理。" : "Report the injected Hermes route metadata and current memory readback.");
const routeId = makeHermesRouteId();
logger.info(`[weixin] hermes route submit route_id=${routeId} route_target=hermes route_reason=${routeReason} channel=openclaw-weixin source=personal_wechat message_type=${messageType} image_url_count=${imageUrls.length} image_source=${imageSource || "none"} cache_strategy=${imageFollowupStrategy || "none"} quoted_message_id=${quotedMessageId || "none"} missing_reason=${visionMissingReason || "none"} accountId=${ctx.accountId} to=${ctx.to}`);
const failText = `ROUTE_TARGET=FAIL\nFAIL_REASON=Hermes unavailable or route not configured\nAIOS_ROUTE_ID=${routeId}`;
const payload = {
route_id: routeId,
route_target: "hermes",
route_reason: routeReason,
channel: "openclaw-weixin",
source: "personal_wechat",
user_prompt: effectivePrompt,
message_type: messageType,
vision_required: visionRequired,
image_urls: imageUrls,
image_url_count: imageUrls.length,
image_source: imageSource,
image_followup_reason: imageFollowupReason,
image_followup_strategy: imageFollowupStrategy,
quoted_message_id: quotedMessageId,
vision_missing_reason: visionMissingReason,
wechat_user_id: ctx.to,
account_id: ctx.accountId,
context_token_present: Boolean(ctx.contextToken),
received_at: receivedAt,
event_timestamp: eventTimestamp ?? null,
};
try {
const resp = await fetch(hermesRouterUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(120000),
});
let data = {};
try {
data = await resp.json();
}
catch {
data = {};
}
if (!resp.ok || !data?.ok) {
logger.error(`[weixin] hermes route failed route_id=${routeId} status=${resp.status}`);
await sendReply(ctx, data?.reply_text || failText);
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
return;
}
await sendReply(ctx, String(data.reply_text || failText));
logger.info(`[weixin] hermes reply delivered route_id=${routeId} route_target=hermes executor=hermes message_type=${messageType} image_url_count=${imageUrls.length} memory_write_id=${data.memory_write_id || "none"} memory_read_id=${data.memory_read_id || "none"}`);
}
catch (err) {
logger.error(`[weixin] hermes route exception route_id=${routeId} err=${String(err)}`);
await sendReply(ctx, failText);
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
}
}
export async function handleHermesImplicitImage(content, ctx, receivedAt, eventTimestamp) {
if (!shouldAutoRouteHermesImage(ctx))
return { handled: false };
await handleHermes(ctx, content, receivedAt, eventTimestamp, "implicit_image_message");
return { handled: true };
}
'''.strip()
TS_PROCESS_HELPERS = r'''
// AIOS Hermes image follow-up helpers begin
type HermesLastImageEntry = {
mediaPath?: string;
mediaUrl?: string;
mediaType?: string;
timestampMs: number;
messageId?: string;
};
type HermesFollowupImageContext = {
imageIntent: boolean;
matched: boolean;
reason: string;
entry?: HermesLastImageEntry;
ageMs?: number;
cacheStrategy?: string;
quotedMessageId?: string;
};
type HermesQuotedTextContext = {
referenced: boolean;
handled: boolean;
prompt?: string;
replyText?: string;
reason: "none" | "quoted_text_resolved" | "quoted_text_unavailable";
};
type HermesTextMessageEntry = {
text: string;
timestampMs: number;
conflict?: boolean;
};
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
const DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-message-text-cache.json";
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES = 500;
function hermesLastImageCachePath(): string {
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH).trim();
}
function hermesLastImageMaxAgeMs(): number {
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_LAST_IMAGE_MAX_AGE_MS ?? DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS;
}
function hermesMediaDebugPath(): string {
return (process.env.AIOS_HERMES_WEIXIN_MEDIA_DEBUG || DEFAULT_HERMES_MEDIA_DEBUG_PATH).trim();
}
function hermesTextMessageCachePath(): string {
return (process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE || DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH).trim();
}
function hermesTextMessageCacheKey(accountId: string, senderId: string, messageId: string): string {
return `${accountId || "unknown"}:${senderId || "unknown"}:${messageId}`;
}
function hermesTextMessageTimeCacheKey(accountId: string, senderId: string, createTimeMs: string): string {
return `${accountId || "unknown"}:${senderId || "unknown"}:created:${createTimeMs}`;
}
function readHermesTextMessageCache(): Record<string, HermesTextMessageEntry> {
try {
const filePath = hermesTextMessageCachePath();
if (!existsSync(filePath)) return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, HermesTextMessageEntry> : {};
} catch (err) {
logger.warn(`[weixin] Hermes text message cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesTextMessageCache(cache: Record<string, HermesTextMessageEntry>): void {
try {
const filePath = hermesTextMessageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
const now = Date.now();
const retained = Object.entries(cache)
.filter(([, entry]) => entry && typeof entry.text === "string" && now - Number(entry.timestampMs ?? 0) <= DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
.sort((a, b) => Number(b[1].timestampMs ?? 0) - Number(a[1].timestampMs ?? 0))
.slice(0, DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES);
writeFileSync(filePath, JSON.stringify(Object.fromEntries(retained), null, 2), { encoding: "utf-8", mode: 0o600 });
} catch (err) {
logger.warn(`[weixin] Hermes text message cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function rememberHermesTextMessage(accountId: string, senderId: string, full: any, text: string): void {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText) return;
const messageIds = collectHermesMessageIds(full);
if (!messageIds.size) return;
const cache = readHermesTextMessageCache();
const timestampMs = Date.now();
for (const messageId of messageIds) {
cache[hermesTextMessageCacheKey(accountId, senderId, messageId)] = { text: normalizedText, timestampMs };
}
writeHermesTextMessageCache(cache);
}
function rememberHermesOutboundTextMessage(accountId: string, senderId: string, text: string, createTimeMs: number): void {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText || !Number.isSafeInteger(createTimeMs) || createTimeMs <= 0) return;
const cache = readHermesTextMessageCache();
const key = hermesTextMessageTimeCacheKey(accountId, senderId, String(createTimeMs));
const existing = cache[key];
cache[key] = existing && existing.text !== normalizedText
? { text: "", timestampMs: Date.now(), conflict: true }
: { text: normalizedText, timestampMs: Date.now() };
writeHermesTextMessageCache(cache);
}
function resolveHermesCachedQuotedText(accountId: string, senderId: string, messageId?: string, createTimeMs?: string | number): string {
const normalizedId = String(messageId ?? "").trim();
const normalizedTime = String(createTimeMs ?? "").trim();
const cache = readHermesTextMessageCache();
const entry = (normalizedId ? cache[hermesTextMessageCacheKey(accountId, senderId, normalizedId)] : undefined) ??
(normalizedTime ? cache[hermesTextMessageTimeCacheKey(accountId, senderId, normalizedTime)] : undefined);
if (!entry || entry.conflict || Date.now() - Number(entry.timestampMs ?? 0) > DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS) return "";
return String(entry.text ?? "").trim().slice(0, 4000);
}
function hermesLastImageCacheKey(accountId: string, senderId: string): string {
return `${accountId || "unknown"}:${senderId || "unknown"}`;
}
function hermesExactImageCacheKey(accountId: string, senderId: string, messageId: string): string {
return `${hermesLastImageCacheKey(accountId, senderId)}:${messageId || "unknown-message"}`;
}
function hermesApproxMessageId(messageId: string): string {
const normalized = String(messageId ?? "").trim();
return /^\d{16,}$/.test(normalized) ? normalized.slice(0, -4) : "";
}
function hermesApproxImageCacheKey(accountId: string, senderId: string, messageId: string): string {
const approx = hermesApproxMessageId(messageId);
return approx ? `${hermesLastImageCacheKey(accountId, senderId)}:approx:${approx}` : "";
}
function sanitizeHermesMediaDebug(value: any, depth = 0): any {
if (depth > 6) return "[max-depth]";
if (value == null) return value;
if (typeof value === "string") return value.length > 160 ? `${value.slice(0, 160)}...` : value;
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) return value.slice(0, 8).map((item) => sanitizeHermesMediaDebug(item, depth + 1));
if (typeof value !== "object") return String(value);
const out: Record<string, any> = {};
for (const [key, item] of Object.entries(value)) {
const normalized = key.toLowerCase();
if (/(token|key|secret|auth|cookie|signature|encrypt_query_param|aes)/i.test(key)) {
out[key] = item ? "[present]" : item;
continue;
}
if (
normalized.includes("ref") ||
normalized.includes("quote") ||
normalized.includes("media") ||
normalized.includes("image") ||
normalized.includes("thumb") ||
normalized.includes("pic") ||
normalized.includes("url") ||
normalized.includes("path") ||
["type", "item_list", "itemlist", "message_item", "messageitem", "msg", "message", "payload", "text_item", "text", "title"].includes(normalized)
) {
out[key] = sanitizeHermesMediaDebug(item, depth + 1);
}
}
const keys = Object.keys(value);
if (!Object.keys(out).length) out.__keys = keys.slice(0, 40);
else out.__keys = keys.slice(0, 40);
return out;
}
function writeHermesMediaDebug(event: string, data: Record<string, any>): void {
try {
const filePath = hermesMediaDebugPath();
mkdirSync(path.dirname(filePath), { recursive: true });
appendFileSync(filePath, `${JSON.stringify({ ts: new Date().toISOString(), event, ...data })}\n`, "utf-8");
} catch (err) {
logger.warn(`[weixin] Hermes media debug write skipped err=${String(err).slice(0, 160)}`);
}
}
function readHermesLastImageCache(): Record<string, HermesLastImageEntry> {
try {
const filePath = hermesLastImageCachePath();
if (!filePath || !existsSync(filePath)) return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, HermesLastImageEntry> : {};
} catch (err) {
logger.warn(`[weixin] Hermes last image cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesLastImageCache(cache: Record<string, HermesLastImageEntry>): void {
try {
const filePath = hermesLastImageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
} catch (err) {
logger.warn(`[weixin] Hermes last image cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function hermesPathLooksLikeImage(filePath: string): boolean {
return /\.(?:jpe?g|png|webp|gif|bmp|heic)$/i.test(String(filePath ?? "").trim());
}
function hermesCurrentMessageIsImage(ctx: any, mediaItem: any): boolean {
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "").toLowerCase();
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "");
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").toLowerCase();
return mediaType.startsWith("image/") ||
hermesPathLooksLikeImage(mediaPath) ||
mediaUrl.startsWith("data:image/") ||
mediaItem?.type === MessageItemType.IMAGE;
}
function hasHermesReferenceContext(value: any, depth = 0): boolean {
if (depth > 8 || value == null) return false;
if (Array.isArray(value)) return value.some((item) => hasHermesReferenceContext(item, depth + 1));
if (typeof value !== "object") return false;
const obj = value as Record<string, any>;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
if (referenceKeys.some((key) => key in obj)) return true;
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj && hasHermesReferenceContext(obj[key], depth + 1)) return true;
}
return false;
}
function findHermesReferencedCreateTimeMs(value: any, depth = 0, insideReference = false): string | undefined {
if (depth > 8 || value == null) return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedCreateTimeMs(item, depth + 1, insideReference);
if (found) return found;
}
return undefined;
}
if (typeof value !== "object") return undefined;
const obj = value as Record<string, any>;
if (insideReference) {
const raw = obj.create_time_ms ?? obj.createTimeMs;
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return String(raw);
if (typeof raw === "string" && /^(?:0|[1-9]\d*)$/.test(raw.trim())) return raw.trim();
}
const referenceKeys = ["ref_msg", "refMsg", "quoted_message", "quotedMessage", "quoted", "quote", "referenced_message", "referencedMessage"];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found) return found;
}
}
if (insideReference) {
for (const key of ["message_item", "messageItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found) return found;
}
}
}
return undefined;
}
function extractHermesReferencedText(value: any, depth = 0, insideReference = false): string {
if (depth > 8 || value == null) return "";
if (Array.isArray(value)) {
for (const item of value) {
const found = extractHermesReferencedText(item, depth + 1, insideReference);
if (found) return found;
}
return "";
}
if (typeof value !== "object") return "";
const obj = value as Record<string, any>;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
for (const key of referenceKeys) {
if (key in obj) {
const referenceValue = obj[key];
const found = extractHermesReferencedText(referenceValue, depth + 1, true);
if (found) return found;
const title = String(referenceValue?.title ?? "").trim();
if (title) return title.slice(0, 4000);
}
}
if (insideReference) {
const directCandidates = [
obj.text_item?.text,
obj.textItem?.text,
];
for (const candidate of directCandidates) {
const normalized = String(candidate ?? "").trim();
if (normalized) return normalized.slice(0, 4000);
}
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, true);
if (found) return found;
}
}
} else {
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, false);
if (found) return found;
}
}
}
return "";
}
function resolveHermesQuotedTextContext(items: any, currentText: string, cachedQuotedText = ""): HermesQuotedTextContext {
if (!hasHermesReferenceContext(items)) {
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
}
const quotedText = extractHermesReferencedText(items) || String(cachedQuotedText ?? "").trim().slice(0, 4000);
if (!quotedText) {
return {
referenced: true,
handled: true,
replyText: "无法读取被引用文字,请复制原文后再提问。",
reason: "quoted_text_unavailable",
};
}
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
return {
referenced: true,
handled: false,
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
reason: "quoted_text_resolved",
};
}
async function dispatchHermesQuotedTextFailClosed(context: HermesQuotedTextContext, dispatcher: any): Promise<boolean> {
if (!context.handled) return false;
dispatcher.sendFinalReply({
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
});
dispatcher.markComplete();
await dispatcher.waitForIdle();
return true;
}
function findHermesReferencedMediaItem(value: any, depth = 0, insideReference = false): any {
if (depth > 8 || value == null) return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedMediaItem(item, depth + 1, insideReference);
if (found) return found;
}
return undefined;
}
if (typeof value !== "object") return undefined;
if (insideReference && isMediaItem(value)) return value;
const obj = value as Record<string, any>;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
const nestedReferenceKeys = [
"message_item",
"messageItem",
"item_list",
"itemList",
"message",
"msg",
"payload",
];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
if (found) return found;
}
}
if (insideReference) {
for (const key of nestedReferenceKeys) {
if (key in obj) {
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
if (found) return found;
}
}
}
return undefined;
}
function extractHermesMessageId(value: any): string | undefined {
if (!value || typeof value !== "object") return undefined;
const obj = value as Record<string, any>;
for (const key of ["message_id", "messageId", "msg_id", "msgId", "client_msg_id", "clientMsgId", "server_msg_id", "serverMsgId", "new_msg_id", "newMsgId", "id"]) {
const raw = obj[key];
if (typeof raw === "number" && !Number.isSafeInteger(raw)) continue;
if (typeof raw === "string" || typeof raw === "number") {
const text = String(raw).trim();
if (text) return text;
}
}
return undefined;
}
function collectHermesMessageIds(value: any, out = new Set<string>(), depth = 0): Set<string> {
if (depth > 6 || value == null) return out;
if (Array.isArray(value)) {
for (const item of value) collectHermesMessageIds(item, out, depth + 1);
return out;
}
if (typeof value !== "object") return out;
const direct = extractHermesMessageId(value);
if (direct) out.add(direct);
const obj = value as Record<string, any>;
for (const key of ["item_list", "itemList", "message_item", "messageItem", "message", "msg", "payload"]) {
if (key in obj) collectHermesMessageIds(obj[key], out, depth + 1);
}
return out;
}
function findHermesReferencedMessageId(value: any, depth = 0, insideReference = false): string | undefined {
if (depth > 8 || value == null) return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedMessageId(item, depth + 1, insideReference);
if (found) return found;
}
return undefined;
}
if (typeof value !== "object") return undefined;
const obj = value as Record<string, any>;
if (insideReference) {
const direct = extractHermesMessageId(obj);
if (direct) return direct;
}
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
const nestedReferenceKeys = [
"message_item",
"messageItem",
"item_list",
"itemList",
"message",
"msg",
"payload",
];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
if (found) return found;
}
}
if (insideReference) {
for (const key of nestedReferenceKeys) {
if (key in obj) {
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
if (found) return found;
}
}
} else {
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = findHermesReferencedMessageId(obj[key], depth + 1, false);
if (found) return found;
}
}
}
return undefined;
}
function rememberHermesLastImage(accountId: string, senderId: string, ctx: any, full: any, mediaItem: any): void {
if (!hermesCurrentMessageIsImage(ctx, mediaItem)) return;
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "").trim();
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").trim();
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "image/jpeg").trim() || "image/jpeg";
if (!mediaPath && !mediaUrl) return;
const cache = readHermesLastImageCache();
const latestKey = hermesLastImageCacheKey(accountId, senderId);
const messageId = String(full?.message_id ?? full?.id ?? "").trim();
const aliasIds = collectHermesMessageIds([full, mediaItem]);
const entry = {
mediaPath: mediaPath || undefined,
mediaUrl: mediaUrl || undefined,
mediaType,
timestampMs: Date.now(),
messageId: messageId || undefined,
};
cache[latestKey] = entry;
for (const aliasId of aliasIds) {
cache[hermesExactImageCacheKey(accountId, senderId, aliasId)] = entry;
const approxKey = hermesApproxImageCacheKey(accountId, senderId, aliasId);
if (approxKey) cache[approxKey] = entry;
}
writeHermesLastImageCache(cache);
logger.info(`[weixin] Hermes last image cache updated accountId=${accountId} sender=${senderId} hasMediaPath=${Boolean(mediaPath)} hasMediaUrl=${Boolean(mediaUrl)} messageId=${entry.messageId ?? "none"} exactKeyCount=${aliasIds.size}`);
}
function hermesImageFollowupHasDiagnosticSignal(normalized: string): boolean {
if (!normalized) return false;
return [
"\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548",
"\u7f13\u5b58\u5df2\u5931\u6548",
"\u7f13\u5b58\u5931\u6548",
"\u56fe\u7247\u7f13\u5b58\u9519\u8bef",
"\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
"\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
"\u8fd9\u4e0d\u662f\u56fe\u7247",
"\u4e0d\u662f\u56fe\u7247",
"\u6211\u6ca1\u6709\u53d1\u56fe\u7247",
"\u6ca1\u6709\u53d1\u56fe\u7247",
"\u6ca1\u53d1\u56fe\u7247",
"\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7",
"\u7cfb\u7edf\u65e5\u5fd7",
"\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
"\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
"image cache error",
"image cache expired",
"cache error",
"cache expired",
"please resend",
"text message",
"not an image",
"not image",
"no image",
"resend the image",
"check the logs",
].some((phrase) => normalized.includes(phrase));
}
function looksLikeHermesImageFollowup(text: string): boolean {
const normalized = normalizeHermesImageIntentText(text);
if (!normalized) return false;
if (hermesImageFollowupHasDiagnosticSignal(normalized)) return false;
return /(\u7167\u7247|\u56fe\u7247|\u56fe\u91cc|\u56fe\u4e2d|\u8fd9\u5f20|\u8fd9\u4e2a\u56fe|\u8fd9\u56fe|\u76f8\u7247|\u62cd\u7684\u662f|\u54ea\u91cc|\u54ea\u513f|\u5730\u70b9|\u4f4d\u7f6e|\u8bc6\u522b|\u770b\u4e00\u4e0b|\u770b\u4e0b|\u662f\u4ec0\u4e48|\u5728\u54ea\u91cc|\u5728\u54ea|photo|image|picture|where|location|what is in|look at)/i.test(normalized);
}
function isHermesSlashCommandText(text: string): boolean {
return /^\/hermes(?:\s|$)/i.test(String(text ?? "").trim());
}
function normalizeHermesImageIntentText(text: string): string {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) return "";
if (isHermesSlashCommandText(normalized)) {
return normalized.replace(/^\/hermes(?:\s+|$)/i, "").trim();
}
if (normalized.startsWith("/")) return "";
return normalized;
}
function resolveHermesCachedImageEntry(entry: any, strategy: string, quotedMessageId?: string): HermesFollowupImageContext {
if (!entry) {
return { imageIntent: true, matched: false, reason: strategy === "exact" ? "referenced_image_unresolved" : "no_cached_image", cacheStrategy: strategy, quotedMessageId };
}
const ageMs = Date.now() - Number(entry.timestampMs ?? 0);
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > hermesLastImageMaxAgeMs()) {
return { imageIntent: true, matched: false, reason: "cached_image_expired", ageMs, cacheStrategy: strategy, quotedMessageId };
}
const mediaPath = String(entry.mediaPath ?? "").trim();
const mediaUrl = String(entry.mediaUrl ?? "").trim();
if (mediaPath && !existsSync(mediaPath) && !mediaUrl) {
return { imageIntent: true, matched: false, reason: "cached_image_path_missing", ageMs, cacheStrategy: strategy, quotedMessageId };
}
if (!mediaPath && !mediaUrl) {
return { imageIntent: true, matched: false, reason: "cached_image_empty", ageMs, cacheStrategy: strategy, quotedMessageId };
}
return {
imageIntent: true,
matched: true,
reason: "cached_image_matched",
ageMs,
cacheStrategy: strategy,
quotedMessageId,
entry: {
mediaPath: mediaPath || undefined,
mediaUrl: mediaUrl || undefined,
mediaType: String(entry.mediaType ?? "image/jpeg").trim() || "image/jpeg",
timestampMs: Number(entry.timestampMs ?? Date.now()),
messageId: entry.messageId,
},
};
}
function resolveHermesFollowupImageContext(accountId: string, senderId: string, text: string, quotedMessageId?: string, allowLatestFallback = true, forceImageIntent = false): HermesFollowupImageContext {
if (!forceImageIntent && !looksLikeHermesImageFollowup(text)) {
return { imageIntent: false, matched: false, reason: "not_image_followup" };
}
const cache = readHermesLastImageCache();
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
if (normalizedQuotedId) {
const exact = resolveHermesCachedImageEntry(cache[hermesExactImageCacheKey(accountId, senderId, normalizedQuotedId)], "exact", normalizedQuotedId);
if (exact.matched) return exact;
const approxKey = hermesApproxImageCacheKey(accountId, senderId, normalizedQuotedId);
if (approxKey) {
const approx = resolveHermesCachedImageEntry(cache[approxKey], "exact_approx", normalizedQuotedId);
if (approx.matched) return approx;
}
if (!allowLatestFallback) return exact;
}
const latest = resolveHermesCachedImageEntry(cache[hermesLastImageCacheKey(accountId, senderId)], "latest", normalizedQuotedId || undefined);
if (!allowLatestFallback && !latest.matched) {
return { ...latest, reason: "referenced_image_unresolved", cacheStrategy: "exact", quotedMessageId: normalizedQuotedId || undefined };
}
return latest;
}
function resolveHermesReferencedImageFollowup(accountId: string, senderId: string, text: string, quotedMessageId?: string): HermesFollowupImageContext {
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
const candidate = resolveHermesFollowupImageContext(accountId, senderId, text, normalizedQuotedId || undefined, false, true);
if (candidate.matched) return candidate;
return {
imageIntent: false,
matched: false,
reason: "referenced_image_not_confirmed",
cacheStrategy: "exact",
quotedMessageId: normalizedQuotedId || undefined,
};
}
// AIOS Hermes image follow-up helpers end
'''.strip()
JS_PROCESS_HELPERS = r'''
// AIOS Hermes image follow-up helpers begin
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
const DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-message-text-cache.json";
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES = 500;
function hermesLastImageCachePath() {
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH).trim();
}
function hermesLastImageMaxAgeMs() {
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_LAST_IMAGE_MAX_AGE_MS ?? DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS;
}
function hermesMediaDebugPath() {
return (process.env.AIOS_HERMES_WEIXIN_MEDIA_DEBUG || DEFAULT_HERMES_MEDIA_DEBUG_PATH).trim();
}
function hermesTextMessageCachePath() {
return (process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE || DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH).trim();
}
function hermesTextMessageCacheKey(accountId, senderId, messageId) {
return `${accountId || "unknown"}:${senderId || "unknown"}:${messageId}`;
}
function hermesTextMessageTimeCacheKey(accountId, senderId, createTimeMs) {
return `${accountId || "unknown"}:${senderId || "unknown"}:created:${createTimeMs}`;
}
function readHermesTextMessageCache() {
try {
const filePath = hermesTextMessageCachePath();
if (!existsSync(filePath))
return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}
catch (err) {
logger.warn(`[weixin] Hermes text message cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesTextMessageCache(cache) {
try {
const filePath = hermesTextMessageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
const now = Date.now();
const retained = Object.entries(cache)
.filter(([, entry]) => entry && typeof entry.text === "string" && now - Number(entry.timestampMs ?? 0) <= DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
.sort((a, b) => Number(b[1].timestampMs ?? 0) - Number(a[1].timestampMs ?? 0))
.slice(0, DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES);
writeFileSync(filePath, JSON.stringify(Object.fromEntries(retained), null, 2), { encoding: "utf-8", mode: 0o600 });
}
catch (err) {
logger.warn(`[weixin] Hermes text message cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function rememberHermesTextMessage(accountId, senderId, full, text) {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText)
return;
const messageIds = collectHermesMessageIds(full);
if (!messageIds.size)
return;
const cache = readHermesTextMessageCache();
const timestampMs = Date.now();
for (const messageId of messageIds) {
cache[hermesTextMessageCacheKey(accountId, senderId, messageId)] = { text: normalizedText, timestampMs };
}
writeHermesTextMessageCache(cache);
}
function rememberHermesOutboundTextMessage(accountId, senderId, text, createTimeMs) {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText || !Number.isSafeInteger(createTimeMs) || createTimeMs <= 0)
return;
const cache = readHermesTextMessageCache();
const key = hermesTextMessageTimeCacheKey(accountId, senderId, String(createTimeMs));
const existing = cache[key];
cache[key] = existing && existing.text !== normalizedText
? { text: "", timestampMs: Date.now(), conflict: true }
: { text: normalizedText, timestampMs: Date.now() };
writeHermesTextMessageCache(cache);
}
function resolveHermesCachedQuotedText(accountId, senderId, messageId, createTimeMs) {
const normalizedId = String(messageId ?? "").trim();
const normalizedTime = String(createTimeMs ?? "").trim();
const cache = readHermesTextMessageCache();
const entry = (normalizedId ? cache[hermesTextMessageCacheKey(accountId, senderId, normalizedId)] : undefined) ??
(normalizedTime ? cache[hermesTextMessageTimeCacheKey(accountId, senderId, normalizedTime)] : undefined);
if (!entry || entry.conflict || Date.now() - Number(entry.timestampMs ?? 0) > DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
return "";
return String(entry.text ?? "").trim().slice(0, 4000);
}
function hermesLastImageCacheKey(accountId, senderId) {
return `${accountId || "unknown"}:${senderId || "unknown"}`;
}
function hermesExactImageCacheKey(accountId, senderId, messageId) {
return `${hermesLastImageCacheKey(accountId, senderId)}:${messageId || "unknown-message"}`;
}
function hermesApproxMessageId(messageId) {
const normalized = String(messageId ?? "").trim();
return /^\d{16,}$/.test(normalized) ? normalized.slice(0, -4) : "";
}
function hermesApproxImageCacheKey(accountId, senderId, messageId) {
const approx = hermesApproxMessageId(messageId);
return approx ? `${hermesLastImageCacheKey(accountId, senderId)}:approx:${approx}` : "";
}
function sanitizeHermesMediaDebug(value, depth = 0) {
if (depth > 6)
return "[max-depth]";
if (value == null)
return value;
if (typeof value === "string")
return value.length > 160 ? `${value.slice(0, 160)}...` : value;
if (typeof value === "number" || typeof value === "boolean")
return value;
if (Array.isArray(value))
return value.slice(0, 8).map((item) => sanitizeHermesMediaDebug(item, depth + 1));
if (typeof value !== "object")
return String(value);
const out = {};
for (const [key, item] of Object.entries(value)) {
const normalized = key.toLowerCase();
if (/(token|key|secret|auth|cookie|signature|encrypt_query_param|aes)/i.test(key)) {
out[key] = item ? "[present]" : item;
continue;
}
if (normalized.includes("ref") ||
normalized.includes("quote") ||
normalized.includes("media") ||
normalized.includes("image") ||
normalized.includes("thumb") ||
normalized.includes("pic") ||
normalized.includes("url") ||
normalized.includes("path") ||
["type", "item_list", "itemlist", "message_item", "messageitem", "msg", "message", "payload", "text_item", "text", "title"].includes(normalized)) {
out[key] = sanitizeHermesMediaDebug(item, depth + 1);
}
}
const keys = Object.keys(value);
if (!Object.keys(out).length)
out.__keys = keys.slice(0, 40);
else
out.__keys = keys.slice(0, 40);
return out;
}
function writeHermesMediaDebug(event, data) {
try {
const filePath = hermesMediaDebugPath();
mkdirSync(path.dirname(filePath), { recursive: true });
appendFileSync(filePath, `${JSON.stringify({ ts: new Date().toISOString(), event, ...data })}\n`, "utf-8");
}
catch (err) {
logger.warn(`[weixin] Hermes media debug write skipped err=${String(err).slice(0, 160)}`);
}
}
function readHermesLastImageCache() {
try {
const filePath = hermesLastImageCachePath();
if (!filePath || !existsSync(filePath))
return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}
catch (err) {
logger.warn(`[weixin] Hermes last image cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesLastImageCache(cache) {
try {
const filePath = hermesLastImageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
}
catch (err) {
logger.warn(`[weixin] Hermes last image cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function hermesPathLooksLikeImage(filePath) {
return /\.(?:jpe?g|png|webp|gif|bmp|heic)$/i.test(String(filePath ?? "").trim());
}
function hermesCurrentMessageIsImage(ctx, mediaItem) {
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "").toLowerCase();
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "");
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").toLowerCase();
return mediaType.startsWith("image/") ||
hermesPathLooksLikeImage(mediaPath) ||
mediaUrl.startsWith("data:image/") ||
mediaItem?.type === MessageItemType.IMAGE;
}
function hasHermesReferenceContext(value, depth = 0) {
if (depth > 8 || value == null)
return false;
if (Array.isArray(value))
return value.some((item) => hasHermesReferenceContext(item, depth + 1));
if (typeof value !== "object")
return false;
const obj = value;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
if (referenceKeys.some((key) => key in obj))
return true;
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj && hasHermesReferenceContext(obj[key], depth + 1))
return true;
}
return false;
}
function findHermesReferencedCreateTimeMs(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedCreateTimeMs(item, depth + 1, insideReference);
if (found)
return found;
}
return undefined;
}
if (typeof value !== "object")
return undefined;
const obj = value;
if (insideReference) {
const raw = obj.create_time_ms ?? obj.createTimeMs;
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0)
return String(raw);
if (typeof raw === "string" && /^(?:0|[1-9]\d*)$/.test(raw.trim()))
return raw.trim();
}
const referenceKeys = ["ref_msg", "refMsg", "quoted_message", "quotedMessage", "quoted", "quote", "referenced_message", "referencedMessage"];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found)
return found;
}
}
if (insideReference) {
for (const key of ["message_item", "messageItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found)
return found;
}
}
}
return undefined;
}
function extractHermesReferencedText(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return "";
if (Array.isArray(value)) {
for (const item of value) {
const found = extractHermesReferencedText(item, depth + 1, insideReference);
if (found)
return found;
}
return "";
}
if (typeof value !== "object")
return "";
const obj = value;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
for (const key of referenceKeys) {
if (key in obj) {
const referenceValue = obj[key];
const found = extractHermesReferencedText(referenceValue, depth + 1, true);
if (found)
return found;
const title = String(referenceValue?.title ?? "").trim();
if (title)
return title.slice(0, 4000);
}
}
if (insideReference) {
const directCandidates = [
obj.text_item?.text,
obj.textItem?.text,
];
for (const candidate of directCandidates) {
const normalized = String(candidate ?? "").trim();
if (normalized)
return normalized.slice(0, 4000);
}
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, true);
if (found)
return found;
}
}
}
else {
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, false);
if (found)
return found;
}
}
}
return "";
}
function resolveHermesQuotedTextContext(items, currentText, cachedQuotedText = "") {
if (!hasHermesReferenceContext(items)) {
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
}
const quotedText = extractHermesReferencedText(items) || String(cachedQuotedText ?? "").trim().slice(0, 4000);
if (!quotedText) {
return {
referenced: true,
handled: true,
replyText: "无法读取被引用文字,请复制原文后再提问。",
reason: "quoted_text_unavailable",
};
}
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
return {
referenced: true,
handled: false,
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
reason: "quoted_text_resolved",
};
}
async function dispatchHermesQuotedTextFailClosed(context, dispatcher) {
if (!context.handled)
return false;
dispatcher.sendFinalReply({
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
});
dispatcher.markComplete();
await dispatcher.waitForIdle();
return true;
}
function findHermesReferencedMediaItem(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedMediaItem(item, depth + 1, insideReference);
if (found)
return found;
}
return undefined;
}
if (typeof value !== "object")
return undefined;
if (insideReference && isMediaItem(value))
return value;
const obj = value;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
const nestedReferenceKeys = [
"message_item",
"messageItem",
"item_list",
"itemList",
"message",
"msg",
"payload",
];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
if (found)
return found;
}
}
if (insideReference) {
for (const key of nestedReferenceKeys) {
if (key in obj) {
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
if (found)
return found;
}
}
}
return undefined;
}
function extractHermesMessageId(value) {
if (!value || typeof value !== "object")
return undefined;
const obj = value;
for (const key of ["message_id", "messageId", "msg_id", "msgId", "client_msg_id", "clientMsgId", "server_msg_id", "serverMsgId", "new_msg_id", "newMsgId", "id"]) {
const raw = obj[key];
if (typeof raw === "number" && !Number.isSafeInteger(raw))
continue;
if (typeof raw === "string" || typeof raw === "number") {
const text = String(raw).trim();
if (text)
return text;
}
}
return undefined;
}
function collectHermesMessageIds(value, out = new Set(), depth = 0) {
if (depth > 6 || value == null)
return out;
if (Array.isArray(value)) {
for (const item of value)
collectHermesMessageIds(item, out, depth + 1);
return out;
}
if (typeof value !== "object")
return out;
const direct = extractHermesMessageId(value);
if (direct)
out.add(direct);
const obj = value;
for (const key of ["item_list", "itemList", "message_item", "messageItem", "message", "msg", "payload"]) {
if (key in obj)
collectHermesMessageIds(obj[key], out, depth + 1);
}
return out;
}
function findHermesReferencedMessageId(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedMessageId(item, depth + 1, insideReference);
if (found)
return found;
}
return undefined;
}
if (typeof value !== "object")
return undefined;
const obj = value;
if (insideReference) {
const direct = extractHermesMessageId(obj);
if (direct)
return direct;
}
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
const nestedReferenceKeys = [
"message_item",
"messageItem",
"item_list",
"itemList",
"message",
"msg",
"payload",
];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
if (found)
return found;
}
}
if (insideReference) {
for (const key of nestedReferenceKeys) {
if (key in obj) {
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
if (found)
return found;
}
}
}
else {
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = findHermesReferencedMessageId(obj[key], depth + 1, false);
if (found)
return found;
}
}
}
return undefined;
}
function rememberHermesLastImage(accountId, senderId, ctx, full, mediaItem) {
if (!hermesCurrentMessageIsImage(ctx, mediaItem))
return;
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "").trim();
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").trim();
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "image/jpeg").trim() || "image/jpeg";
if (!mediaPath && !mediaUrl)
return;
const cache = readHermesLastImageCache();
const latestKey = hermesLastImageCacheKey(accountId, senderId);
const messageId = String(full?.message_id ?? full?.id ?? "").trim();
const aliasIds = collectHermesMessageIds([full, mediaItem]);
const entry = {
mediaPath: mediaPath || undefined,
mediaUrl: mediaUrl || undefined,
mediaType,
timestampMs: Date.now(),
messageId: messageId || undefined,
};
cache[latestKey] = entry;
for (const aliasId of aliasIds) {
cache[hermesExactImageCacheKey(accountId, senderId, aliasId)] = entry;
const approxKey = hermesApproxImageCacheKey(accountId, senderId, aliasId);
if (approxKey)
cache[approxKey] = entry;
}
writeHermesLastImageCache(cache);
logger.info(`[weixin] Hermes last image cache updated accountId=${accountId} sender=${senderId} hasMediaPath=${Boolean(mediaPath)} hasMediaUrl=${Boolean(mediaUrl)} messageId=${entry.messageId ?? "none"} exactKeyCount=${aliasIds.size}`);
}
function hermesImageFollowupHasDiagnosticSignal(normalized) {
if (!normalized)
return false;
return [
"\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548",
"\u7f13\u5b58\u5df2\u5931\u6548",
"\u7f13\u5b58\u5931\u6548",
"\u56fe\u7247\u7f13\u5b58\u9519\u8bef",
"\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
"\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
"\u8fd9\u4e0d\u662f\u56fe\u7247",
"\u4e0d\u662f\u56fe\u7247",
"\u6211\u6ca1\u6709\u53d1\u56fe\u7247",
"\u6ca1\u6709\u53d1\u56fe\u7247",
"\u6ca1\u53d1\u56fe\u7247",
"\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7",
"\u7cfb\u7edf\u65e5\u5fd7",
"\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
"\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
"image cache error",
"image cache expired",
"cache error",
"cache expired",
"please resend",
"text message",
"not an image",
"not image",
"no image",
"resend the image",
"check the logs",
].some((phrase) => normalized.includes(phrase));
}
function looksLikeHermesImageFollowup(text) {
const normalized = normalizeHermesImageIntentText(text);
if (!normalized)
return false;
if (hermesImageFollowupHasDiagnosticSignal(normalized))
return false;
return /(\u7167\u7247|\u56fe\u7247|\u56fe\u91cc|\u56fe\u4e2d|\u8fd9\u5f20|\u8fd9\u4e2a\u56fe|\u8fd9\u56fe|\u76f8\u7247|\u62cd\u7684\u662f|\u54ea\u91cc|\u54ea\u513f|\u5730\u70b9|\u4f4d\u7f6e|\u8bc6\u522b|\u770b\u4e00\u4e0b|\u770b\u4e0b|\u662f\u4ec0\u4e48|\u5728\u54ea\u91cc|\u5728\u54ea|photo|image|picture|where|location|what is in|look at)/i.test(normalized);
}
function isHermesSlashCommandText(text) {
return /^\/hermes(?:\s|$)/i.test(String(text ?? "").trim());
}
function normalizeHermesImageIntentText(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized)
return "";
if (isHermesSlashCommandText(normalized)) {
return normalized.replace(/^\/hermes(?:\s+|$)/i, "").trim();
}
if (normalized.startsWith("/"))
return "";
return normalized;
}
function resolveHermesCachedImageEntry(entry, strategy, quotedMessageId) {
if (!entry) {
return { imageIntent: true, matched: false, reason: strategy === "exact" ? "referenced_image_unresolved" : "no_cached_image", cacheStrategy: strategy, quotedMessageId };
}
const ageMs = Date.now() - Number(entry.timestampMs ?? 0);
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > hermesLastImageMaxAgeMs()) {
return { imageIntent: true, matched: false, reason: "cached_image_expired", ageMs, cacheStrategy: strategy, quotedMessageId };
}
const mediaPath = String(entry.mediaPath ?? "").trim();
const mediaUrl = String(entry.mediaUrl ?? "").trim();
if (mediaPath && !existsSync(mediaPath) && !mediaUrl) {
return { imageIntent: true, matched: false, reason: "cached_image_path_missing", ageMs, cacheStrategy: strategy, quotedMessageId };
}
if (!mediaPath && !mediaUrl) {
return { imageIntent: true, matched: false, reason: "cached_image_empty", ageMs, cacheStrategy: strategy, quotedMessageId };
}
return {
imageIntent: true,
matched: true,
reason: "cached_image_matched",
ageMs,
cacheStrategy: strategy,
quotedMessageId,
entry: {
mediaPath: mediaPath || undefined,
mediaUrl: mediaUrl || undefined,
mediaType: String(entry.mediaType ?? "image/jpeg").trim() || "image/jpeg",
timestampMs: Number(entry.timestampMs ?? Date.now()),
messageId: entry.messageId,
},
};
}
function resolveHermesFollowupImageContext(accountId, senderId, text, quotedMessageId, allowLatestFallback = true, forceImageIntent = false) {
if (!forceImageIntent && !looksLikeHermesImageFollowup(text)) {
return { imageIntent: false, matched: false, reason: "not_image_followup" };
}
const cache = readHermesLastImageCache();
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
if (normalizedQuotedId) {
const exact = resolveHermesCachedImageEntry(cache[hermesExactImageCacheKey(accountId, senderId, normalizedQuotedId)], "exact", normalizedQuotedId);
if (exact.matched)
return exact;
const approxKey = hermesApproxImageCacheKey(accountId, senderId, normalizedQuotedId);
if (approxKey) {
const approx = resolveHermesCachedImageEntry(cache[approxKey], "exact_approx", normalizedQuotedId);
if (approx.matched)
return approx;
}
if (!allowLatestFallback)
return exact;
}
const latest = resolveHermesCachedImageEntry(cache[hermesLastImageCacheKey(accountId, senderId)], "latest", normalizedQuotedId || undefined);
if (!allowLatestFallback && !latest.matched) {
return { ...latest, reason: "referenced_image_unresolved", cacheStrategy: "exact", quotedMessageId: normalizedQuotedId || undefined };
}
return latest;
}
function resolveHermesReferencedImageFollowup(accountId, senderId, text, quotedMessageId) {
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
const candidate = resolveHermesFollowupImageContext(accountId, senderId, text, normalizedQuotedId || undefined, false, true);
if (candidate.matched)
return candidate;
return {
imageIntent: false,
matched: false,
reason: "referenced_image_not_confirmed",
cacheStrategy: "exact",
quotedMessageId: normalizedQuotedId || undefined,
};
}
// AIOS Hermes image follow-up helpers end
'''.strip()
TS_PROCESS_HOOK = r'''
// AIOS Hermes image ingress hook begin
const hermesSenderId = full.from_user_id ?? "";
rememberHermesLastImage(deps.accountId, hermesSenderId, ctx, full, mediaItem);
const hermesCtx = ctx as typeof ctx & { MediaPath?: string; MediaUrl?: string; MediaType?: string };
const hermesContent = rawBody || textBody;
const hermesExplicitSlash = isHermesSlashCommandText(hermesContent);
const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);
const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);
const hermesQuotedCreateTimeMs = findHermesReferencedCreateTimeMs(full.item_list);
rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody);
const hermesCachedQuotedText = resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs);
const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));
const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;
const hermesCurrentImage = hermesCurrentMessageIsImage(hermesCtx, mediaItem);
const hermesFollowup = hermesCurrentImage
? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }
: (hermesReferencedMediaIsImage
? resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId, false, true)
: (hermesReferencedImageFollowupIntent
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
const hermesQuotedTextContext = hermesReferenceContextPresent &&
!hermesExplicitSlash &&
!hermesCurrentImage &&
!hermesReferencedMediaIsImage &&
!hermesFollowup.matched
? resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" as const };
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
ctx.Body = hermesQuotedTextContext.prompt;
ctx.CommandBody = hermesQuotedTextContext.prompt;
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
}
if (hermesFollowup.reason === "referenced_image_unresolved") {
writeHermesMediaDebug("referenced_image_unresolved", {
accountId: deps.accountId,
senderId: hermesSenderId,
messageId: String(full?.message_id ?? full?.id ?? "") || undefined,
quotedMessageId: hermesQuotedMessageId || undefined,
cacheStrategy: hermesFollowup.cacheStrategy,
itemList: sanitizeHermesMediaDebug(full.item_list),
rawMessage: sanitizeHermesMediaDebug(full),
});
}
if (hermesFollowup.imageIntent) {
logger.info(`[weixin] Hermes image follow-up ${hermesFollowup.matched ? "matched cached media" : "has no cached media, fail-closed"} reason=${hermesFollowup.reason} ageMs=${hermesFollowup.ageMs ?? "none"} cache_strategy=${hermesFollowup.cacheStrategy ?? "none"} quoted_message_id=${hermesFollowup.quotedMessageId ?? hermesQuotedMessageId ?? "none"}`);
}
const hermesMessageType = hermesCurrentImage || hermesFollowup.imageIntent ? "image" : undefined;
const hermesImageSource = hermesCurrentImage ? (refMediaItem ? "quoted" : "current") : (hermesFollowup.reason === "referenced_image_unresolved" ? "quoted_unresolved" : (hermesFollowup.matched ? "cache" : "none"));
const hermesMediaPath = hermesCurrentImage ? hermesCtx.MediaPath : hermesFollowup.entry?.mediaPath;
const hermesMediaUrl = hermesCurrentImage ? hermesCtx.MediaUrl : hermesFollowup.entry?.mediaUrl;
const hermesMediaType = hermesCurrentImage ? hermesCtx.MediaType : hermesFollowup.entry?.mediaType;
const hermesRouteCtx = {
to: full.from_user_id ?? "",
contextToken: full.context_token,
baseUrl: deps.baseUrl,
token: deps.token,
accountId: deps.accountId,
log: deps.log,
errLog: deps.errLog,
messageType: hermesMessageType,
mediaPath: hermesMediaPath,
mediaUrl: hermesMediaUrl,
mediaType: hermesMediaType,
imageSource: hermesImageSource,
imageFollowup: hermesFollowup.imageIntent,
imageFollowupReason: hermesFollowup.reason,
imageFollowupStrategy: hermesFollowup.cacheStrategy,
quotedMessageId: hermesFollowup.quotedMessageId ?? hermesQuotedMessageId,
cachedImageAgeMs: hermesFollowup.ageMs,
rawMessage: full,
inboundContext: ctx,
};
if (hermesExplicitSlash) {
const hermesSlashResult = await handleSlashCommand(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
if (hermesSlashResult.handled) {
logger.info(`[weixin] Hermes slash command handled after media context, skipping AI pipeline`);
return;
}
}
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
if (hermesImageResult.handled) {
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
return;
}
if (hermesFollowup.imageIntent) {
logger.error(`[weixin] Hermes image follow-up failed to route, stopping direct AI pipeline reason=${hermesFollowup.reason}`);
return;
}
}
// AIOS Hermes image ingress hook end
'''.strip()
JS_PROCESS_HOOK = r'''
// AIOS Hermes image ingress hook begin
const hermesSenderId = full.from_user_id ?? "";
rememberHermesLastImage(deps.accountId, hermesSenderId, ctx, full, mediaItem);
const hermesContent = rawBody || textBody;
const hermesExplicitSlash = isHermesSlashCommandText(hermesContent);
const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);
const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);
const hermesQuotedCreateTimeMs = findHermesReferencedCreateTimeMs(full.item_list);
rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody);
const hermesCachedQuotedText = resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs);
const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));
const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;
const hermesCurrentImage = hermesCurrentMessageIsImage(ctx, mediaItem);
const hermesFollowup = hermesCurrentImage
? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }
: (hermesReferencedMediaIsImage
? resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId, false, true)
: (hermesReferencedImageFollowupIntent
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
const hermesQuotedTextContext = hermesReferenceContextPresent &&
!hermesExplicitSlash &&
!hermesCurrentImage &&
!hermesReferencedMediaIsImage &&
!hermesFollowup.matched
? resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" };
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
ctx.Body = hermesQuotedTextContext.prompt;
ctx.CommandBody = hermesQuotedTextContext.prompt;
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
}
if (hermesFollowup.reason === "referenced_image_unresolved") {
writeHermesMediaDebug("referenced_image_unresolved", {
accountId: deps.accountId,
senderId: hermesSenderId,
messageId: String(full?.message_id ?? full?.id ?? "") || undefined,
quotedMessageId: hermesQuotedMessageId || undefined,
cacheStrategy: hermesFollowup.cacheStrategy,
itemList: sanitizeHermesMediaDebug(full.item_list),
rawMessage: sanitizeHermesMediaDebug(full),
});
}
if (hermesFollowup.imageIntent) {
logger.info(`[weixin] Hermes image follow-up ${hermesFollowup.matched ? "matched cached media" : "has no cached media, fail-closed"} reason=${hermesFollowup.reason} ageMs=${hermesFollowup.ageMs ?? "none"} cache_strategy=${hermesFollowup.cacheStrategy ?? "none"} quoted_message_id=${hermesFollowup.quotedMessageId ?? hermesQuotedMessageId ?? "none"}`);
}
const hermesMessageType = hermesCurrentImage || hermesFollowup.imageIntent ? "image" : undefined;
const hermesImageSource = hermesCurrentImage ? (refMediaItem ? "quoted" : "current") : (hermesFollowup.reason === "referenced_image_unresolved" ? "quoted_unresolved" : (hermesFollowup.matched ? "cache" : "none"));
const hermesMediaPath = hermesCurrentImage ? ctx.MediaPath : hermesFollowup.entry?.mediaPath;
const hermesMediaUrl = hermesCurrentImage ? ctx.MediaUrl : hermesFollowup.entry?.mediaUrl;
const hermesMediaType = hermesCurrentImage ? ctx.MediaType : hermesFollowup.entry?.mediaType;
const hermesRouteCtx = {
to: full.from_user_id ?? "",
contextToken: full.context_token,
baseUrl: deps.baseUrl,
token: deps.token,
accountId: deps.accountId,
log: deps.log,
errLog: deps.errLog,
messageType: hermesMessageType,
mediaPath: hermesMediaPath,
mediaUrl: hermesMediaUrl,
mediaType: hermesMediaType,
imageSource: hermesImageSource,
imageFollowup: hermesFollowup.imageIntent,
imageFollowupReason: hermesFollowup.reason,
imageFollowupStrategy: hermesFollowup.cacheStrategy,
quotedMessageId: hermesFollowup.quotedMessageId ?? hermesQuotedMessageId,
cachedImageAgeMs: hermesFollowup.ageMs,
rawMessage: full,
inboundContext: ctx,
};
if (hermesExplicitSlash) {
const hermesSlashResult = await handleSlashCommand(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
if (hermesSlashResult.handled) {
logger.info(`[weixin] Hermes slash command handled after media context, skipping AI pipeline`);
return;
}
}
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
if (hermesImageResult.handled) {
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
return;
}
if (hermesFollowup.imageIntent) {
logger.error(`[weixin] Hermes image follow-up failed to route, stopping direct AI pipeline reason=${hermesFollowup.reason}`);
return;
}
}
// AIOS Hermes image ingress hook end
'''.strip()
TS_QUOTED_TEXT_DISPATCH_GUARD = r'''
// AIOS quoted text dispatch guard begin
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
markDispatchIdle();
return;
}
// AIOS quoted text dispatch guard end
'''.strip()
JS_QUOTED_TEXT_DISPATCH_GUARD = r'''
// AIOS quoted text dispatch guard begin
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
markDispatchIdle();
return;
}
// AIOS quoted text dispatch guard end
'''.strip()
TS_HANDLE_SLASH_COMMAND = r'''
export async function handleSlashCommand(
content: string,
ctx: SlashCommandContext,
receivedAt: number,
eventTimestamp?: number,
): Promise<SlashCommandResult> {
const trimmed = content.trim();
if (!trimmed.startsWith("/")) {
return handleHermesImplicitImage(content, ctx, receivedAt, eventTimestamp);
}
const commandMatch = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
const command = (commandMatch?.[1] ?? "").toLowerCase();
const args = commandMatch?.[2] ?? "";
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
try {
switch (command) {
case "/hermes":
await handleHermes(ctx, args, receivedAt, eventTimestamp);
return { handled: true };
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 {
// The original command has already failed; preserve the root error in logs.
}
return { handled: true };
}
}
'''.strip()
JS_HANDLE_SLASH_COMMAND = r'''
export async function handleSlashCommand(content, ctx, receivedAt, eventTimestamp) {
const trimmed = content.trim();
if (!trimmed.startsWith("/")) {
return handleHermesImplicitImage(content, ctx, receivedAt, eventTimestamp);
}
const commandMatch = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
const command = (commandMatch?.[1] ?? "").toLowerCase();
const args = commandMatch?.[2] ?? "";
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
try {
switch (command) {
case "/hermes":
await handleHermes(ctx, args, receivedAt, eventTimestamp);
return { handled: true };
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 };
}
}
'''.strip()
TS_CHANNEL_OUTBOUND_CACHE_HELPERS = r'''
// AIOS Hermes outbound image cache helpers begin
const DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
function hermesOutboundImageCachePath(): string {
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH).trim();
}
function hermesOutboundCacheKey(accountId: string, to: string): string {
return `${accountId || "unknown"}:${to || "unknown"}`;
}
function hermesOutboundExactCacheKey(accountId: string, to: string, messageId: string): string {
return `${hermesOutboundCacheKey(accountId, to)}:${messageId || "unknown-message"}`;
}
function hermesOutboundImageMimeFromPath(filePath: string): string {
const lower = String(filePath ?? "").toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".webp")) return "image/webp";
return "image/jpeg";
}
function hermesOutboundPathLooksLikeImage(filePath: string): boolean {
return /\.(?:jpe?g|png|webp)$/i.test(String(filePath ?? "").trim());
}
function readHermesOutboundImageCache(): Record<string, any> {
try {
const filePath = hermesOutboundImageCachePath();
if (!filePath || !existsSync(filePath)) return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch (err) {
logger.warn(`[weixin] Hermes outbound image cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesOutboundImageCache(cache: Record<string, any>): void {
try {
const filePath = hermesOutboundImageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
} catch (err) {
logger.warn(`[weixin] Hermes outbound image cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function rememberHermesOutboundImage(accountId: string, to: string, filePath: string, messageId: string): void {
const normalizedPath = String(filePath ?? "").trim();
const normalizedMessageId = String(messageId ?? "").trim();
if (!normalizedPath || !normalizedMessageId || !hermesOutboundPathLooksLikeImage(normalizedPath)) return;
const cache = readHermesOutboundImageCache();
const baseKey = hermesOutboundCacheKey(accountId, to);
const shortMessageId = normalizedMessageId.replace(/^openclaw-weixin:/, "");
const entry = {
mediaPath: normalizedPath,
mediaType: hermesOutboundImageMimeFromPath(normalizedPath),
messageId: normalizedMessageId,
timestampMs: Date.now(),
source: "outbound_channel_send",
};
cache[baseKey] = entry;
cache[hermesOutboundExactCacheKey(accountId, to, normalizedMessageId)] = entry;
if (shortMessageId && shortMessageId !== normalizedMessageId) {
cache[hermesOutboundExactCacheKey(accountId, to, shortMessageId)] = entry;
}
writeHermesOutboundImageCache(cache);
logger.info(`[weixin] Hermes outbound image cache updated accountId=${accountId} to=${to} messageId=${normalizedMessageId} hasMediaPath=true exactKey=true`);
}
// AIOS Hermes outbound image cache helpers end
'''.strip()
JS_CHANNEL_OUTBOUND_CACHE_HELPERS = r'''
// AIOS Hermes outbound image cache helpers begin
const DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
function hermesOutboundImageCachePath() {
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH).trim();
}
function hermesOutboundCacheKey(accountId, to) {
return `${accountId || "unknown"}:${to || "unknown"}`;
}
function hermesOutboundExactCacheKey(accountId, to, messageId) {
return `${hermesOutboundCacheKey(accountId, to)}:${messageId || "unknown-message"}`;
}
function hermesOutboundImageMimeFromPath(filePath) {
const lower = String(filePath ?? "").toLowerCase();
if (lower.endsWith(".png"))
return "image/png";
if (lower.endsWith(".webp"))
return "image/webp";
return "image/jpeg";
}
function hermesOutboundPathLooksLikeImage(filePath) {
return /\.(?:jpe?g|png|webp)$/i.test(String(filePath ?? "").trim());
}
function readHermesOutboundImageCache() {
try {
const filePath = hermesOutboundImageCachePath();
if (!filePath || !existsSync(filePath))
return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}
catch (err) {
logger.warn(`[weixin] Hermes outbound image cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesOutboundImageCache(cache) {
try {
const filePath = hermesOutboundImageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
}
catch (err) {
logger.warn(`[weixin] Hermes outbound image cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function rememberHermesOutboundImage(accountId, to, filePath, messageId) {
const normalizedPath = String(filePath ?? "").trim();
const normalizedMessageId = String(messageId ?? "").trim();
if (!normalizedPath || !normalizedMessageId || !hermesOutboundPathLooksLikeImage(normalizedPath))
return;
const cache = readHermesOutboundImageCache();
const baseKey = hermesOutboundCacheKey(accountId, to);
const shortMessageId = normalizedMessageId.replace(/^openclaw-weixin:/, "");
const entry = {
mediaPath: normalizedPath,
mediaType: hermesOutboundImageMimeFromPath(normalizedPath),
messageId: normalizedMessageId,
timestampMs: Date.now(),
source: "outbound_channel_send",
};
cache[baseKey] = entry;
cache[hermesOutboundExactCacheKey(accountId, to, normalizedMessageId)] = entry;
if (shortMessageId && shortMessageId !== normalizedMessageId) {
cache[hermesOutboundExactCacheKey(accountId, to, shortMessageId)] = entry;
}
writeHermesOutboundImageCache(cache);
logger.info(`[weixin] Hermes outbound image cache updated accountId=${accountId} to=${to} messageId=${normalizedMessageId} hasMediaPath=true exactKey=true`);
}
// AIOS Hermes outbound image cache helpers end
'''.strip()
def read_text(path: pathlib.Path) -> str:
return path.read_text(encoding="utf-8")
def write_if_changed(path: pathlib.Path, text: str, original: str) -> bool:
if text == original:
return False
path.write_text(text, encoding="utf-8")
return True
def ensure_import(text: str, import_line: str, anchor: str) -> str:
if import_line in text:
return text
if anchor in text:
return text.replace(anchor, import_line + anchor, 1)
return import_line + text
def replace_or_insert_hermes_helpers(text: str, helpers: str) -> str:
start = text.find("const DEFAULT_HERMES_ROUTER_URL")
if start >= 0:
end = text.find("async function sendReply", start)
if end < 0:
raise RuntimeError("sendReply not found after existing Hermes helper block")
return text[:start] + helpers + "\n\n" + text[end:]
marker = "async function sendReply"
if marker not in text:
raise RuntimeError("sendReply not found for Hermes helper insertion")
return text.replace(marker, helpers + "\n\n" + marker, 1)
def ensure_ts_context_fields(text: str) -> str:
if "mediaPath?: string;" in text:
return text
needle = " errLog: (msg: string) => void;\n}"
replacement = (
" errLog: (msg: string) => void;\n"
" messageType?: string;\n"
" mediaPath?: string;\n"
" mediaUrl?: string;\n"
" mediaType?: string;\n"
" rawMessage?: unknown;\n"
" inboundContext?: unknown;\n"
"}"
)
if needle not in text:
return text
return text.replace(needle, replacement, 1)
def ensure_hermes_case(text: str) -> str:
if 'case "/hermes":' in text:
return text
pattern = re.compile(r'(?m)^(\s*)case "/echo":')
match = pattern.search(text)
if not match:
raise RuntimeError("slash command /echo case not found")
indent = match.group(1)
stmt_indent = indent + (" " if len(indent) <= 6 else " ")
hermes_case = (
f'{indent}case "/hermes":\n'
f'{stmt_indent}await handleHermes(ctx, args, receivedAt, eventTimestamp);\n'
f'{stmt_indent}return {{ handled: true }};\n'
f'{indent}case "/echo":'
)
return pattern.sub(hermes_case, text, count=1)
def replace_non_slash_branch(text: str, is_ts: bool) -> str:
var_match = re.search(r"const trimmed = (content|text|input)\.trim\(\);", text)
if not var_match:
raise RuntimeError("handleSlashCommand trim source not found")
source_var = var_match.group(1)
replacement = (
' if (!trimmed.startsWith("/")) {\n'
f' return handleHermesImplicitImage({source_var}, ctx, receivedAt, eventTimestamp);\n'
' }\n'
if is_ts
else ' if (!trimmed.startsWith("/")) {\n'
f' return handleHermesImplicitImage({source_var}, ctx, receivedAt, eventTimestamp);\n'
' }\n'
)
pattern = re.compile(
r'\s*if \(!trimmed\.startsWith\("/"\)\) \{.*?\n\s*\}\n',
re.S,
)
text, count = pattern.subn(replacement, text, count=1)
if count == 0:
raise RuntimeError("handleSlashCommand non-slash branch not found")
return text
def replace_handle_slash_command(text: str, is_ts: bool) -> str:
start = text.find("export async function handleSlashCommand")
if start < 0:
raise RuntimeError("handleSlashCommand function not found")
source_map_start = text.find("\n//# sourceMappingURL", start)
if source_map_start >= 0:
return text[:start] + (TS_HANDLE_SLASH_COMMAND if is_ts else JS_HANDLE_SLASH_COMMAND) + text[source_map_start:]
return text[:start] + (TS_HANDLE_SLASH_COMMAND if is_ts else JS_HANDLE_SLASH_COMMAND) + "\n"
def replace_or_insert_process_helpers(text: str, is_ts: bool) -> str:
start_marker = "// AIOS Hermes image follow-up helpers begin"
end_marker = "// AIOS Hermes image follow-up helpers end"
helpers = TS_PROCESS_HELPERS if is_ts else JS_PROCESS_HELPERS
start = text.find(start_marker)
if start >= 0:
end = text.find(end_marker, start)
if end < 0:
raise RuntimeError("unterminated Hermes process helper block")
end = text.find("\n", end)
if end < 0:
end = len(text)
else:
end += 1
while end < len(text) and text[end] == "\n":
end += 1
text = text[:start] + helpers + "\n\n" + text[end:]
return text
marker = "export async function processOneMessage"
if marker not in text:
raise RuntimeError("processOneMessage function not found for Hermes helper insertion")
return text.replace(marker, helpers + "\n\n" + marker, 1)
def replace_or_insert_channel_outbound_helpers(text: str, is_ts: bool) -> str:
start_marker = "// AIOS Hermes outbound image cache helpers begin"
end_marker = "// AIOS Hermes outbound image cache helpers end"
helpers = TS_CHANNEL_OUTBOUND_CACHE_HELPERS if is_ts else JS_CHANNEL_OUTBOUND_CACHE_HELPERS
start = text.find(start_marker)
if start >= 0:
end = text.find(end_marker, start)
if end < 0:
raise RuntimeError("unterminated Hermes outbound image cache helper block")
end = text.find("\n", end)
if end < 0:
end = len(text)
else:
end += 1
return text[:start] + helpers + "\n\n" + text[end:]
marker = "const MEDIA_OUTBOUND_TEMP_DIR ="
idx = text.find(marker)
if idx < 0:
raise RuntimeError("MEDIA_OUTBOUND_TEMP_DIR not found for outbound cache helper insertion")
line_end = text.find("\n", idx)
if line_end < 0:
line_end = len(text)
return text[: line_end + 1] + "\n" + helpers + "\n\n" + text[line_end + 1 :]
def patch_channel_outbound_cache(text: str) -> str:
if "rememberHermesOutboundImage(account.accountId, ctx.to, filePath, result.messageId);" in text:
return text
pattern = re.compile(
r'(?m)^([ \t]*)emitWeixinMessageSent\(\{ to: ctx\.to, content: text, success: true, accountId: account\.accountId \}\);\n'
r'\1return \{ channel: "openclaw-weixin", messageId: result\.messageId \};'
)
match = pattern.search(text)
if match:
indent = match.group(1)
replacement = (
f"{indent}rememberHermesOutboundImage(account.accountId, ctx.to, filePath, result.messageId);\n"
f"{indent}emitWeixinMessageSent({{ to: ctx.to, content: text, success: true, accountId: account.accountId }});\n"
f'{indent}return {{ channel: "openclaw-weixin", messageId: result.messageId }};'
)
return text[: match.start()] + replacement + text[match.end() :]
raise RuntimeError("sendWeixinMediaFile success branch not found for outbound cache patch")
def remove_managed_process_block(text: str, start_marker: str, end_marker: str, label: str) -> str:
while True:
start = text.find(start_marker)
if start < 0:
return text
line_start = text.rfind("\n", 0, start)
block_start = 0 if line_start < 0 else line_start + 1
if line_start > 0 and text[line_start - 1] == "\n":
block_start = line_start
end = text.find(end_marker, start)
if end < 0:
raise RuntimeError(f"unterminated {label} block")
end = text.find("\n", end)
block_end = len(text) if end < 0 else end + 1
while block_end < len(text) and text[block_end] == "\n":
block_end += 1
text = text[:block_start] + text[block_end:]
def remove_existing_process_hermes_hook(text: str) -> str:
text = remove_managed_process_block(
text,
"// AIOS Hermes image ingress hook begin",
"// AIOS Hermes image ingress hook end",
"Hermes process hook",
)
text = remove_managed_process_block(
text,
"// AIOS quoted text dispatch guard begin",
"// AIOS quoted text dispatch guard end",
"quoted text dispatch guard",
)
old_hook_pattern = re.compile(
r'\n\s*if \(!textBody\.startsWith\("/"\)\) \{\n'
r'.*?logger\.info\(`\[weixin\] Hermes implicit image handled, skipping AI pipeline`\);\n'
r'\s*return;\n'
r'\s*\}\n'
r'\s*\}\n',
re.S,
)
text, count = old_hook_pattern.subn("\n", text, count=1)
return text
def defer_hermes_slash_until_media_context(text: str) -> str:
text = text.replace(
'if (textBody.startsWith("/")) {',
'if (textBody.startsWith("/") && !isHermesSlashCommandText(textBody)) {',
1,
)
return text
def prefer_recursive_referenced_media(text: str) -> str:
direct_ref_pattern = re.compile(
r'const refMediaItem = !mainMediaItem\s*'
r'\?\s*full\.item_list\?\.find\(\(i(?::\s*any)?\) => i\.type === MessageItemType\.TEXT &&\s*'
r'i\.ref_msg\?\.message_item &&\s*'
r'isMediaItem\(i\.ref_msg\.message_item\)\)\?\.ref_msg\?\.message_item\s*'
r':\s*undefined;'
)
text, _ = direct_ref_pattern.subn('const refMediaItem = findHermesReferencedMediaItem(full.item_list);', text, count=1)
text = text.replace(
'const refMediaItem = !mainMediaItem ? findHermesReferencedMediaItem(full.item_list) : undefined;',
'const refMediaItem = findHermesReferencedMediaItem(full.item_list);',
1,
)
text = text.replace(
'const mediaItem = mainMediaItem ?? refMediaItem;',
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem);',
1,
)
text = text.replace(
'const mediaItem = mainMediaItem ?? refMediaItem ?? { type: MessageItemType.IMAGE };',
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem) ?? { type: MessageItemType.IMAGE };',
1,
)
text = text.replace(
'const mediaItem = refMediaItem ?? mainMediaItem;',
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem);',
1,
)
text = text.replace(
'const mediaItem = refMediaItem ?? mainMediaItem ?? { type: MessageItemType.IMAGE };',
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem) ?? { type: MessageItemType.IMAGE };',
1,
)
return text
def patch_slash_command_file(path: pathlib.Path, is_ts: bool) -> bool:
text = read_text(path)
original = text
if is_ts:
text = ensure_import(text, 'import { existsSync, readFileSync, statSync } from "node:fs";\n', 'import type { WeixinApiOptions } from "../api/api.js";\n')
text = ensure_import(text, 'import { randomBytes } from "node:crypto";\n', 'import type { WeixinApiOptions } from "../api/api.js";\n')
text = ensure_ts_context_fields(text)
text = replace_or_insert_hermes_helpers(text, TS_HERMES_HELPERS)
else:
text = ensure_import(text, 'import { existsSync, readFileSync, statSync } from "node:fs";\n', 'import { logger } from "../util/logger.js";\n')
text = ensure_import(text, 'import { randomBytes } from "node:crypto";\n', 'import { logger } from "../util/logger.js";\n')
text = replace_or_insert_hermes_helpers(text, JS_HERMES_HELPERS)
text = replace_handle_slash_command(text, is_ts=is_ts)
return write_if_changed(path, text, original)
def patch_process_message_file(path: pathlib.Path, is_ts: bool) -> bool:
text = read_text(path)
original = text
fs_import = 'import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";\n'
text = text.replace(
'import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";',
'import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";',
)
text = ensure_import(text, fs_import, 'import path from "node:path";\n')
text = ensure_import(text, 'import path from "node:path";\n', 'import { randomUUID } from "node:crypto";\n')
text = text.replace(
'import { handleSlashCommand } from "./slash-commands.js";',
'import { handleHermesImplicitImage, handleSlashCommand } from "./slash-commands.js";',
1,
)
if "handleHermesImplicitImage" not in text:
raise RuntimeError("process-message slash import not found")
text = replace_or_insert_process_helpers(text, is_ts=is_ts)
text = remove_existing_process_hermes_hook(text)
text = defer_hermes_slash_until_media_context(text)
text = prefer_recursive_referenced_media(text)
hook = TS_PROCESS_HOOK if is_ts else JS_PROCESS_HOOK
needle = " ctx.CommandAuthorized = commandAuthorized;\n" if is_ts else " ctx.CommandAuthorized = commandAuthorized;\n"
if needle not in text:
raise RuntimeError("ctx.CommandAuthorized assignment not found")
text = text.replace(needle, needle + hook + "\n", 1)
dispatch_guard = TS_QUOTED_TEXT_DISPATCH_GUARD if is_ts else JS_QUOTED_TEXT_DISPATCH_GUARD
dispatch_needle = (
' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
if is_ts
else ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
)
if dispatch_needle not in text:
raise RuntimeError("dispatchReplyFromConfig start log not found")
text = text.replace("\n\n" + dispatch_needle, "\n" + dispatch_needle, 1)
text = text.replace(dispatch_needle, dispatch_guard + "\n" + dispatch_needle, 1)
return write_if_changed(path, text, original)
def patch_channel_file(path: pathlib.Path, is_ts: bool) -> bool:
text = read_text(path)
original = text
fs_import = 'import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";\n'
text = ensure_import(text, fs_import, 'import path from "node:path";\n')
text = replace_or_insert_channel_outbound_helpers(text, is_ts=is_ts)
text = patch_channel_outbound_cache(text)
return write_if_changed(path, text, original)
def patch_ts(root: pathlib.Path) -> bool:
return patch_slash_command_file(root / "src/messaging/slash-commands.ts", is_ts=True)
def patch_js(root: pathlib.Path) -> bool:
return patch_slash_command_file(root / "dist/src/messaging/slash-commands.js", is_ts=False)
def patch_process_messages(root: pathlib.Path) -> dict[str, bool]:
changes: dict[str, bool] = {}
for rel, is_ts in (
("src/messaging/process-message.ts", True),
("dist/src/messaging/process-message.js", False),
):
path = root / rel
if path.exists():
changes[rel] = patch_process_message_file(path, is_ts=is_ts)
else:
changes[rel] = False
return changes
def patch_channel_files(root: pathlib.Path) -> dict[str, bool]:
changes: dict[str, bool] = {}
for rel, is_ts in (
("src/channel.ts", True),
("dist/src/channel.js", False),
):
path = root / rel
if path.exists():
changes[rel] = patch_channel_file(path, is_ts=is_ts)
return changes
def patch_existing_runtime_behavior(root: pathlib.Path) -> dict[str, bool]:
# Compatibility entry point kept for older tests and operators.
changes = patch_process_messages(root)
changes.update({f"channel:{key}": value for key, value in patch_channel_files(root).items()})
return changes
def source_files(root: pathlib.Path) -> list[pathlib.Path]:
files: list[pathlib.Path] = []
for rel_root in ("src", "dist/src"):
base = root / rel_root
if not base.exists():
continue
files.extend(path for path in base.rglob("*") if path.suffix in {".ts", ".js", ".mjs", ".cjs"})
return sorted(set(files))
def verify_plugin_hook(root: pathlib.Path) -> dict[str, object]:
hook_files = [
root / "src/messaging/slash-commands.ts",
root / "dist/src/messaging/slash-commands.js",
]
hook_status: dict[str, bool] = {}
for path in hook_files:
if not path.exists():
hook_status[str(path.relative_to(root))] = False
continue
text = path.read_text(encoding="utf-8", errors="replace")
hook_status[str(path.relative_to(root))] = (
"export async function handleHermesImplicitImage" in text
and "shouldAutoRouteHermesImage" in text
and "localHermesImageToDataUrl" in text
and 'case "/hermes":' in text
and "return handleHermesImplicitImage(" in text
)
process_files = [
root / "src/messaging/process-message.ts",
root / "dist/src/messaging/process-message.js",
]
process_status: dict[str, bool] = {}
for path in process_files:
if not path.exists():
process_status[str(path.relative_to(root))] = False
continue
text = path.read_text(encoding="utf-8", errors="replace")
process_status[str(path.relative_to(root))] = (
"handleHermesImplicitImage" in text
and "Hermes implicit image handled, skipping AI pipeline" in text
and "rememberHermesLastImage" in text
and "resolveHermesFollowupImageContext" in text
and "matched cached media" in text
and "has no cached media, fail-closed" in text
and "resolveHermesQuotedTextContext" in text
and "dispatchHermesQuotedTextFailClosed" in text
and "AIOS quoted text dispatch guard begin" in text
and "无法读取被引用文字,请复制原文后再提问。" in text
and "mediaPath" in text
)
channel_files = [
root / "src/channel.ts",
root / "dist/src/channel.js",
]
channel_status: dict[str, bool] = {}
for path in channel_files:
if not path.exists():
continue
text = path.read_text(encoding="utf-8", errors="replace")
channel_status[str(path.relative_to(root))] = (
"rememberHermesOutboundImage" in text
and "hermesOutboundExactCacheKey" in text
and "outbound_channel_send" in text
and "Hermes outbound image cache updated" in text
and "sendWeixinMediaFile({" in text
)
dispatch_refs: list[str] = []
for path in source_files(root):
rel = str(path.relative_to(root)).replace("\\", "/")
if rel in {
"src/messaging/slash-commands.ts",
"dist/src/messaging/slash-commands.js",
}:
continue
text = path.read_text(encoding="utf-8", errors="replace")
if "handleHermesImplicitImage" in text or "handleSlashCommand" in text or "slash-commands" in text:
dispatch_refs.append(rel)
return {
"hook_status": hook_status,
"hook_installed": all(hook_status.values()) if hook_status else False,
"process_status": process_status,
"process_hook_installed": all(process_status.values()) if process_status else False,
"channel_status": channel_status,
"channel_hook_installed": all(channel_status.values()) if channel_status else True,
"dispatch_reference_found": bool(dispatch_refs),
"dispatch_reference_files": dispatch_refs[:20],
}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog='patch_openclaw_weixin_hermes.py')
parser.add_argument('plugin_root', nargs='?', type=pathlib.Path, default=DEFAULT_PLUGIN_ROOT)
parser.add_argument('--process-only', action='store_true', help='only patch process-message files')
return parser.parse_args(argv)
def patch_plugin(root: pathlib.Path, process_only: bool = False) -> dict[str, object]:
mode = 'process-only' if process_only else 'full'
changed_ts = False
changed_js = False
slash_changes: dict[str, bool] = {}
process_changes: dict[str, bool] = {}
channel_changes: dict[str, bool] = {}
if process_only:
process_changes = patch_process_messages(root)
else:
changed_ts = patch_ts(root)
changed_js = patch_js(root)
process_changes = patch_process_messages(root)
channel_changes = patch_channel_files(root)
slash_changes = {
'src/messaging/slash-commands.ts': changed_ts,
'dist/src/messaging/slash-commands.js': changed_js,
}
verification = verify_plugin_hook(root)
result = {
'plugin_root': str(root),
'mode': mode,
'changed_ts': changed_ts,
'changed_js': changed_js,
'slash_changes': slash_changes,
'process_changes': process_changes,
'channel_changes': channel_changes,
'behavior_changes': process_changes if process_only else {**process_changes, **channel_changes},
'verification': verification,
}
return result
def main() -> int:
args = parse_args()
root = args.plugin_root
if not root.exists():
raise SystemExit(f'plugin root not found: {root}')
result = patch_plugin(root, process_only=args.process_only)
print(json.dumps(result, ensure_ascii=False))
verification = result['verification']
if args.process_only:
if not verification.get('process_hook_installed'):
raise SystemExit('process-message implicit image dispatch hook not installed')
return 0
if not verification.get('hook_installed'):
raise SystemExit('Hermes slash/image hook not installed')
if not verification.get('process_hook_installed'):
raise SystemExit('process-message implicit image dispatch hook not installed')
if not verification.get('channel_hook_installed'):
raise SystemExit('channel outbound image cache hook not installed')
if not verification.get('dispatch_reference_found'):
raise SystemExit('dispatch reference to Hermes slash/image hook not found')
return 0
if __name__ == '__main__':
raise SystemExit(main())