diff --git a/src/channel.test.ts b/src/channel.test.ts new file mode 100644 index 0000000..98d7036 --- /dev/null +++ b/src/channel.test.ts @@ -0,0 +1,184 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerWeixinAccountId, saveWeixinAccount } from "./auth/accounts.js"; +import { weixinPlugin } from "./channel.js"; +import { clearContextTokensForAccount, setContextToken } from "./messaging/inbound.js"; + +const { sendMessageApi, uploadFileToWeixin } = vi.hoisted(() => ({ + sendMessageApi: vi.fn(), + uploadFileToWeixin: vi.fn(), +})); + +vi.mock("./api/api.js", async (importOriginal) => ({ + ...(await importOriginal()), + sendMessage: sendMessageApi, +})); +vi.mock("./cdn/upload.js", async (importOriginal) => ({ + ...(await importOriginal()), + uploadFileToWeixin, +})); +vi.mock("./util/logger.js", () => { + const logger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + withAccount: () => logger, + }; + return { logger }; +}); + +let stateDir: string; +let previousStateDir: string | undefined; + +beforeEach(() => { + previousStateDir = process.env.OPENCLAW_STATE_DIR; + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "weixin-session-route-")); + process.env.OPENCLAW_STATE_DIR = stateDir; +}); + +afterEach(() => { + clearContextTokensForAccount("bot-one"); + clearContextTokensForAccount("bot-two"); + fs.rmSync(stateDir, { recursive: true, force: true }); + if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = previousStateDir; +}); + +async function resolveRoute(params: { target: string; accountId?: string }) { + const resolver = weixinPlugin.messaging?.resolveOutboundSessionRoute; + if (!resolver) throw new Error("expected outbound session route resolver"); + return await resolver({ + cfg: { session: { dmScope: "per-account-channel-peer" } }, + agentId: "main", + target: params.target, + accountId: params.accountId, + }); +} + +describe("Weixin outbound session route", () => { + it("resolves an explicit account and canonical Weixin user", async () => { + const route = await resolveRoute({ + target: "openclaw-weixin:user:Alice@im.wechat", + accountId: "Bot@One", + }); + + expect(route).toMatchObject({ + sessionKey: "agent:main:openclaw-weixin:bot-one:direct:alice@im.wechat", + recipientSessionExact: false, + peer: { kind: "direct", id: "Alice@im.wechat" }, + to: "Alice@im.wechat", + }); + }); + + it("uses the only registered account instead of the default account", async () => { + registerWeixinAccountId("bot-one"); + + const route = await resolveRoute({ target: "alice@im.wechat" }); + + expect(route).toMatchObject({ + sessionKey: "agent:main:openclaw-weixin:bot-one:direct:alice@im.wechat", + recipientSessionExact: false, + }); + }); + + it("does not certify case-distinct recipients as exact sessions", async () => { + const upper = await resolveRoute({ target: "Alice@im.wechat", accountId: "bot-one" }); + const lower = await resolveRoute({ target: "alice@im.wechat", accountId: "bot-one" }); + expect(upper).toMatchObject({ to: "Alice@im.wechat", recipientSessionExact: false }); + expect(lower).toMatchObject({ to: "alice@im.wechat", recipientSessionExact: false }); + }); + + it("rejects targets that cannot identify an inbound Weixin session", async () => { + await expect(resolveRoute({ target: "group:alice@im.wechat" })).resolves.toBeNull(); + }); +}); + +describe("Weixin outbound target normalization", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const accountId of ["bot-one", "bot-two"]) { + registerWeixinAccountId(accountId); + saveWeixinAccount(accountId, { token: `synthetic-${accountId}` }); + } + setContextToken("bot-one", "Alice@im.wechat", "fixture-alice-context"); + setContextToken("bot-two", "Bob@im.wechat", "fixture-bob-context"); + sendMessageApi.mockResolvedValue({ ret: 0 }); + uploadFileToWeixin.mockResolvedValue({ + filekey: "fixture-file", + downloadEncryptedQueryParam: "fixture-query", + aeskey: "0123456789abcdef0123456789abcdef", + fileSize: 16, + fileSizeCiphertext: 32, + }); + }); + + it.each([ + { to: "Alice@im.wechat" }, + { to: "user:Alice@im.wechat" }, + { to: "dm:Alice@im.wechat" }, + { to: "openclaw-weixin:user:Alice@im.wechat" }, + { to: "user:Alice@im.wechat", accountId: "bot-one" }, + { to: "user:Alice@im.wechat", accountId: "Bot@One" }, + ])( + "selects the account and cached context before sending text to $to", + async ({ to, accountId }) => { + const sendText = weixinPlugin.outbound?.sendText; + if (!sendText) throw new Error("expected text sender"); + await sendText({ cfg: {}, to, accountId, text: "fixture reply" }); + expect(sendMessageApi).toHaveBeenCalledOnce(); + expect(sendMessageApi.mock.calls[0][0]).toMatchObject({ + token: "synthetic-bot-one", + body: { msg: { to_user_id: "Alice@im.wechat", context_token: "fixture-alice-context" } }, + }); + expect(weixinPlugin.messaging?.normalizeTarget?.(to)).toBe("Alice@im.wechat"); + }, + ); + + it.each([undefined, "/tmp/fixture-image.png"])( + "normalizes media delivery with mediaUrl=%s", + async (mediaUrl) => { + const sendMedia = weixinPlugin.outbound?.sendMedia; + if (!sendMedia) throw new Error("expected media sender"); + await sendMedia({ cfg: {}, to: "user:Alice@im.wechat", text: "fixture caption", mediaUrl }); + expect(sendMessageApi).toHaveBeenCalled(); + for (const [request] of sendMessageApi.mock.calls) { + expect(request.body.msg).toMatchObject({ + to_user_id: "Alice@im.wechat", + context_token: "fixture-alice-context", + }); + } + if (mediaUrl) { + expect(uploadFileToWeixin).toHaveBeenCalledWith( + expect.objectContaining({ toUserId: "Alice@im.wechat" }), + ); + } else { + expect(uploadFileToWeixin).not.toHaveBeenCalled(); + } + }, + ); + + it.each(["group:Alice@im.wechat", "channel:Alice@im.wechat", "not-a-weixin-recipient"])( + "rejects unsupported target %s before sending or uploading", + async (to) => { + const { sendText, sendMedia } = weixinPlugin.outbound ?? {}; + if (!sendText || !sendMedia) throw new Error("expected outbound senders"); + await expect( + sendText({ cfg: {}, accountId: "bot-one", to, text: "fixture" }), + ).rejects.toThrow("direct"); + await expect( + sendMedia({ + cfg: {}, + accountId: "bot-one", + to, + text: "fixture", + mediaUrl: "/tmp/fixture.png", + }), + ).rejects.toThrow("direct"); + expect(sendMessageApi).not.toHaveBeenCalled(); + expect(uploadFileToWeixin).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/channel.ts b/src/channel.ts index 1899793..0cfac5e 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1,6 +1,14 @@ import path from "node:path"; -import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + buildChannelOutboundSessionRoute, + stripChannelTargetPrefix, + stripTargetKindPrefix, + type ChannelOutboundSessionRouteParams, + type ChannelPlugin, + type OpenClawConfig, + type PluginRuntime, +} from "openclaw/plugin-sdk/core"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; @@ -115,6 +123,35 @@ function resolveOutboundAccountId(cfg: OpenClawConfig, to: string): string { ); } +function normalizeWeixinUserId(raw: string): string | null { + const channelTarget = stripChannelTargetPrefix(raw.trim(), "openclaw-weixin"); + const userId = stripTargetKindPrefix(channelTarget).trim(); + if (userId !== channelTarget.trim() && !/^(user|dm):/i.test(channelTarget)) return null; + return userId.endsWith("@im.wechat") ? userId : null; +} + +function resolveWeixinOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { + const userId = normalizeWeixinUserId(params.target); + if (!userId) return null; + const accountId = params.accountId?.trim() + ? resolveWeixinAccount(params.cfg, params.accountId).accountId + : resolveOutboundAccountId(params.cfg, userId); + return { + ...buildChannelOutboundSessionRoute({ + cfg: params.cfg, + agentId: params.agentId, + channel: "openclaw-weixin", + accountId, + peer: { kind: "direct", id: userId }, + chatType: "direct", + from: userId, + to: userId, + }), + // The host folds direct-peer IDs, so it cannot certify an exact case-sensitive recipient. + recipientSessionExact: false as const, + }; +} + async function sendWeixinOutbound(params: { cfg: OpenClawConfig; to: string; @@ -238,10 +275,12 @@ export const weixinPlugin: ChannelPlugin = { }, }, messaging: { + normalizeTarget: (raw) => normalizeWeixinUserId(raw) ?? undefined, targetResolver: { // Weixin user IDs always end with @im.wechat; treat as direct IDs, skip directory lookup. - looksLikeId: (raw) => raw.endsWith("@im.wechat"), + looksLikeId: (raw) => normalizeWeixinUserId(raw) !== null, }, + resolveOutboundSessionRoute: resolveWeixinOutboundSessionRoute, }, agentPrompt: { messageToolHints: () => [ @@ -268,18 +307,24 @@ export const weixinPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 4000, sendText: async (ctx) => { - const accountId = ctx.accountId || resolveOutboundAccountId(ctx.cfg, ctx.to); + const to = normalizeWeixinUserId(ctx.to); + if (!to) throw new Error("weixin: expected a direct @im.wechat recipient"); + const accountId = ctx.accountId?.trim() + ? normalizeAccountId(ctx.accountId) + : resolveOutboundAccountId(ctx.cfg, to); const result = await sendWeixinOutbound({ cfg: ctx.cfg, - to: ctx.to, + to, text: ctx.text, accountId, - contextToken: getContextToken(accountId!, ctx.to), + contextToken: getContextToken(accountId!, to), }); return result; }, sendMedia: async (ctx) => { - const accountId = ctx.accountId || resolveOutboundAccountId(ctx.cfg, ctx.to); + const to = normalizeWeixinUserId(ctx.to); + if (!to) throw new Error("weixin: expected a direct @im.wechat recipient"); + const accountId = ctx.accountId || resolveOutboundAccountId(ctx.cfg, to); const account = resolveWeixinAccount(ctx.cfg, accountId); const aLog = logger.withAccount(account.accountId); assertSessionActive(account.accountId); @@ -294,13 +339,13 @@ export const weixinPlugin: ChannelPlugin = { let text = ctx.text ?? ""; const sendingResult = await applyWeixinMessageSendingHook({ - to: ctx.to, + to, text, accountId: account.accountId, mediaUrl, }); if (sendingResult.cancelled) { - aLog.info(`sendMedia: cancelled by message_sending hook to=${ctx.to}`); + aLog.info(`sendMedia: cancelled by message_sending hook to=${to}`); return { channel: "openclaw-weixin", messageId: "" }; } text = sendingResult.text; @@ -315,11 +360,11 @@ export const weixinPlugin: ChannelPlugin = { filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR); aLog.debug(`sendMedia: remote image downloaded to ${filePath}`); } - const contextToken = getContextToken(account.accountId, ctx.to); + const contextToken = getContextToken(account.accountId, to); try { const result = await sendWeixinMediaFile({ filePath, - to: ctx.to, + to, text, opts: { baseUrl: account.baseUrl, @@ -330,7 +375,7 @@ export const weixinPlugin: ChannelPlugin = { cdnBaseUrl: account.cdnBaseUrl, }); emitWeixinMessageSent({ - to: ctx.to, + to, content: text, success: true, accountId: account.accountId, @@ -338,7 +383,7 @@ export const weixinPlugin: ChannelPlugin = { return { channel: "openclaw-weixin", messageId: result.messageId }; } catch (err) { emitWeixinMessageSent({ - to: ctx.to, + to, content: text, success: false, error: String(err), @@ -348,10 +393,10 @@ export const weixinPlugin: ChannelPlugin = { } } - const contextToken = getContextToken(account.accountId, ctx.to); + const contextToken = getContextToken(account.accountId, to); try { const result = await sendMessageWeixin({ - to: ctx.to, + to, text, opts: { baseUrl: account.baseUrl, @@ -361,7 +406,7 @@ export const weixinPlugin: ChannelPlugin = { }, }); emitWeixinMessageSent({ - to: ctx.to, + to, content: text, success: true, accountId: account.accountId, @@ -369,7 +414,7 @@ export const weixinPlugin: ChannelPlugin = { return { channel: "openclaw-weixin", messageId: result.messageId }; } catch (err) { emitWeixinMessageSent({ - to: ctx.to, + to, content: text, success: false, error: String(err),