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
12 changes: 12 additions & 0 deletions .changeset/openclaw-2026.5-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@agent-wechat/wechat": minor
---

Update for openclaw 2026.5+ compatibility:

- Add `channelConfigs` metadata to `openclaw.plugin.json` so the gateway can validate config and load setup surfaces before the plugin runtime imports (silences the "channel plugin manifest declares wechat without channelConfigs metadata" warning).
- Replace deprecated `runtime.config.loadConfig()` calls with `runtime.config.current()`.
- Add a `message` adapter via `createChannelMessageAdapterFromOutbound` from `openclaw/plugin-sdk/channel-message`. The legacy `outbound` adapter is kept for older openclaw versions.
- Bump the `openclaw` peer dependency floor to `^2026.5.12`.

The deprecated `outbound` adapter and `dispatchReplyWithBufferedBlockDispatcher` ingest flow continue to work via openclaw's compat shims; a follow-up release will migrate the monitor's dispatch path to `core.channel.turn.runPrepared(...)`.
42 changes: 42 additions & 0 deletions packages/openclaw-extension/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,47 @@
"type": "object",
"additionalProperties": false,
"properties": {}
},
"channelConfigs": {
"wechat": {
"label": "WeChat",
"description": "WeChat messaging via agent-wechat container.",
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"serverUrl": { "type": "string" },
"token": { "type": "string" },
"dmPolicy": {
"type": "string",
"enum": ["open", "allowlist", "disabled"]
},
"allowFrom": { "type": "array", "items": { "type": "string" } },
"groupPolicy": {
"type": "string",
"enum": ["open", "allowlist", "disabled"]
},
"groupAllowFrom": { "type": "array", "items": { "type": "string" } },
"groups": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"requireMention": { "type": "boolean" },
"groupPolicy": {
"type": "string",
"enum": ["open", "allowlist", "disabled"]
},
"allowFrom": { "type": "array", "items": { "type": "string" } }
}
}
},
"pollIntervalMs": { "type": "integer", "minimum": 100 },
"authPollIntervalMs": { "type": "integer", "minimum": 1000 }
}
}
}
}
}
2 changes: 1 addition & 1 deletion packages/openclaw-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@agent-wechat/shared": "workspace:*",
"@types/node": "^22",
"esbuild": "^0.25.0",
"openclaw": ">=2026.3.23 <2027.0.0",
"openclaw": "^2026.5.12",
"typescript": "^5.4.5"
},
"openclaw": {
Expand Down
17 changes: 10 additions & 7 deletions packages/openclaw-extension/src/access-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,16 +221,19 @@ test("resolveWeChatCommandAuthorization computes only for command-like bodies",
}
return params.authorizers.some((entry) => entry.configured && entry.allowed);
},
readAllowFromStore: async () => ["wxid_store"],
// openclaw 2026.5+ only consults the pairing-store allowlist when the DM policy is
// neither "open" nor "allowlist". For "allowlist" policies, command owners come
// from the configured allowFrom list (passed via allowFromForCommands).
readAllowFromStore: async () => [],
};

const authorized = await resolveWeChatCommandAuthorization({
cfg,
rawBody: "/status",
isGroup: false,
senderId: "wechat:wxid_store",
dmPolicy: "open",
allowFromForCommands: [],
senderId: "wechat:wxid_owner",
dmPolicy: "allowlist",
allowFromForCommands: ["wxid_owner"],
deps,
});
assert.equal(authorized, true);
Expand All @@ -239,9 +242,9 @@ test("resolveWeChatCommandAuthorization computes only for command-like bodies",
cfg,
rawBody: "hello",
isGroup: false,
senderId: "wechat:wxid_store",
dmPolicy: "open",
allowFromForCommands: [],
senderId: "wechat:wxid_owner",
dmPolicy: "allowlist",
allowFromForCommands: ["wxid_owner"],
deps,
});
assert.equal(skipped, undefined);
Expand Down
176 changes: 89 additions & 87 deletions packages/openclaw-extension/src/channel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
import type { ResolvedWeChatAccount } from "./types.js";
import { resolveWeChatAccount } from "./types.js";
import { getWeChatRuntime } from "./runtime.js";
Expand All @@ -12,6 +13,81 @@ import { loginStart, loginWait, loginTerminal } from "./login.js";
import { createWeChatLoginTool } from "./agent-tools.js";
import { normalizeWeChatCommandBody, normalizeWeChatId } from "./access-control.js";

async function sendWeChatText(cfg: unknown, to: string, text: string): Promise<string> {
const account = resolveWeChatAccount(cfg as Record<string, unknown>);
if (!account?.serverUrl) throw new Error("No serverUrl configured");
const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
const result = await client.sendMessage({ chatId: to, text });
if (!result.success) throw new Error(result.error ?? "Send failed");
return `wechat:${to}:${Date.now()}`;
}

async function sendWeChatMedia(
cfg: unknown,
to: string,
text: string,
mediaUrl: string | undefined,
): Promise<string> {
const account = resolveWeChatAccount(cfg as Record<string, unknown>);
if (!account?.serverUrl) throw new Error("No serverUrl configured");
const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
if (!mediaUrl) {
const result = await client.sendMessage({ chatId: to, text: text || undefined });
if (!result.success) throw new Error(result.error ?? "Send failed");
return `wechat:${to}:${Date.now()}`;
}

const fsmod = await import("fs/promises");
const pathmod = await import("path");
let base64: string;
let mimeType: string;
let filename: string;
if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
const res = await fetch(mediaUrl);
const buffer = await res.arrayBuffer();
base64 = Buffer.from(buffer).toString("base64");
mimeType = res.headers.get("content-type") ?? "application/octet-stream";
const urlPath = new URL(mediaUrl).pathname;
filename = pathmod.basename(urlPath) || "file";
} else {
const buf = await fsmod.readFile(mediaUrl);
base64 = buf.toString("base64");
filename = pathmod.basename(mediaUrl);
const ext = pathmod.extname(mediaUrl).toLowerCase().replace(".", "");
const extMime: Record<string, string> = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", webp: "image/webp",
};
mimeType = extMime[ext] ?? "application/octet-stream";
}

