Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 114 additions & 33 deletions packages/channels/weixin/src/WeixinAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@ import type {
import { loadAccount, DEFAULT_BASE_URL } from './accounts.js';
import { startPollLoop, getContextToken } from './monitor.js';
import type { CdnRef, FileCdnRef } from './monitor.js';
import { sendText } from './send.js';
import { sendText, sendImage, detectImageMime } from './send.js';
import { downloadAndDecrypt } from './media.js';
import { getConfig, sendTyping } from './api.js';
import { getConfig, sendTyping, WeixinApiError } from './api.js';
import { TypingStatus } from './types.js';

/** In-memory typing ticket cache: userId -> typingTicket */
const typingTickets = new Map<string, string>();

/** Escape special regex characters in a string. */
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export class WeixinChannel extends ChannelBase {
private abortController: AbortController | null = null;
private baseUrl: string;
Expand All @@ -43,6 +48,35 @@ export class WeixinChannel extends ChannelBase {
}

async connect(): Promise<void> {
// Default channel instructions — always include image capability info
const imageInstructions = [
'',
'If you created an image file (screenshot, chart, etc.), you can send it to the user by writing:',
'[IMAGE: /absolute/path/to/file.png]',
'',
'The marker is stripped from text and the image is uploaded automatically.',
'',
'CRITICAL: Only use real file paths. Do NOT write [IMAGE: ...] with:',
'- Example paths like /path/to/file or /tmp/cat.png',
'- Placeholder symbols like ...',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] connect() 重复调用会污染 config.instructions

this.config.instructions += '\n' + imageInstructions 在原地修改配置对象。通道重连时(崩溃恢复),imageInstructions 会被重复追加,使配置持续膨胀。

Suggested change
'- Placeholder symbols like ...',
// 使用局部变量,不修改 this.config
const instructions = this.config.instructions + '\n' + imageInstructions;

— deepseek-v4-pro via Qwen Code /review

"- Paths that don't exist on disk",
].join('\n');

if (!this.config.instructions) {
this.config.instructions = [
'## WeChat Channel',
'',
'You are a concise coding assistant responding via WeChat.',
'Keep responses under 500 characters. Use plain text only.',
'',
'Users can also send you images.',
imageInstructions,
].join('\n');
} else if (!this.config.instructions.includes('[IMAGE:')) {
// Use a local copy to avoid mutating this.config.instructions on reconnect.
this.config.instructions =
this.config.instructions + '\n' + imageInstructions;
}
const account = loadAccount();
if (!account) {
throw new Error(
Expand Down Expand Up @@ -158,13 +192,84 @@ export class WeixinChannel extends ChannelBase {

async sendMessage(chatId: string, text: string): Promise<void> {
const contextToken = getContextToken(chatId) || '';
await sendText({
to: chatId,
text,
baseUrl: this.baseUrl,
token: this.token,
contextToken,
});

// Parse [IMAGE: /path/to/file.png] markers from text.
// Strip code blocks first to avoid matching example syntax inside them.
const textWithoutCode = text
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] 代码块内 [IMAGE:] 标记被静默剥离 — 数据丢失

正则替换 text.replace(imageRegex, '') 作用于包含代码块的原始文本。代码块内的标记被移除但不解析为图片,用户看到的内容被静默篡改。

Suggested change
// 仅替换实际解析为图片的标记,而非全局替换
let cleanedText = text;
for (const img of parsedImages) {
cleanedText = cleanedText.replace(/\[IMAGE:\s*[^\]]+\]/i, '');
}

— deepseek-v4-pro via Qwen Code /review

// Extract image paths from code-free text.
const imageRegex = /\[IMAGE:\s*([^\]]+)\]/gi;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] [IMAGE:] regex matches inside code blocks and produces empty paths

Two issues with the regex \[IMAGE:\s*([^\]]+)\]:

  1. If the AI explains the syntax inside a code block (`[IMAGE: /tmp/example.png]`), the marker is extracted and the code block text is corrupted.
  2. [IMAGE: ] captures a space, which .trim() converts to '', then readFileSync('') throws a confusing error.

For (2), filter empty paths after trimming:

Suggested change
const imageRegex = /\[IMAGE:\s*([^\]]+)\]/gi;
let cleanedText = text.replace(imageRegex, (_, path: string) => {
const trimmed = path.trim();
if (trimmed) parsedImages.push(trimmed);
return '';
});

