Skip to content
Open
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
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Send text, voice, or images on WeChat to chat normally. Additional commands:
| `/session` | Show session details |
| `/help` | Show help |

Advanced: `/thinking`, `/tools`, `/compact`.
Advanced: `/thinking`, `/compact`. `/tools` is disabled for WeChat by default; it can only be enabled on a local Ubuntu host with `PI_WECHAT_ALLOW_REMOTE_TOOLS=1`.

## Supported Message Types

Expand All @@ -119,13 +119,15 @@ Advanced: `/thinking`, `/tools`, `/compact`.
| `PI_WECHAT_DEBUG_FILE` | Debug log file path | `~/.pi/agent/wechat-assistant/debug.log` |
| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | Batch wait time for images | `8000` |
| `PI_WECHAT_IMAGE_MAX_BYTES` | Per-image size limit | `52428800` (50 MB) |
| `PI_WECHAT_ALLOW_REMOTE_TOOLS` | Enable WeChat `/tools` only on local Ubuntu | Disabled |

### Config Files

```
~/.pi/agent/wechat-assistant/
├── credentials.json # Login credentials (mode 600)
├── config.json # Auto-start, image limits
├── transport-state.json # iLink cursor and last 500 processed message IDs (mode 600)
└── session.lock # Exclusive lock file
```

Expand All @@ -148,9 +150,17 @@ WeChat ⇄ pi TUI session ⇄ AI model + tools
- WeChat messages are fetched via iLink Bot API long polling
- Incoming messages are injected into the active pi session via `pi.sendUserMessage()`
- When you type in TUI, a preview is sent to WeChat
- AI replies are delivered incrementally (per `message_end`) and finalized on `agent_end`
- AI replies are delivered only from assistant text emitted in the current turn's `message_end`
- Only the TUI session that runs `/wechat start` holds the connection

## Security boundaries

- Only the WeChat user ID embedded in the QR-login credential is accepted. Messages from every other ID are dropped before a context token is stored, a queue entry is created, or a reply is sent.
- The iLink cursor and the latest 500 processed message IDs are persisted in `transport-state.json`; a restart will not inject those messages again. `/wechat login --force` and `/wechat logout` reset this state.
- Replies are sent only from assistant text emitted in the current turn's `message_end`; restored session history is never replayed to WeChat.
- `send_file_to_wechat` and `send_image_to_wechat` accept only real, ordinary files inside the real project directory. Symbolic links are rejected.
- WeChat `/tools` is disabled by default. It requires `PI_WECHAT_ALLOW_REMOTE_TOOLS=1` on Ubuntu to opt in.

## FAQ

**WeChat not responding?** Run `/wechat status` to check. If the session expired, run `/wechat login --force`.
Expand Down Expand Up @@ -262,7 +272,7 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant
| `/session` | 查看会话详情 |
| `/help` | 显示帮助 |

高级命令:`/thinking`、`/tools`、`/compact`
高级命令:`/thinking`、`/compact`。微信端 `/tools` 默认禁用;仅 Ubuntu 本机设置 `PI_WECHAT_ALLOW_REMOTE_TOOLS=1` 后才可启用

## 支持的消息类型

Expand All @@ -285,13 +295,15 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant
| `PI_WECHAT_DEBUG_FILE` | 调试日志路径 | `~/.pi/agent/wechat-assistant/debug.log` |
| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | 图片批量等待时间 | `8000` |
| `PI_WECHAT_IMAGE_MAX_BYTES` | 单张图片大小上限 | `52428800`(50 MB) |
| `PI_WECHAT_ALLOW_REMOTE_TOOLS` | 仅 Ubuntu 本机启用微信 `/tools` | 默认禁用 |

### 配置文件

```
~/.pi/agent/wechat-assistant/
├── credentials.json # 登录凭证(权限 600)
├── config.json # 自动启动、图片限制
├── transport-state.json # iLink 游标及最近 500 条消息 ID(权限 600)
└── session.lock # 排他锁文件
```

Expand All @@ -304,9 +316,17 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant
- 微信消息通过 iLink Bot API 长轮询获取
- 收到的消息通过 `pi.sendUserMessage()` 注入当前 pi 会话
- TUI 输入时微信端会收到预览
- AI 回复增量发送(每条 `message_end` 即发),`agent_end` 时补发遗漏
- AI 回复仅从当前 turn 的 `message_end` 产生的 assistant 文本发送
- 只有执行 `/wechat start` 的 TUI 会话持有连接

