mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 21:57:29 +08:00
Ensure cron reminders deliver to explicit targets
This commit is contained in:
@@ -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
|
||||
|
||||
68
cmd/clawgo/cmd_gateway_test.go
Normal file
68
cmd/clawgo/cmd_gateway_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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: '频道',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user