feat(cron): enable WeChat as a supported delivery channel (#789)

This commit is contained in:
paisley
2026-04-07 18:56:54 +08:00
committed by GitHub
Unverified
parent 97d29ab23c
commit 3021ad5089
10 changed files with 92 additions and 23 deletions

View File

@@ -110,7 +110,7 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
既知の制限: WeChat は現在、定期タスク配信の対応チャネルから意図的に除外しています。`openclaw-weixin` プラグインの送信処理が、リアルタイム会話で得られる `contextToken` を必要とするため、cron のような能動配信をプラグイン自体がサポートしていません。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。

View File

@@ -110,7 +110,7 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand.
Known limitation: WeChat is intentionally excluded from supported cron delivery channels for now. The current `openclaw-weixin` plugin requires a live conversation `contextToken` for outbound sends, so cron-style proactive delivery is not supported by the plugin itself.
### 🧩 Extensible Skill System
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.

View File

@@ -111,7 +111,7 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`
已知限制:微信当前不在支持的定时任务投递通道列表内。原因是 `openclaw-weixin` 插件的出站发送依赖实时会话里的 `contextToken`,插件本身不支持 cron 这类主动推送场景。
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。

View File

@@ -265,11 +265,10 @@ export function buildCronSessionFallbackMessages(params: {
type JsonRecord = Record<string, unknown>;
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
if (!channel) return null;
return toUiChannelType(channel) === 'wechat'
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
: null;
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
// Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS).
// No per-channel backend blocks are needed.
return null;
}
function normalizeCronDelivery(

View File

@@ -747,11 +747,10 @@ interface GatewayCronJob {
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
if (!channel) return null;
return toUiChannelType(channel) === 'wechat'
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
: null;
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
// Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS).
// No per-channel backend blocks are needed.
return null;
}
function normalizeCronDelivery(

View File

@@ -45,7 +45,7 @@
"selectDeliveryAccount": "Select an account",
"deliveryAccountDesc": "Uses the same configured account list shown on the Channels page.",
"selectChannel": "Select a channel",
"deliveryChannelUnsupported": "WeChat does not currently support scheduled outbound delivery because the plugin requires a live conversation context token.",
"deliveryChannelUnsupported": "{{channel}} does not currently support scheduled outbound delivery.",
"deliveryDefaultAccountHint": "Uses the channel's default account: {{account}}",
"deliveryTarget": "Recipient / Target",
"selectDeliveryTarget": "Select a delivery target",

View File

@@ -45,7 +45,7 @@
"selectDeliveryAccount": "选择账号",
"deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。",
"selectChannel": "选择通道",
"deliveryChannelUnsupported": "微信通道当前不支持定时任务主动投递,因为插件要求实时会话里的 contextToken。",
"deliveryChannelUnsupported": "{{channel}} 通道当前不支持定时任务主动投递。",
"deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}",
"deliveryTarget": "接收目标",
"selectDeliveryTarget": "选择接收目标",

View File

@@ -205,7 +205,7 @@ function getDeliveryAccountDisplayName(account: DeliveryChannelAccount, t: TFunc
: account.name;
}
const TESTED_CRON_DELIVERY_CHANNELS = new Set<string>(['feishu', 'telegram', 'qqbot', 'wecom']);
const TESTED_CRON_DELIVERY_CHANNELS = new Set<string>(['feishu', 'telegram', 'qqbot', 'wecom', 'wechat']);
function isSupportedCronDeliveryChannel(channelType: string): boolean {
return TESTED_CRON_DELIVERY_CHANNELS.has(channelType);
@@ -573,7 +573,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
<p className="text-[12px] text-muted-foreground">{t('dialog.noChannels')}</p>
)}
{unsupportedDeliveryChannel && (
<p className="text-[12px] text-destructive">{t('dialog.deliveryChannelUnsupported')}</p>
<p className="text-[12px] text-destructive">{t('dialog.deliveryChannelUnsupported', { channel: getChannelDisplayName(effectiveDeliveryChannel) })}</p>
)}
{selectedChannel && (
<p className="text-[12px] text-muted-foreground">

View File

@@ -158,7 +158,7 @@ describe('handleCronRoutes', () => {
});
});
it('rejects WeChat scheduled delivery because the plugin requires a live context token', async () => {
it('allows WeChat scheduled delivery', async () => {
parseJsonBodyMock.mockResolvedValue({
name: 'WeChat delivery',
message: 'Send update',
@@ -172,7 +172,17 @@ describe('handleCronRoutes', () => {
enabled: true,
});
const rpc = vi.fn();
const rpc = vi.fn().mockResolvedValue({
id: 'job-wechat',
name: 'WeChat delivery',
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: 'cron', expr: '0 10 * * *' },
payload: { kind: 'agentTurn', message: 'Send update' },
delivery: { mode: 'announce', channel: 'openclaw-weixin', to: 'wechat:wxid_target', accountId: 'wechat-bot' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
@@ -185,13 +195,14 @@ describe('handleCronRoutes', () => {
);
expect(handled).toBe(true);
expect(rpc).not.toHaveBeenCalled();
expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({
delivery: expect.objectContaining({ mode: 'announce', to: 'wechat:wxid_target' }),
}));
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
400,
200,
expect.objectContaining({
success: false,
error: expect.stringContaining('WeChat scheduled delivery is not supported'),
id: 'job-wechat',
}),
);
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
getGatewayStartupRecoveryAction,
hasInvalidConfigFailureSignal,
isInvalidConfigSignal,
shouldAttemptConfigAutoRepair,
@@ -50,3 +51,62 @@ describe('gateway startup recovery heuristics', () => {
});
});
describe('getGatewayStartupRecoveryAction', () => {
const configInvalidStderr = ['Config invalid', 'Run: openclaw doctor --fix'];
const transientError = new Error('Gateway process exited before becoming ready (code=1)');
it('returns repair on first config-invalid failure', () => {
const action = getGatewayStartupRecoveryAction({
startupError: transientError,
startupStderrLines: configInvalidStderr,
configRepairAttempted: false,
attempt: 1,
maxAttempts: 3,
});
expect(action).toBe('repair');
});
it('returns retry when repair was attempted but error is still transient', () => {
const action = getGatewayStartupRecoveryAction({
startupError: transientError,
startupStderrLines: configInvalidStderr,
configRepairAttempted: true,
attempt: 1,
maxAttempts: 3,
});
expect(action).toBe('retry');
});
it('returns retry for transient errors after successful repair (no config signal)', () => {
const action = getGatewayStartupRecoveryAction({
startupError: transientError,
startupStderrLines: ['Gateway process exited (code=1, expected=no)'],
configRepairAttempted: true,
attempt: 1,
maxAttempts: 3,
});
expect(action).toBe('retry');
});
it('returns fail when max attempts exceeded even for transient errors', () => {
const action = getGatewayStartupRecoveryAction({
startupError: transientError,
startupStderrLines: [],
configRepairAttempted: false,
attempt: 3,
maxAttempts: 3,
});
expect(action).toBe('fail');
});
it('returns fail for non-transient, non-config errors', () => {
const action = getGatewayStartupRecoveryAction({
startupError: new Error('Unknown fatal error'),
startupStderrLines: [],
configRepairAttempted: false,
attempt: 1,
maxAttempts: 3,
});
expect(action).toBe('fail');
});
});