## 安全边界

- 仅接受扫码凭证中绑定的微信用户 ID;其他 ID 的消息不会保存 context token、入队、注入 Agent 或自动回复。
- iLink 游标和最近 500 条已处理 message ID 保存于 `transport-state.json`,重启后不会重复注入;`/wechat login --force` 与 `/wechat logout` 会清除它。
- 仅转发当前 turn 的 `message_end` 实际产生的 assistant 文本,不会重发恢复 session 中的历史回复。
- `send_file_to_wechat` 与 `send_image_to_wechat` 仅允许真实项目目录内的普通文件,符号链接一律拒绝。
- 微信端 `/tools` 默认禁用,只有 Ubuntu 本机设置 `PI_WECHAT_ALLOW_REMOTE_TOOLS=1` 才能启用。

## 常见问题

**微信没有回复?** 执行 `/wechat status` 检查状态。Session 过期则执行 `/wechat login --force`。
Expand Down
29 changes: 29 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { DEFAULT_BASE_URL, fetchQrCode, getQrCodeStatus, type QrStatusResponse } from './api.js'
import { MAX_RECENT_MESSAGE_IDS, recordProcessedMessageId } from './security.js'
import type { Credentials } from './types.js'

// --- 路径 ---
Expand All @@ -15,6 +16,7 @@ const CREDS_FILE = path.join(STATE_DIR, 'credentials.json')
const CONFIG_FILE = path.join(STATE_DIR, 'config.json')
const LOCK_FILE = path.join(STATE_DIR, 'session.lock')
const CONTEXT_TOKENS_FILE = path.join(STATE_DIR, 'context-tokens.json')
const TRANSPORT_STATE_FILE = path.join(STATE_DIR, 'transport-state.json')

export function getStateDir(): string {
return STATE_DIR
Expand Down Expand Up @@ -42,6 +44,8 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await ensureStateDir()
await fs.writeFile(filePath, JSON.stringify(data, null, 2), { mode: 0o600 })
// writeFile 的 mode 不会修改已有文件;明确收紧已有状态文件权限。
await fs.chmod(filePath, 0o600)
}

