Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/content/building-gormes/architecture_plan/progress.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,17 +319,17 @@
},
{
"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": [
"gateway",
"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",
Expand All @@ -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"
Expand Down
234 changes: 234 additions & 0 deletions internal/channels/whatsapp/identity.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading