From 3fa8a02d4bf6e0255e51c44fa436965e906ac66f Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 18 Apr 2026 17:44:57 +0700 Subject: [PATCH] feat(tools): add MessageTargetEnforced session guard helper Classifies session kinds into FREE (cron, heartbeat, subagent, team) vs ENFORCED (DM, group, ws, default, unknown, empty). FREE kinds compose targets per job spec; everything else must match the ctx-bound origin. postCrossTargetNotice posts the audit breadcrumb back to the origin chat when an explicit forward succeeds: msgBus preferred, falls back to direct sender, else slog.Warn so the skip is traceable. --- internal/tools/message_guard.go | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 internal/tools/message_guard.go diff --git a/internal/tools/message_guard.go b/internal/tools/message_guard.go new file mode 100644 index 00000000..7eea797b --- /dev/null +++ b/internal/tools/message_guard.go @@ -0,0 +1,73 @@ +package tools + +import ( + "context" + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/sessions" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// MessageTargetEnforced reports whether the message tool must enforce +// (channel, target) == ctx (channel, target) for this session kind. +// +// FREE kinds (cron, heartbeat, subagent, team) compose targets per job spec +// and are allowed to address arbitrary chats. Everything else (DM, group, +// default, unknown, empty) enforces the bound origin — fail-closed. +func MessageTargetEnforced(sessionKey string) bool { + if sessionKey == "" { + return true + } + if sessions.IsCronSession(sessionKey) { + return false + } + if sessions.IsHeartbeatSession(sessionKey) { + return false + } + if sessions.IsSubagentSession(sessionKey) { + return false + } + if sessions.IsTeamSession(sessionKey) { + return false + } + return true +} + +// postCrossTargetNotice posts a short notice back to the origin chat after +// an explicit, SUCCESSFUL cross-target forward. Call inline post-send — not +// via defer — so that failed forwards don't announce fake deliveries. +// +// Prefers msgBus (async, group-aware). Falls back to t.sender so deployments +// that wire only a direct sender still get the audit breadcrumb. Last resort: +// slog.Warn so the skip is still traceable. +func (t *MessageTool) postCrossTargetNotice(ctx context.Context, target, reason string) { + origCh := ToolChannelFromCtx(ctx) + origChat := ToolChatIDFromCtx(ctx) + if origCh == "" || origChat == "" { + return + } + locale := store.LocaleFromContext(ctx) + text := i18n.T(locale, i18n.MessageCrossTargetForwarded, target, reason) + + if t.msgBus != nil { + msg := bus.OutboundMessage{Channel: origCh, ChatID: origChat, Content: text} + if isGroupContext(ctx) { + msg.Metadata = map[string]string{"group_id": origChat} + } + t.msgBus.PublishOutbound(msg) + return + } + if t.sender != nil { + if err := t.sender(ctx, origCh, origChat, text); err == nil { + return + } else { + slog.Warn("message.cross_target_notice_send_failed", + "channel", origCh, "chat", origChat, "err", err) + return + } + } + slog.Warn("message.cross_target_notice_skipped", + "reason", "no_bus_or_sender", "channel", origCh, "chat", origChat) +}