diff --git a/README.ja-JP.md b/README.ja-JP.md index beec9d87d..70d249a6f 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -110,7 +110,7 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同 ### ⏰ Cronベースの自動化 AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。 定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。 -既知の制限: WeChat は現在、定期タスク配信の対応チャネルから意図的に除外しています。`openclaw-weixin` プラグインの送信処理が、リアルタイム会話で得られる `contextToken` を必要とするため、cron のような能動配信をプラグイン自体がサポートしていません。 + ### 🧩 拡張可能なスキルシステム 事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。 diff --git a/README.md b/README.md index c4118a7ef..029900b58 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index c304ea87b..1317c69d1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,7 +111,7 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan ### ⏰ 定时任务自动化 调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。 现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。 -已知限制:微信当前不在支持的定时任务投递通道列表内。原因是 `openclaw-weixin` 插件的出站发送依赖实时会话里的 `contextToken`,插件本身不支持 cron 这类主动推送场景。 + ### 🧩 可扩展技能系统 通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。 diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts index 8f485c8b8..2292bc13a 100644 --- a/electron/api/routes/cron.ts +++ b/electron/api/routes/cron.ts @@ -265,11 +265,10 @@ export function buildCronSessionFallbackMessages(params: { type JsonRecord = Record; type GatewayCronDelivery = NonNullable; -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( diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 5f7c61976..295867878 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -747,11 +747,10 @@ interface GatewayCronJob { type GatewayCronDelivery = NonNullable; -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( diff --git a/src/i18n/locales/en/cron.json b/src/i18n/locales/en/cron.json index 81a761ee7..8c1f63d13 100644 --- a/src/i18n/locales/en/cron.json +++ b/src/i18n/locales/en/cron.json @@ -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", diff --git a/src/i18n/locales/zh/cron.json b/src/i18n/locales/zh/cron.json index 01a02f6a6..30d1f25ac 100644 --- a/src/i18n/locales/zh/cron.json +++ b/src/i18n/locales/zh/cron.json @@ -45,7 +45,7 @@ "selectDeliveryAccount": "选择账号", "deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。", "selectChannel": "选择通道", - "deliveryChannelUnsupported": "微信通道当前不支持定时任务主动投递,因为插件要求实时会话里的 contextToken。", + "deliveryChannelUnsupported": "{{channel}} 通道当前不支持定时任务主动投递。", "deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}", "deliveryTarget": "接收目标", "selectDeliveryTarget": "选择接收目标", diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 8a39340f9..93175b9ff 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -205,7 +205,7 @@ function getDeliveryAccountDisplayName(account: DeliveryChannelAccount, t: TFunc : account.name; } -const TESTED_CRON_DELIVERY_CHANNELS = new Set(['feishu', 'telegram', 'qqbot', 'wecom']); +const TESTED_CRON_DELIVERY_CHANNELS = new Set(['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

{t('dialog.noChannels')}

)} {unsupportedDeliveryChannel && ( -

{t('dialog.deliveryChannelUnsupported')}

+

{t('dialog.deliveryChannelUnsupported', { channel: getChannelDisplayName(effectiveDeliveryChannel) })}

)} {selectedChannel && (

diff --git a/tests/unit/cron-routes.test.ts b/tests/unit/cron-routes.test.ts index c614da9fe..7746ecebe 100644 --- a/tests/unit/cron-routes.test.ts +++ b/tests/unit/cron-routes.test.ts @@ -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', }), ); }); diff --git a/tests/unit/gateway-startup-recovery.test.ts b/tests/unit/gateway-startup-recovery.test.ts index 2692385ce..9ff766a3c 100644 --- a/tests/unit/gateway-startup-recovery.test.ts +++ b/tests/unit/gateway-startup-recovery.test.ts @@ -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'); + }); +});