async function deleteFile(filePath: string): Promise<void> {
Expand Down Expand Up @@ -70,6 +74,31 @@ export async function clearContextTokens(): Promise<void> {
await deleteFile(CONTEXT_TOKENS_FILE)
}

// --- iLink 长轮询传输状态 ---

export interface TransportState {
cursor: string
processedMessageIds: string[]
}

export async function loadTransportState(): Promise<TransportState> {
const data = await readJsonFile<Partial<TransportState>>(TRANSPORT_STATE_FILE)
const ids = Array.isArray(data?.processedMessageIds)
? data.processedMessageIds.filter((id): id is string => typeof id === 'string').slice(-MAX_RECENT_MESSAGE_IDS)
: []
return { cursor: typeof data?.cursor === 'string' ? data.cursor : '', processedMessageIds: ids }
}

export async function saveTransportState(state: TransportState): Promise<void> {
let ids: string[] = []
for (const id of state.processedMessageIds) ids = recordProcessedMessageId(ids, id)
await writeJsonFile(TRANSPORT_STATE_FILE, { cursor: state.cursor, processedMessageIds: ids })
}

export async function clearTransportState(): Promise<void> {
await deleteFile(TRANSPORT_STATE_FILE)
}

// --- 配置 ---

export interface BridgeConfig {
Expand Down
54 changes: 46 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ import {
loadContextTokens,
saveContextTokensThrottled,
flushContextTokens,
loadTransportState,
saveTransportState,
} from './auth.js'
import {
isAuthorizedWeChatSender,
isDuplicateMessageId,
MAX_RECENT_MESSAGE_IDS,
recordProcessedMessageId,
} from './security.js'
import {
CDN_BASE,
STREAM_ENCRYPTION_THRESHOLD,
Expand Down Expand Up @@ -128,6 +136,8 @@ export class WeixinClient {
private readonly token: string
private baseUrl: string
private cursor = ''
private readonly processedMessageIds = new Set<string>()
private processedMessageIdOrder: string[] = []
private readonly typingTickets = new Map<string, string>()
private readonly contextTokens = new Map<string, string>()
private _lastActiveUserId: string | null = null
Expand All @@ -147,10 +157,15 @@ export class WeixinClient {
}

private async _init(): Promise<void> {
const persisted = await loadContextTokens()
this._lastActiveUserId = persisted.lastUserId
for (const [userId, token] of Object.entries(persisted.tokens)) {
this.contextTokens.set(userId, token)
const [persisted, transport] = await Promise.all([loadContextTokens(), loadTransportState()])
// Context token 只允许属于当前扫码凭证绑定用户,避免旧凭证残留跨用户复用。
const token = persisted.tokens[this.userId]
if (token) this.contextTokens.set(this.userId, token)
this._lastActiveUserId = persisted.lastUserId === this.userId ? this.userId : null
this.cursor = transport.cursor
for (const id of transport.processedMessageIds) {
this.processedMessageIds.add(id)
this.processedMessageIdOrder.push(id)
}
}

Expand Down Expand Up @@ -190,10 +205,22 @@ export class WeixinClient {
const incoming: IncomingMessage[] = []

for (const raw of response.msgs ?? []) {
if (raw.message_type !== 1) continue
if (!isAuthorizedWeChatSender(raw.from_user_id, this.userId)) {
debugLog(`丢弃未授权微信消息: from=${raw.from_user_id ?? '(empty)'}`)
continue
}
const messageId = String(raw.message_id ?? '')
if (isDuplicateMessageId(this.processedMessageIds, messageId)) {
debugLog(`跳过重复微信消息: id=${messageId}`)
continue
}
this.rememberProcessedMessageId(messageId)
this.rememberContext(raw)
const normalized = this.normalizeIncomingMessage(raw)
if (normalized) incoming.push(normalized)
}
await saveTransportState({ cursor: this.cursor, processedMessageIds: this.processedMessageIdOrder })
return incoming
}

Expand Down Expand Up @@ -259,15 +286,26 @@ export class WeixinClient {
// --- 上下文管理 ---

rememberContext(raw: { from_user_id?: string; to_user_id?: string; context_token?: string; message_type?: number }): void {
const userId = raw.message_type === 1 ? raw.from_user_id : raw.to_user_id
if (userId && raw.context_token) {
this.contextTokens.set(userId, raw.context_token)
this._lastActiveUserId = userId
if (raw.message_type === 1 && isAuthorizedWeChatSender(raw.from_user_id, this.userId) && raw.context_token) {
this.contextTokens.set(this.userId, raw.context_token)
this._lastActiveUserId = this.userId
this._contextTokensDirty = true
this._schedulePersist()
}
}

private rememberProcessedMessageId(messageId: string): void {
if (!messageId) return
this.processedMessageIdOrder = recordProcessedMessageId(this.processedMessageIdOrder, messageId)
this.processedMessageIds.clear()
for (const id of this.processedMessageIdOrder) this.processedMessageIds.add(id)
// 类型层面同时固定容量,避免未来修改 record 函数时无界增长。
while (this.processedMessageIdOrder.length > MAX_RECENT_MESSAGE_IDS) {
const removed = this.processedMessageIdOrder.shift()
if (removed) this.processedMessageIds.delete(removed)
}
}

private _persistTimer: ReturnType<typeof setTimeout> | null = null

private _schedulePersist(): void {
Expand Down
9 changes: 7 additions & 2 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
acquireLock,
clearCredentials,
clearContextTokens,
clearTransportState,
getCredentialsPath,
getQrCode,
loadConfig,
Expand Down Expand Up @@ -57,8 +58,11 @@ async function cmdLogin(args: string, ctx: Ctx, deps: CommandDeps): Promise<void
}
if (deps.isRunning()) await deps.stopBridge({ releaseLock: true })

// 强制重新登录时清除旧的 context tokens(旧 session 的 token 已无效)
if (force) await clearContextTokens()
// 强制重新登录时清除旧会话状态(context token 和长轮询游标均不应跨凭证复用)。
if (force) {
await clearContextTokens()
await clearTransportState()
}

let currentBaseUrl: string | undefined

Expand Down Expand Up @@ -162,6 +166,7 @@ async function cmdLogout(_args: string, ctx: Ctx, deps: CommandDeps): Promise<vo
await deps.disposeClient()
await clearCredentials()
await clearContextTokens()
await clearTransportState()
deps.setClient(null)
deps.queue.lastWechatUser = null
deps.notify(`已清除微信凭证: ${getCredentialsPath()}`, 'info')
Expand Down
Loading