Ensure cron reminders deliver to explicit targets

This commit is contained in:
野生派Coder~
2026-03-03 00:10:20 +08:00
parent d1c277d3c0
commit 8b97064255
5 changed files with 151 additions and 41 deletions

View File

@@ -70,42 +70,7 @@ func gatewayCmd() {
msgBus := bus.NewMessageBus()
cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json")
cronService := cron.NewCronService(cronStorePath, func(job *cron.CronJob) (string, error) {
if job == nil {
return "", nil
}
targetChannel := strings.TrimSpace(job.Payload.Channel)
targetChatID := strings.TrimSpace(job.Payload.To)
message := strings.TrimSpace(job.Payload.Message)
if job.Payload.Deliver && targetChannel != "" && targetChatID != "" && message != "" {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: targetChannel,
ChatID: targetChatID,
Content: message,
})
return "delivered", nil
}
if message == "" {
return "", nil
}
if targetChannel == "" || targetChatID == "" {
targetChannel = "internal"
targetChatID = "cron"
}
msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: "cron",
ChatID: fmt.Sprintf("%s:%s", targetChannel, targetChatID),
Content: message,
SessionKey: fmt.Sprintf("cron:%s", job.ID),
Metadata: map[string]string{
"trigger": "cron",
"job_id": job.ID,
},
})
return "scheduled", nil
return dispatchCronJob(msgBus, job), nil
})
configureCronServiceRuntime(cronService, cfg)
heartbeatService := buildHeartbeatService(cfg, msgBus)
@@ -904,6 +869,56 @@ func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.Me
return agentLoop, channelManager, nil
}
func normalizeCronTargetChatID(channel, chatID string) string {
ch := strings.ToLower(strings.TrimSpace(channel))
target := strings.TrimSpace(chatID)
if ch == "" || target == "" {
return target
}
prefix := ch + ":"
if strings.HasPrefix(strings.ToLower(target), prefix) {
return strings.TrimSpace(target[len(prefix):])
}
return target
}
func dispatchCronJob(msgBus *bus.MessageBus, job *cron.CronJob) string {
if job == nil {
return ""
}
message := strings.TrimSpace(job.Payload.Message)
if message == "" {
return ""
}
targetChannel := strings.TrimSpace(job.Payload.Channel)
targetChatID := normalizeCronTargetChatID(targetChannel, job.Payload.To)
if targetChannel != "" && targetChatID != "" {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: targetChannel,
ChatID: targetChatID,
Content: message,
})
if job.Payload.Deliver {
return "delivered"
}
return "delivered_targeted"
}
msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: "cron",
ChatID: "internal:cron",
Content: message,
SessionKey: fmt.Sprintf("cron:%s", job.ID),
Metadata: map[string]string{
"trigger": "cron",
"job_id": job.ID,
},
})
return "scheduled"
}
func configureCronServiceRuntime(cs *cron.CronService, cfg *config.Config) {
if cs == nil || cfg == nil {
return

View File

@@ -0,0 +1,68 @@
package main
import (
"context"
"testing"
"time"
"clawgo/pkg/bus"
"clawgo/pkg/cron"
)
func TestNormalizeCronTargetChatID(t *testing.T) {
if got := normalizeCronTargetChatID("telegram", "telegram:12345"); got != "12345" {
t.Fatalf("expected 12345, got %q", got)
}
if got := normalizeCronTargetChatID("telegram", "12345"); got != "12345" {
t.Fatalf("expected unchanged chat id, got %q", got)
}
}
func TestDispatchCronJob_DeliversTargetedMessageEvenWhenDeliverFalse(t *testing.T) {
mb := bus.NewMessageBus()
defer mb.Close()
status := dispatchCronJob(mb, &cron.CronJob{
ID: "job-1",
Payload: cron.CronPayload{
Message: "time to sleep",
Deliver: false,
Channel: "telegram",
To: "telegram:5988738763",
},
})
if status != "delivered_targeted" {
t.Fatalf("unexpected status: %s", status)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
out, ok := mb.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected outbound message")
}
if out.Channel != "telegram" || out.ChatID != "5988738763" || out.Content != "time to sleep" {
t.Fatalf("unexpected outbound: %#v", out)
}
}
func TestDispatchCronJob_FallsBackToSystemInboundWithoutTarget(t *testing.T) {
mb := bus.NewMessageBus()
defer mb.Close()
status := dispatchCronJob(mb, &cron.CronJob{ID: "job-2", Payload: cron.CronPayload{Message: "tick"}})
if status != "scheduled" {
t.Fatalf("unexpected status: %s", status)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
in, ok := mb.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message")
}
if in.Channel != "system" || in.ChatID != "internal:cron" {
t.Fatalf("unexpected inbound: %#v", in)
}
}

View File

@@ -97,6 +97,7 @@ const resources = {
kind: 'Kind',
everyMs: 'Interval (ms)',
cronExpression: 'Cron Expression',
runAt: 'Run At',
message: 'Message',
deliver: 'Deliver',
channel: 'Channel',
@@ -280,6 +281,7 @@ const resources = {
kind: '类型',
everyMs: '间隔 (毫秒)',
cronExpression: 'Cron 表达式',
runAt: '执行时间',
message: '消息',
deliver: '投递',
channel: '频道',

View File

@@ -32,6 +32,20 @@ const isNonGroupRecipient = (channel: string, id: string) => {
return true;
};
const formatSchedule = (job: CronJob, t: (key: string) => string) => {
const kind = String(job.schedule?.kind || '').toLowerCase();
if (kind === 'at' && job.schedule?.atMs) {
return {
label: t('runAt'),
value: new Date(job.schedule.atMs).toLocaleString(),
};
}
return {
label: t('cronExpression'),
value: job.expr || '-',
};
};
const Cron: React.FC = () => {
const { t } = useTranslation();
const { cron, refreshCron, q, cfg } = useAppContext();
@@ -76,10 +90,11 @@ const Cron: React.FC = () => {
const r = await fetch(`/webui/api/cron${q}&id=${job.id}`);
if (r.ok) {
const details = await r.json();
const isAtSchedule = String(details.job?.schedule?.kind || '').toLowerCase() === 'at';
setEditingCron(details.job);
setCronForm({
name: details.job.name || '',
expr: details.job.expr || '',
expr: isAtSchedule ? '' : (details.job.expr || ''),
message: details.job.message || '',
deliver: details.job.deliver || false,
channel: details.job.channel || 'telegram',
@@ -141,7 +156,9 @@ const Cron: React.FC = () => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{cron.map((j) => (
{cron.map((j) => {
const schedule = formatSchedule(j, t);
return (
<div key={j.id} className="bg-zinc-900/40 border border-zinc-800/80 rounded-2xl p-6 flex flex-col shadow-sm group hover:border-zinc-700/50 transition-colors">
<div className="flex items-start justify-between mb-4">
<div>
@@ -164,8 +181,8 @@ const Cron: React.FC = () => {
<div className="flex-1 space-y-3 mb-6">
<div className="text-sm text-zinc-400 line-clamp-2 italic">"{j.message}"</div>
<div className="bg-zinc-950/50 rounded-lg p-2 border border-zinc-800/50">
<div className="text-[10px] text-zinc-500 uppercase mb-0.5">{t('cronExpression')}</div>
<div className="text-xs text-zinc-300 font-medium break-all">{j.expr || '-'}</div>
<div className="text-[10px] text-zinc-500 uppercase mb-0.5">{schedule.label}</div>
<div className="text-xs text-zinc-300 font-medium break-all">{schedule.value}</div>
</div>
</div>
@@ -192,7 +209,8 @@ const Cron: React.FC = () => {
</button>
</div>
</div>
))}
);
})}
{cron.length === 0 && (
<div className="col-span-full py-20 bg-zinc-900/20 border border-dashed border-zinc-800 rounded-3xl flex flex-col items-center justify-center text-zinc-500">
<Clock className="w-12 h-12 mb-4 opacity-20" />

View File

@@ -6,6 +6,13 @@ export type CronJob = {
name: string;
enabled: boolean;
expr?: string;
schedule?: {
kind?: string;
atMs?: number;
everyMs?: number;
expr?: string;
tz?: string;
};
message?: string;
deliver?: boolean;
channel?: string;