diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json index 58eda9b33..123b091ca 100644 --- a/docs/content/building-gormes/architecture_plan/progress.json +++ b/docs/content/building-gormes/architecture_plan/progress.json @@ -319,9 +319,9 @@ }, { "name": "WhatsApp identity resolution + self-chat guard", - "status": "planned", + "status": "complete", "contract": "WhatsApp bot identity, self-chat suppression, and peer mapping stay deterministic across bridge and native runtimes", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "gateway", "trust_class": [ @@ -329,7 +329,7 @@ "operator" ], "degraded_mode": "Gateway status reports unresolved WhatsApp bot identity instead of accepting self-chat loops or ambiguous peer mappings.", - "fixture": "internal/channels/whatsapp identity fixtures", + "fixture": "internal/channels/whatsapp/testdata/identity_contract.json", "source_refs": [ "../hermes-agent/gateway/whatsapp_identity.py", "../hermes-agent/gateway/platforms/whatsapp.py", @@ -350,7 +350,7 @@ "Messages from the bot's own identity are ignored or surfaced as self-chat suppression, not routed back into the kernel.", "Group and DM peers preserve raw platform IDs needed for outbound delivery." ], - "note": "Hermes now has `gateway/whatsapp_identity.py`; Gormes should freeze identity resolution as its own WhatsApp slice before pairing/reconnect/send broadens the adapter.", + "note": "TDD landed: internal/channels/whatsapp now exposes the runtime-owned bot identity source, fixture-tested bridge/native peer canonicalization, raw WhatsApp reply peers for outbound delivery, self-chat suppression decisions, and unresolved bot identity degraded status before pairing/reconnect/send wiring.", "write_scope": [ "internal/channels/whatsapp/", "docs/content/building-gormes/architecture_plan/progress.json" diff --git a/internal/channels/whatsapp/identity.go b/internal/channels/whatsapp/identity.go new file mode 100644 index 000000000..aa274bdf8 --- /dev/null +++ b/internal/channels/whatsapp/identity.go @@ -0,0 +1,234 @@ +package whatsapp + +import ( + "sort" + "strings" + + "github.com/TrebuchetDynamics/gormes-agent/internal/gateway" +) + +const botIdentityUnresolvedReason = "bot_identity_unresolved" + +// IdentityContext carries runtime-specific identity inputs that are known +// before send/reconnect code is wired. +type IdentityContext struct { + Runtime RuntimeKind + AccountMode AccountMode + BotIDs []string + NativeBotID string + AliasMappings []IdentityAlias + ReplyPrefix string + RecentSentMessageIDs []string +} + +// IdentityAlias maps bridge-observed LID and phone forms for the same person. +type IdentityAlias struct { + From string + To string +} + +// InboundDecision identifies whether an inbound WhatsApp message can enter the +// gateway manager or must be stopped at the adapter boundary. +type InboundDecision string + +const ( + InboundDecisionRoute InboundDecision = "route" + InboundDecisionDrop InboundDecision = "drop" + InboundDecisionSuppressSelfChat InboundDecision = "suppress_self_chat" + InboundDecisionUnresolvedIdentity InboundDecision = "unresolved_identity" +) + +// SelfChatSuppressionReason is the stable reason surfaced for self-message +// drops that would otherwise create gateway loops. +type SelfChatSuppressionReason string + +const ( + SelfChatSuppressionBotOwnMessage SelfChatSuppressionReason = "bot_own_message" + SelfChatSuppressionAgentEcho SelfChatSuppressionReason = "agent_echo" + SelfChatSuppressionBotIdentityUnresolved SelfChatSuppressionReason = botIdentityUnresolvedReason +) + +// IdentityStatus is the status payload a future gateway status command can +// report when bot/self identity cannot be resolved safely. +type IdentityStatus struct { + Source IdentitySource + Resolved bool + BotID string + RawBotID string + Reason string +} + +// SessionIdentity captures stable gateway peer IDs while retaining raw +// WhatsApp JIDs needed for outbound delivery. +type SessionIdentity struct { + ChatKind ChatKind + ChatID string + UserID string + RawChatID string + RawUserID string + BotID string + RawBotID string + BotIdentitySource IdentitySource +} + +// ReplyTarget is the raw platform peer required by future send code. +type ReplyTarget struct { + ChatID string + ChatKind ChatKind +} + +// SelfChatSuppression describes a self-message that was deliberately kept out +// of the kernel route. +type SelfChatSuppression struct { + Reason SelfChatSuppressionReason + ChatID string + UserID string + MessageID string +} + +// InboundResult contains either a routable gateway event or the reason it was +// stopped before reaching the gateway manager. +type InboundResult struct { + Event gateway.InboundEvent + Identity SessionIdentity + Reply ReplyTarget + Status IdentityStatus + Suppression SelfChatSuppression + Decision InboundDecision +} + +// Routed reports whether the event should be sent to gateway.Manager. +func (r InboundResult) Routed() bool { + return r.Decision == InboundDecisionRoute +} + +// NormalizeWhatsAppIdentifier strips WhatsApp JID/LID/device syntax down to a +// stable peer identifier suitable for gateway equality checks. +func NormalizeWhatsAppIdentifier(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "+") + if value == "" { + return "" + } + if before, _, ok := strings.Cut(value, ":"); ok { + value = before + } + if before, _, ok := strings.Cut(value, "@"); ok { + value = before + } + return strings.TrimSpace(value) +} + +func resolveBotIdentity(ctx IdentityContext, msg InboundMessage) IdentityStatus { + source := identitySourceForRuntime(ctx.Runtime) + var candidates []string + switch source { + case IdentitySourceNativeSession: + candidates = append(candidates, ctx.NativeBotID) + default: + candidates = append(candidates, msg.BotIDs...) + candidates = append(candidates, ctx.BotIDs...) + } + + for _, raw := range candidates { + botID := canonicalWhatsAppUserID(raw, ctx.AliasMappings) + if botID == "" { + continue + } + return IdentityStatus{ + Source: source, + Resolved: true, + BotID: botID, + RawBotID: strings.TrimSpace(raw), + } + } + + return IdentityStatus{ + Source: source, + Reason: botIdentityUnresolvedReason, + } +} + +func canonicalWhatsAppUserID(raw string, aliases []IdentityAlias) string { + start := NormalizeWhatsAppIdentifier(raw) + if start == "" { + return "" + } + + graph := map[string][]string{} + for _, alias := range aliases { + from := NormalizeWhatsAppIdentifier(alias.From) + to := NormalizeWhatsAppIdentifier(alias.To) + if from == "" || to == "" { + continue + } + graph[from] = append(graph[from], to) + graph[to] = append(graph[to], from) + } + + seen := map[string]bool{} + queue := []string{start} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if current == "" || seen[current] { + continue + } + seen[current] = true + queue = append(queue, graph[current]...) + } + if len(seen) == 0 { + return start + } + + candidates := make([]string, 0, len(seen)) + for candidate := range seen { + candidates = append(candidates, candidate) + } + sort.Slice(candidates, func(i, j int) bool { + if len(candidates[i]) != len(candidates[j]) { + return len(candidates[i]) < len(candidates[j]) + } + return candidates[i] < candidates[j] + }) + return candidates[0] +} + +func canonicalWhatsAppChatID(raw string, kind ChatKind, aliases []IdentityAlias) string { + if normalizedChatKind(kind, raw) == ChatKindDirect { + return canonicalWhatsAppUserID(raw, aliases) + } + return NormalizeWhatsAppIdentifier(raw) +} + +func normalizedChatKind(kind ChatKind, rawChatID string) ChatKind { + switch ChatKind(strings.ToLower(strings.TrimSpace(string(kind)))) { + case ChatKindDirect: + return ChatKindDirect + case ChatKindGroup: + return ChatKindGroup + } + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(rawChatID)), "@g.us") { + return ChatKindGroup + } + return ChatKindDirect +} + +func normalizedAccountMode(mode AccountMode) AccountMode { + normalized := strings.TrimSpace(strings.ToLower(string(mode))) + normalized = strings.ReplaceAll(normalized, "_", "-") + if normalized == string(AccountModeBot) { + return AccountModeBot + } + return AccountModeSelfChat +} + +func recentMessageIDSet(ids []string) map[string]bool { + out := make(map[string]bool, len(ids)) + for _, id := range ids { + if id = strings.TrimSpace(id); id != "" { + out[id] = true + } + } + return out +} diff --git a/internal/channels/whatsapp/identity_test.go b/internal/channels/whatsapp/identity_test.go new file mode 100644 index 000000000..bd76113a0 --- /dev/null +++ b/internal/channels/whatsapp/identity_test.go @@ -0,0 +1,148 @@ +package whatsapp + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +func TestDecideRuntime_IdentitySourceFollowsSelectedRuntime(t *testing.T) { + stateRoot := filepath.Join(t.TempDir(), "state") + + bridgePlan, err := DecideRuntime(RuntimeConfig{ + StateRoot: stateRoot, + }) + if err != nil { + t.Fatalf("DecideRuntime(bridge) error = %v, want nil", err) + } + if bridgePlan.Identity.BotIdentitySource != IdentitySourceBridgeMessage { + t.Fatalf("bridge BotIdentitySource = %q, want %q", bridgePlan.Identity.BotIdentitySource, IdentitySourceBridgeMessage) + } + + nativePlan, err := DecideRuntime(RuntimeConfig{ + Preference: RuntimePreferenceNativeFirst, + StateRoot: stateRoot, + Native: NativeRuntimeConfig{ + Enabled: true, + }, + }) + if err != nil { + t.Fatalf("DecideRuntime(native) error = %v, want nil", err) + } + if nativePlan.Identity.BotIdentitySource != IdentitySourceNativeSession { + t.Fatalf("native BotIdentitySource = %q, want %q", nativePlan.Identity.BotIdentitySource, IdentitySourceNativeSession) + } +} + +func TestNormalizeInboundWithIdentity_Fixtures(t *testing.T) { + fixtures := loadIdentityFixtures(t) + keys := make([]string, 0, len(fixtures)) + for key := range fixtures { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + tt := fixtures[key] + t.Run(key, func(t *testing.T) { + got := NormalizeInboundWithIdentity(tt.Message, tt.Context) + + if string(got.Decision) != tt.Want.Decision { + t.Fatalf("Decision = %q, want %q", got.Decision, tt.Want.Decision) + } + if got.Status.Source != IdentitySource(tt.Want.BotIdentitySource) { + t.Fatalf("Status.Source = %q, want %q", got.Status.Source, tt.Want.BotIdentitySource) + } + if got.Status.Resolved != tt.Want.BotResolved { + t.Fatalf("Status.Resolved = %v, want %v", got.Status.Resolved, tt.Want.BotResolved) + } + if got.Status.BotID != tt.Want.BotID { + t.Fatalf("Status.BotID = %q, want %q", got.Status.BotID, tt.Want.BotID) + } + if got.Status.Reason != tt.Want.StatusReason { + t.Fatalf("Status.Reason = %q, want %q", got.Status.Reason, tt.Want.StatusReason) + } + + if tt.Want.SuppressionReason != "" { + if got.Suppression.Reason != SelfChatSuppressionReason(tt.Want.SuppressionReason) { + t.Fatalf("Suppression.Reason = %q, want %q", got.Suppression.Reason, tt.Want.SuppressionReason) + } + if got.Routed() { + t.Fatal("Routed() = true for suppressed message, want false") + } + return + } + + if !got.Routed() { + t.Fatal("Routed() = false, want true") + } + if got.Event.Kind.String() != tt.Want.Kind { + t.Fatalf("Event.Kind = %q, want %q", got.Event.Kind.String(), tt.Want.Kind) + } + if got.Event.Text != tt.Want.Text { + t.Fatalf("Event.Text = %q, want %q", got.Event.Text, tt.Want.Text) + } + if got.Event.ChatID != tt.Want.EventChatID { + t.Fatalf("Event.ChatID = %q, want %q", got.Event.ChatID, tt.Want.EventChatID) + } + if got.Event.UserID != tt.Want.EventUserID { + t.Fatalf("Event.UserID = %q, want %q", got.Event.UserID, tt.Want.EventUserID) + } + if got.Identity.ChatID != tt.Want.IdentityChatID { + t.Fatalf("Identity.ChatID = %q, want %q", got.Identity.ChatID, tt.Want.IdentityChatID) + } + if got.Identity.UserID != tt.Want.IdentityUserID { + t.Fatalf("Identity.UserID = %q, want %q", got.Identity.UserID, tt.Want.IdentityUserID) + } + if got.Identity.RawChatID != tt.Want.RawChatID { + t.Fatalf("Identity.RawChatID = %q, want %q", got.Identity.RawChatID, tt.Want.RawChatID) + } + if got.Identity.RawUserID != tt.Want.RawUserID { + t.Fatalf("Identity.RawUserID = %q, want %q", got.Identity.RawUserID, tt.Want.RawUserID) + } + if got.Reply.ChatID != tt.Want.ReplyChatID { + t.Fatalf("Reply.ChatID = %q, want %q", got.Reply.ChatID, tt.Want.ReplyChatID) + } + }) + } +} + +type identityFixture struct { + Context IdentityContext `json:"Context"` + Message InboundMessage `json:"Message"` + Want identityWant `json:"Want"` +} + +type identityWant struct { + Decision string `json:"Decision"` + Kind string `json:"Kind"` + Text string `json:"Text"` + EventChatID string `json:"EventChatID"` + EventUserID string `json:"EventUserID"` + IdentityChatID string `json:"IdentityChatID"` + IdentityUserID string `json:"IdentityUserID"` + RawChatID string `json:"RawChatID"` + RawUserID string `json:"RawUserID"` + ReplyChatID string `json:"ReplyChatID"` + BotIdentitySource string `json:"BotIdentitySource"` + BotResolved bool `json:"BotResolved"` + BotID string `json:"BotID"` + StatusReason string `json:"StatusReason"` + SuppressionReason string `json:"SuppressionReason"` +} + +func loadIdentityFixtures(t *testing.T) map[string]identityFixture { + t.Helper() + + raw, err := os.ReadFile(filepath.Join("testdata", "identity_contract.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var fixtures map[string]identityFixture + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + return fixtures +} diff --git a/internal/channels/whatsapp/inbound.go b/internal/channels/whatsapp/inbound.go index b28646a26..822bc8551 100644 --- a/internal/channels/whatsapp/inbound.go +++ b/internal/channels/whatsapp/inbound.go @@ -8,8 +8,6 @@ import ( const platformName = "whatsapp" -var peerIDSuffixes = []string{"@s.whatsapp.net", "@c.us", "@g.us"} - // ChatKind distinguishes direct chats from group chats without committing to a // particular transport implementation. type ChatKind string @@ -30,6 +28,8 @@ type InboundMessage struct { MessageID string Text string Mentioned bool + FromMe bool + BotIDs []string } // NormalizeInbound maps a WhatsApp transport event onto the shared gateway @@ -37,29 +37,74 @@ type InboundMessage struct { // gateway.ParseInboundText so the adapter never consumes generic commands // locally. func NormalizeInbound(msg InboundMessage) (gateway.InboundEvent, bool) { - userID := normalizePeerID(msg.UserID) - if userID == "" { + result := NormalizeInboundWithIdentity(msg, IdentityContext{}) + if !result.Routed() { return gateway.InboundEvent{}, false } + return result.Event, true +} + +// NormalizeInboundWithIdentity maps a WhatsApp transport event onto the shared +// gateway contract while preserving the identity and raw reply peer metadata +// future send code needs. +func NormalizeInboundWithIdentity(msg InboundMessage, identity IdentityContext) InboundResult { + status := resolveBotIdentity(identity, msg) + + rawUserID := strings.TrimSpace(msg.UserID) + userID := canonicalWhatsAppUserID(rawUserID, identity.AliasMappings) + if userID == "" { + return InboundResult{Decision: InboundDecisionDrop, Status: status} + } text := strings.TrimSpace(msg.Text) if text == "" { - return gateway.InboundEvent{}, false + return InboundResult{Decision: InboundDecisionDrop, Status: status} } if msg.Mentioned { text = stripLeadingMentions(text) if text == "" { - return gateway.InboundEvent{}, false + return InboundResult{Decision: InboundDecisionDrop, Status: status} } } - chatID := normalizePeerID(msg.ChatID) + rawChatID := strings.TrimSpace(msg.ChatID) + if rawChatID == "" { + rawChatID = rawUserID + } + chatKind := normalizedChatKind(msg.ChatKind, rawChatID) + chatID := canonicalWhatsAppChatID(rawChatID, chatKind, identity.AliasMappings) if chatID == "" { chatID = userID } + result := InboundResult{ + Identity: SessionIdentity{ + ChatKind: chatKind, + ChatID: chatID, + UserID: userID, + RawChatID: rawChatID, + RawUserID: rawUserID, + BotID: status.BotID, + RawBotID: status.RawBotID, + BotIdentitySource: status.Source, + }, + Reply: ReplyTarget{ + ChatID: rawChatID, + ChatKind: chatKind, + }, + Status: status, + } + if suppression, ok := selfChatSuppression(msg, text, identity, result.Identity, status); ok { + result.Decision = InboundDecisionSuppressSelfChat + if suppression.Reason == SelfChatSuppressionBotIdentityUnresolved { + result.Decision = InboundDecisionUnresolvedIdentity + } + result.Suppression = suppression + return result + } + kind, body := gateway.ParseInboundText(text) - return gateway.InboundEvent{ + result.Event = gateway.InboundEvent{ Platform: platformName, ChatID: chatID, ChatName: strings.TrimSpace(msg.ChatName), @@ -68,21 +113,41 @@ func NormalizeInbound(msg InboundMessage) (gateway.InboundEvent, bool) { MsgID: strings.TrimSpace(msg.MessageID), Kind: kind, Text: body, - }, true + } + result.Decision = InboundDecisionRoute + return result } -func normalizePeerID(id string) string { - id = strings.TrimSpace(id) - if id == "" { - return "" +func selfChatSuppression(msg InboundMessage, text string, ctx IdentityContext, identity SessionIdentity, status IdentityStatus) (SelfChatSuppression, bool) { + if !msg.FromMe && (status.BotID == "" || identity.UserID != status.BotID) { + return SelfChatSuppression{}, false } - lower := strings.ToLower(id) - for _, suffix := range peerIDSuffixes { - if strings.HasSuffix(lower, suffix) { - return id[:len(id)-len(suffix)] + + suppression := SelfChatSuppression{ + ChatID: identity.ChatID, + UserID: identity.UserID, + MessageID: strings.TrimSpace(msg.MessageID), + } + if msg.FromMe && !status.Resolved { + suppression.Reason = SelfChatSuppressionBotIdentityUnresolved + return suppression, true + } + + switch normalizedAccountMode(ctx.AccountMode) { + case AccountModeBot: + if msg.FromMe || (status.BotID != "" && identity.UserID == status.BotID) { + suppression.Reason = SelfChatSuppressionBotOwnMessage + return suppression, true + } + case AccountModeSelfChat: + recent := recentMessageIDSet(ctx.RecentSentMessageIDs) + replyPrefix := ctx.ReplyPrefix + if msg.FromMe && ((replyPrefix != "" && strings.HasPrefix(text, replyPrefix)) || recent[suppression.MessageID]) { + suppression.Reason = SelfChatSuppressionAgentEcho + return suppression, true } } - return id + return SelfChatSuppression{}, false } func stripLeadingMentions(text string) string { diff --git a/internal/channels/whatsapp/runtime.go b/internal/channels/whatsapp/runtime.go index d397aa016..fd5b12795 100644 --- a/internal/channels/whatsapp/runtime.go +++ b/internal/channels/whatsapp/runtime.go @@ -71,6 +71,7 @@ type RuntimePlan struct { Bridge BridgePlan Native NativePlan Account AccountPlan + Identity IdentityPlan } // StartupPlan freezes the selected runtime and the candidate order. @@ -117,6 +118,21 @@ type AccountPlan struct { DropsOwnMessages bool } +// IdentitySource identifies where the selected runtime reports the bot/self +// peer identity used for self-message filtering. +type IdentitySource string + +const ( + IdentitySourceBridgeMessage IdentitySource = "bridge_message" + IdentitySourceNativeSession IdentitySource = "native_session" +) + +// IdentityPlan freezes which runtime-specific identity source owns bot/self +// peer resolution before transport send/reconnect code is wired. +type IdentityPlan struct { + BotIdentitySource IdentitySource +} + // DecideRuntime selects the WhatsApp runtime and freezes its session/account // policy without checking the filesystem, spawning Node, or importing a native // WhatsApp client. @@ -152,6 +168,9 @@ func DecideRuntime(cfg RuntimeConfig) (RuntimePlan, error) { ContainsCredentials: true, }, Account: account, + Identity: IdentityPlan{ + BotIdentitySource: identitySourceForRuntime(selected), + }, } if selected == RuntimeKindBridge { plan.Bridge = decideBridge(cfg.Bridge, sessionPath, account.Mode) @@ -162,6 +181,13 @@ func DecideRuntime(cfg RuntimeConfig) (RuntimePlan, error) { return plan, nil } +func identitySourceForRuntime(runtime RuntimeKind) IdentitySource { + if runtime == RuntimeKindNative { + return IdentitySourceNativeSession + } + return IdentitySourceBridgeMessage +} + func normalizeRuntimePreference(preference RuntimePreference) (RuntimePreference, error) { raw := strings.TrimSpace(strings.ToLower(string(preference))) raw = strings.ReplaceAll(raw, "-", "_") diff --git a/internal/channels/whatsapp/testdata/identity_contract.json b/internal/channels/whatsapp/testdata/identity_contract.json new file mode 100644 index 000000000..a75fb0695 --- /dev/null +++ b/internal/channels/whatsapp/testdata/identity_contract.json @@ -0,0 +1,135 @@ +{ + "bridge_dm_lid_alias_routes_with_raw_delivery_peer": { + "Context": { + "Runtime": "bridge", + "AccountMode": "bot", + "BotIDs": ["18005550100:10@s.whatsapp.net"], + "AliasMappings": [ + { + "From": "999999999999999@lid", + "To": "15551234567@s.whatsapp.net" + } + ] + }, + "Message": { + "ChatID": "999999999999999@lid", + "ChatName": "Alice", + "ChatKind": "direct", + "UserID": "999999999999999@lid", + "UserName": "Alice", + "MessageID": "wamid-bridge-1", + "Text": " hello from bridge " + }, + "Want": { + "Decision": "route", + "Kind": "submit", + "Text": "hello from bridge", + "EventChatID": "15551234567", + "EventUserID": "15551234567", + "IdentityChatID": "15551234567", + "IdentityUserID": "15551234567", + "RawChatID": "999999999999999@lid", + "RawUserID": "999999999999999@lid", + "ReplyChatID": "999999999999999@lid", + "BotIdentitySource": "bridge_message", + "BotResolved": true, + "BotID": "18005550100" + } + }, + "native_group_message_routes_with_raw_group_delivery_peer": { + "Context": { + "Runtime": "native", + "AccountMode": "bot", + "NativeBotID": "18005550100:5@s.whatsapp.net" + }, + "Message": { + "ChatID": "120363000000000000@g.us", + "ChatName": "Ops", + "ChatKind": "group", + "UserID": "15557654321:47@s.whatsapp.net", + "UserName": "Bob", + "MessageID": "wamid-native-1", + "Text": " /new " + }, + "Want": { + "Decision": "route", + "Kind": "reset", + "EventChatID": "120363000000000000", + "EventUserID": "15557654321", + "IdentityChatID": "120363000000000000", + "IdentityUserID": "15557654321", + "RawChatID": "120363000000000000@g.us", + "RawUserID": "15557654321:47@s.whatsapp.net", + "ReplyChatID": "120363000000000000@g.us", + "BotIdentitySource": "native_session", + "BotResolved": true, + "BotID": "18005550100" + } + }, + "bot_mode_from_me_message_is_suppressed": { + "Context": { + "Runtime": "bridge", + "AccountMode": "bot", + "BotIDs": ["18005550100@s.whatsapp.net"] + }, + "Message": { + "ChatID": "15551234567@s.whatsapp.net", + "ChatKind": "direct", + "UserID": "18005550100@s.whatsapp.net", + "MessageID": "wamid-own-1", + "Text": "agent echo", + "FromMe": true + }, + "Want": { + "Decision": "suppress_self_chat", + "BotIdentitySource": "bridge_message", + "BotResolved": true, + "BotID": "18005550100", + "SuppressionReason": "bot_own_message" + } + }, + "self_chat_prefixed_agent_echo_is_suppressed": { + "Context": { + "Runtime": "bridge", + "AccountMode": "self-chat", + "BotIDs": ["15550001111:10@s.whatsapp.net"], + "ReplyPrefix": "[Gormes]\n" + }, + "Message": { + "ChatID": "15550001111@s.whatsapp.net", + "ChatKind": "direct", + "UserID": "15550001111@s.whatsapp.net", + "MessageID": "wamid-echo-1", + "Text": "[Gormes]\nanswer", + "FromMe": true + }, + "Want": { + "Decision": "suppress_self_chat", + "BotIdentitySource": "bridge_message", + "BotResolved": true, + "BotID": "15550001111", + "SuppressionReason": "agent_echo" + } + }, + "native_from_me_without_bot_identity_degrades": { + "Context": { + "Runtime": "native", + "AccountMode": "bot" + }, + "Message": { + "ChatID": "15551234567@s.whatsapp.net", + "ChatKind": "direct", + "UserID": "15551234567@s.whatsapp.net", + "MessageID": "wamid-unresolved-1", + "Text": "loop risk", + "FromMe": true + }, + "Want": { + "Decision": "unresolved_identity", + "BotIdentitySource": "native_session", + "BotResolved": false, + "StatusReason": "bot_identity_unresolved", + "SuppressionReason": "bot_identity_unresolved" + } + } +}