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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "^3.1.0",
"openclaw": "2026.3.23",
"openclaw": "^2026.4.9",
"silk-wasm": "^3.7.1",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
Expand Down
7 changes: 7 additions & 0 deletions src/auth/pairing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ const mockWithFileLock = vi.hoisted(() =>
vi.fn(async (_path: string, _opts: unknown, fn: () => Promise<unknown>) => fn()),
);

// Mock both the legacy root barrel and the subpath that pairing.ts actually
// imports from. Starting with openclaw 2026.4.x the infra-runtime subpath is
// a distinct module graph node, so mocking only the root barrel no longer
// intercepts `import { withFileLock } from "openclaw/plugin-sdk/infra-runtime"`.
vi.mock("openclaw/plugin-sdk", () => ({
withFileLock: mockWithFileLock,
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
withFileLock: mockWithFileLock,
}));

let tmpDir: string;

Expand Down
31 changes: 31 additions & 0 deletions src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js
// command-auth chain during plugin registration, which can re-enter plugin/provider registry
// resolution before the account actually starts.
import { sendWeixinMediaFile } from "./messaging/send-media.js";
import { createWeixinThreadBindingManager } from "./thread-bindings.js";
import { sendMessageWeixin, StreamingMarkdownFilter } from "./messaging/send.js";
import { downloadRemoteImageToTemp } from "./cdn/upload.js";

Expand Down Expand Up @@ -167,6 +168,26 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
looksLikeId: (raw) => raw.endsWith("@im.wechat"),
},
},
// Expose ACP / subagent thread binding support. WeChat is 1:1 direct chat
// today (no group / forum topic semantics), so we only offer the `current`
// placement and treat the inbound conversationId (the sender's WeChat id)
// as the canonical conversation ref with no parent. The actual binding
// lifetime is managed by createWeixinThreadBindingManager; the framework
// calls the hook below to dedupe to that per-account manager.
conversationBindings: {
supportsCurrentConversationBinding: true,
defaultTopLevelPlacement: "current",
resolveConversationRef: ({ conversationId }) => {
const trimmed = conversationId?.trim() ?? "";
return trimmed ? { conversationId: trimmed } : null;
},
createManager: ({ accountId }) =>
createWeixinThreadBindingManager({
accountId: accountId ?? undefined,
persist: false,
enableSweeper: false,
}),
},
agentPrompt: {
messageToolHints: () => [
"To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.",
Expand Down Expand Up @@ -365,6 +386,16 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
const aLog = logger.withAccount(account.accountId);
aLog.debug(`about to call monitorWeixinProvider`);
restoreContextTokens(account.accountId);
// Eagerly initialise the ACP thread binding manager for this account so
// it loads the persistent store and starts its idle sweeper before any
// inbound traffic arrives. Subsequent framework-driven calls to
// createManager() (via conversationBindings below) will be deduped and
// return this same instance.
try {
createWeixinThreadBindingManager({ accountId: account.accountId });
} catch (err) {
aLog.warn(`thread binding manager init failed: ${String(err)}`);
}
aLog.info(`starting weixin webhook`);

ctx.setStatus?.({
Expand Down
68 changes: 68 additions & 0 deletions src/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// runtime.ts pulls in logger which in turn reaches for fs/stream paths. Stub
// the logger so these thin unit tests don't race with real log writes.
vi.mock("./util/logger.js", () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

// biome-ignore lint/suspicious/noExplicitAny: tests treat PluginRuntime shape as opaque
type AnyRuntime = any;

const MOCK_RUNTIME: AnyRuntime = {
channel: { id: "openclaw-weixin" },
};

async function loadRuntime() {
vi.resetModules();
return import("./runtime.js");
}

describe("weixin runtime singleton", () => {
afterEach(() => {
vi.resetModules();
});

it("setWeixinRuntime + getWeixinRuntime round-trip", async () => {
const mod = await loadRuntime();
mod.setWeixinRuntime(MOCK_RUNTIME);
expect(mod.getWeixinRuntime()).toBe(MOCK_RUNTIME);
});

it("getWeixinRuntime throws before initialization", async () => {
const mod = await loadRuntime();
expect(() => mod.getWeixinRuntime()).toThrow(/not initialized/);
});

it("waitForWeixinRuntime returns immediately when already set", async () => {
const mod = await loadRuntime();
mod.setWeixinRuntime(MOCK_RUNTIME);
const result = await mod.waitForWeixinRuntime(100);
expect(result).toBe(MOCK_RUNTIME);
});

it("waitForWeixinRuntime times out when never set", async () => {
const mod = await loadRuntime();
await expect(mod.waitForWeixinRuntime(50)).rejects.toThrow(/timeout/);
});

it("resolveWeixinChannelRuntime prefers the channelRuntime param when provided", async () => {
const mod = await loadRuntime();
const injected = { id: "from-ctx" };
// biome-ignore lint/suspicious/noExplicitAny: test fixture
const result = await mod.resolveWeixinChannelRuntime({ channelRuntime: injected as any });
expect(result).toBe(injected);
});

it("resolveWeixinChannelRuntime falls back to the module-global when no ctx is passed", async () => {
const mod = await loadRuntime();
mod.setWeixinRuntime(MOCK_RUNTIME);
const result = await mod.resolveWeixinChannelRuntime({});
expect(result).toBe(MOCK_RUNTIME.channel);
});
});
Loading