feat(cron): enable WeChat as a supported delivery channel (#789)
This commit is contained in:
committed by
GitHub
Unverified
parent
97d29ab23c
commit
3021ad5089
@@ -110,7 +110,7 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
|
|||||||
### ⏰ Cronベースの自動化
|
### ⏰ Cronベースの自動化
|
||||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||||
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
|
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
|
||||||
既知の制限: WeChat は現在、定期タスク配信の対応チャネルから意図的に除外しています。`openclaw-weixin` プラグインの送信処理が、リアルタイム会話で得られる `contextToken` を必要とするため、cron のような能動配信をプラグイン自体がサポートしていません。
|
|
||||||
|
|
||||||
### 🧩 拡張可能なスキルシステム
|
### 🧩 拡張可能なスキルシステム
|
||||||
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
|
|||||||
### ⏰ Cron-Based Automation
|
### ⏰ 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.
|
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.
|
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
|
### 🧩 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.
|
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
|
|||||||
### ⏰ 定时任务自动化
|
### ⏰ 定时任务自动化
|
||||||
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
||||||
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。
|
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。
|
||||||
已知限制:微信当前不在支持的定时任务投递通道列表内。原因是 `openclaw-weixin` 插件的出站发送依赖实时会话里的 `contextToken`,插件本身不支持 cron 这类主动推送场景。
|
|
||||||
|
|
||||||
### 🧩 可扩展技能系统
|
### 🧩 可扩展技能系统
|
||||||
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
||||||
|
|||||||
@@ -265,11 +265,10 @@ export function buildCronSessionFallbackMessages(params: {
|
|||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
|
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
|
||||||
|
|
||||||
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
|
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
|
||||||
if (!channel) return null;
|
// Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS).
|
||||||
return toUiChannelType(channel) === 'wechat'
|
// No per-channel backend blocks are needed.
|
||||||
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
|
return null;
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCronDelivery(
|
function normalizeCronDelivery(
|
||||||
|
|||||||
@@ -747,11 +747,10 @@ interface GatewayCronJob {
|
|||||||
|
|
||||||
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
|
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
|
||||||
|
|
||||||
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
|
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
|
||||||
if (!channel) return null;
|
// Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS).
|
||||||
return toUiChannelType(channel) === 'wechat'
|
// No per-channel backend blocks are needed.
|
||||||
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
|
return null;
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCronDelivery(
|
function normalizeCronDelivery(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"selectDeliveryAccount": "Select an account",
|
"selectDeliveryAccount": "Select an account",
|
||||||
"deliveryAccountDesc": "Uses the same configured account list shown on the Channels page.",
|
"deliveryAccountDesc": "Uses the same configured account list shown on the Channels page.",
|
||||||
"selectChannel": "Select a channel",
|
"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}}",
|
"deliveryDefaultAccountHint": "Uses the channel's default account: {{account}}",
|
||||||
"deliveryTarget": "Recipient / Target",
|
"deliveryTarget": "Recipient / Target",
|
||||||
"selectDeliveryTarget": "Select a delivery target",
|
"selectDeliveryTarget": "Select a delivery target",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"selectDeliveryAccount": "选择账号",
|
"selectDeliveryAccount": "选择账号",
|
||||||
"deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。",
|
"deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。",
|
||||||
"selectChannel": "选择通道",
|
"selectChannel": "选择通道",
|
||||||
"deliveryChannelUnsupported": "微信通道当前不支持定时任务主动投递,因为插件要求实时会话里的 contextToken。",
|
"deliveryChannelUnsupported": "{{channel}} 通道当前不支持定时任务主动投递。",
|
||||||
"deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}",
|
"deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}",
|
||||||
"deliveryTarget": "接收目标",
|
"deliveryTarget": "接收目标",
|
||||||
"selectDeliveryTarget": "选择接收目标",
|
"selectDeliveryTarget": "选择接收目标",
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ function getDeliveryAccountDisplayName(account: DeliveryChannelAccount, t: TFunc
|
|||||||
: account.name;
|
: 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 {
|
function isSupportedCronDeliveryChannel(channelType: string): boolean {
|
||||||
return TESTED_CRON_DELIVERY_CHANNELS.has(channelType);
|
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>
|
<p className="text-[12px] text-muted-foreground">{t('dialog.noChannels')}</p>
|
||||||
)}
|
)}
|
||||||
{unsupportedDeliveryChannel && (
|
{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 && (
|
{selectedChannel && (
|
||||||
<p className="text-[12px] text-muted-foreground">
|
<p className="text-[12px] text-muted-foreground">
|
||||||
|
|||||||
@@ -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({
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
name: 'WeChat delivery',
|
name: 'WeChat delivery',
|
||||||
message: 'Send update',
|
message: 'Send update',
|
||||||
@@ -172,7 +172,17 @@ describe('handleCronRoutes', () => {
|
|||||||
enabled: true,
|
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 { handleCronRoutes } = await import('@electron/api/routes/cron');
|
||||||
const handled = await handleCronRoutes(
|
const handled = await handleCronRoutes(
|
||||||
@@ -185,13 +195,14 @@ describe('handleCronRoutes', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
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(sendJsonMock).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
400,
|
200,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
success: false,
|
id: 'job-wechat',
|
||||||
error: expect.stringContaining('WeChat scheduled delivery is not supported'),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
getGatewayStartupRecoveryAction,
|
||||||
hasInvalidConfigFailureSignal,
|
hasInvalidConfigFailureSignal,
|
||||||
isInvalidConfigSignal,
|
isInvalidConfigSignal,
|
||||||
shouldAttemptConfigAutoRepair,
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user