For (1), consider stripping code blocks before running the regex, or extracting the parsing into a testable pure function.

— pai/glm-5 via Qwen Code /review

const parsedImages: string[] = [];
for (const m of textWithoutCode.matchAll(imageRegex)) {
const trimmed = m[1]?.trim();
if (trimmed) parsedImages.push(trimmed);
}

// Only strip markers that were actually parsed (avoids silently
// removing [IMAGE:] inside code blocks from the displayed text).
let cleanedText = text;
for (const path of parsedImages) {
cleanedText = cleanedText.replace(
new RegExp(`\\[IMAGE:\\s*${escapeRegex(path)}\\]`, 'gi'),
'',
);
}

// Clean up double blank lines left by removed markers
cleanedText = cleanedText.replace(/\n{3,}/g, '\n\n').trim();

// Send text first if non-empty
if (cleanedText) {
await sendText({
to: chatId,
text: cleanedText,
baseUrl: this.baseUrl,
token: this.token,
contextToken,
});
}

// Send images
if (parsedImages.length) {
const workspaceDirs = [this.config.cwd];

for (const imagePath of parsedImages) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] API errmsg 敏感信息泄露到 stderr

process.stderr.write(\[Weixin:${this.name}] Failed to send image ${imagePath}: ${errMsg}\n`)将原始 API 错误消息(含errmsg`)写入 stderr。若 WeChat API 在错误响应中返回 token 或用户标识等敏感数据,会被记录到日志聚合系统。

Suggested change
for (const imagePath of parsedImages) {
`[Weixin:${this.name}] Failed to send image (status=${err.status} ret=${err.ret})`

— deepseek-v4-pro via Qwen Code /review

try {
await sendImage({
to: chatId,
imagePath,
baseUrl: this.baseUrl,
token: this.token,
contextToken,
workspaceDirs,
});
} catch (err) {
const status = err instanceof WeixinApiError ? err.status : 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 错误日志丢失诊断信息 — 非 WeixinApiError 时完全不可用

错误日志从 Failed to send image ${imagePath}: ${errMsg} 改为 Failed to send image (status=${status} ret=${ret})。对于非 WeixinApiError 异常(文件 I/O 错误、路径校验失败等),日志输出无意义的 status=0 ret=undefined,无法区分文件不存在、权限拒绝还是网络超时。同时 errcode 字段虽已传入 WeixinApiError 但日志未提取。

Suggested change
const status = err instanceof WeixinApiError ? err.status : 0;
const status = err instanceof WeixinApiError ? err.status : 0;
const ret = err instanceof WeixinApiError ? err.ret : undefined;
const errcode = err instanceof WeixinApiError ? err.errcode : undefined;
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[Weixin:${this.name}] Failed to send image (status=${status} ret=${ret} errcode=${errcode}): ${msg}\n`,
);

— deepseek-v4-pro via Qwen Code /review

const ret = err instanceof WeixinApiError ? err.ret : undefined;
const errcode =
err instanceof WeixinApiError ? err.errcode : undefined;
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[Weixin:${this.name}] Failed to send image (status=${status} ret=${ret} errcode=${errcode}): ${msg}\n`,
);
try {
await sendText({
to: chatId,
text: '图片发送失败,请稍后重试',
baseUrl: this.baseUrl,
token: this.token,
contextToken,
});
} catch (fallbackErr) {
process.stderr.write(
`[Weixin:${this.name}] Fallback text also failed: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}\n`,
);
}
}
}
}
}

disconnect(): void {
Expand Down Expand Up @@ -202,27 +307,3 @@ export class WeixinChannel extends ChannelBase {
}
}
}

/** Detect image MIME type from magic bytes. */
function detectImageMime(data: Buffer): string {
if (
data[0] === 0x89 &&
data[1] === 0x50 &&
data[2] === 0x4e &&
data[3] === 0x47
) {
return 'image/png';
}
if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
return 'image/gif';
}
if (
data[0] === 0x52 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x46
) {
return 'image/webp';
}
return 'image/jpeg';
}
Loading
Loading