const isImage = mimeType.startsWith("image/");
const result = isImage
? await client.sendMessage({ chatId: to, text: text || undefined, image: { data: base64, mimeType } })
: await client.sendMessage({ chatId: to, text: text || undefined, file: { data: base64, filename } });
if (!result.success) throw new Error(result.error ?? "Send media failed");
return `wechat:${to}:${Date.now()}`;
}

const wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "wechat",
capabilities: {
text: true,
media: true,
messageSendingHooks: true,
},
outbound: {
sendText: async ({ cfg, to, text }) => ({
channel: "wechat",
messageId: await sendWeChatText(cfg, to, text),
}),
sendMedia: async ({ cfg, to, text, mediaUrl }) => ({
channel: "wechat",
messageId: await sendWeChatMedia(cfg, to, text, mediaUrl),
}),
},
});

const meta: ChannelPlugin["meta"] = {
id: "wechat",
label: "WeChat",
Expand Down Expand Up @@ -160,95 +236,21 @@ export const wechatPlugin: ChannelPlugin<ResolvedWeChatAccount> = {
},
},

// ---- Outbound adapter ----
// ---- Outbound adapter (legacy compat path; new message adapter below is preferred in 2026.5+) ----
outbound: {
deliveryMode: "direct",
sendText: async ({ cfg, to, text }) => {
const account = resolveWeChatAccount(
cfg as unknown as Record<string, unknown>,
);
if (!account?.serverUrl) {
throw new Error("No serverUrl configured");
}
const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
const result = await client.sendMessage({ chatId: to, text });
if (!result.success) {
throw new Error(result.error ?? "Send failed");
}
return {
channel: "wechat" as const,
messageId: `wechat:${to}:${Date.now()}`,
};
},
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
const account = resolveWeChatAccount(
cfg as unknown as Record<string, unknown>,
);
if (!account?.serverUrl) {
throw new Error("No serverUrl configured");
}
const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
if (mediaUrl) {
const fsmod = await import("fs/promises");
const pathmod = await import("path");

let base64: string;
let mimeType: string;
let filename: string;
if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
const res = await fetch(mediaUrl);
const buffer = await res.arrayBuffer();
base64 = Buffer.from(buffer).toString("base64");
mimeType = res.headers.get("content-type") ?? "application/octet-stream";
const urlPath = new URL(mediaUrl).pathname;
filename = pathmod.basename(urlPath) || "file";
} else {
const buf = await fsmod.readFile(mediaUrl);
base64 = buf.toString("base64");
filename = pathmod.basename(mediaUrl);
const ext = pathmod.extname(mediaUrl).toLowerCase().replace(".", "");
const extMime: Record<string, string> = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", webp: "image/webp",
};
mimeType = extMime[ext] ?? "application/octet-stream";
}

const isImage = mimeType.startsWith("image/");
const result = isImage
? await client.sendMessage({
chatId: to,
text: text || undefined,
image: { data: base64, mimeType },
})
: await client.sendMessage({
chatId: to,
text: text || undefined,
file: { data: base64, filename },
});
if (!result.success) {
throw new Error(result.error ?? "Send media failed");
}
return {
channel: "wechat" as const,
messageId: `wechat:${to}:${Date.now()}`,
};
}
// Text-only fallback
const result = await client.sendMessage({
chatId: to,
text: text || undefined,
});
if (!result.success) {
throw new Error(result.error ?? "Send failed");
}
return {
channel: "wechat" as const,
messageId: `wechat:${to}:${Date.now()}`,
};
},
sendText: async ({ cfg, to, text }) => ({
channel: "wechat" as const,
messageId: await sendWeChatText(cfg, to, text),
}),
sendMedia: async ({ cfg, to, text, mediaUrl }) => ({
channel: "wechat" as const,
messageId: await sendWeChatMedia(cfg, to, text, mediaUrl),
}),
},

message: wechatMessageAdapter,

// ---- Gateway adapter ----
gateway: {
startAccount: async (ctx) => {
Expand All @@ -266,7 +268,7 @@ export const wechatPlugin: ChannelPlugin<ResolvedWeChatAccount> = {
},

loginWithQrStart: async ({ accountId, force, timeoutMs }) => {
const cfg = getWeChatRuntime().config.loadConfig();
const cfg = getWeChatRuntime().config.current();
const account = resolveWeChatAccount(
cfg as Record<string, unknown>,
accountId ?? undefined,
Expand All @@ -287,7 +289,7 @@ export const wechatPlugin: ChannelPlugin<ResolvedWeChatAccount> = {
},

logoutAccount: async ({ accountId }) => {
const cfg = getWeChatRuntime().config.loadConfig();
const cfg = getWeChatRuntime().config.current();
const account = resolveWeChatAccount(
cfg as Record<string, unknown>,
accountId ?? undefined,
Expand Down
4 changes: 2 additions & 2 deletions packages/openclaw-extension/src/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ export async function startWeChatMonitor(

while (!abortSignal.aborted) {
try {
// Reload config each iteration so hot-reloads take effect
const cfg = getWeChatRuntime().config.loadConfig();
// Read the runtime config snapshot each iteration; the host updates it on hot-reload.
const cfg = getWeChatRuntime().config.current();

// ---- Auth polling (every authPollIntervalMs) ----
const now = Date.now();
Expand Down
Loading
Loading