[PRD] Deepen WhatsApp Target Resolution into Canonical Target Facts
Refactor WhatsApp target handling into a canonical target facts module to eliminate scattered normalization logic and ensure consistent behavior across all send paths.
π Current State (Problem Context)
The WhatsApp target handling currently exhibits fragmented normalization behavior across multiple code paths. The same raw target is processed differently depending on which module receives it, leading to inconsistent behavior and maintenance complexity.
Symptomatic Behaviors
Inconsistent Phone Number Normalization
javascript // Path 1: Message command parsing const target = normalizeForMessage(rawTarget); // Result: +15551234567 β 15551234567
// Path 2: Outbound authorization const auth = checkAllowFrom(rawTarget); // Result: +15551234567 β different normalization
// Path 3: Session routing
const session = resolveSession(rawTarget);
// Result: +15551234567 β yet another normalization
Scattered Classification Logic
The chat type (direct/group/newsletter) is determined in multiple locations:
javascript // In message_command.js if (target.includes(’@g.us’)) classifyAs(‘group’);
// In session_routing.js if (jid.endsWith(’@newsletter’)) classifyAs(‘channel’);
// In auth_check.js if (target.startsWith(‘wa.me’)) handlePrefix();
Duplicate Wire JID Conversion
javascript
// Wire delivery JID selection is duplicated across:
adapter.getWireJID(target); // send adapter
routing.resolvePeer(target); // routing module
delivery.selectJID(target, lidMap); // delivery path
Policy Knowledge Distributed
AllowFrom semantics appear in:
whatsapp_auth.jsfor sendswhatsapp_actions.jsfor reactionschannel_send.jsfor channel posts
π§ Architectural Rationale
Why This Matters
The current architecture violates the single responsibility principle for target handling. Each module that touches WhatsApp targets must understand:
- Raw target formats (E.164, JIDs, prefixes, LID)
- Normalization rules specific to each path
- Chat type classification heuristics
- AllowFrom policy evaluation
- Wire JID conversion logic
- Presence behavior per target type
This creates a maintenance anti-pattern: changing target behavior requires modifying N modules, each with partial context.
The Deep Module Pattern
The solution adopts the “deep module” anti-pattern correction. Rather than shallow helper functions scattered across callers, consolidate target resolution into a single, stable interface:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Canonical Target Facts β β βββββββββββββββ ββββββββββββ βββββββββββββ βββββββββββ β β β Normalize βββ Classify βββ Authorize βββ Wire JIDβ β β βββββββββββββββ ββββββββββββ βββββββββββββ βββββββββββ β β β β β ββββββββββββββββββ β β β Route Peer β β β ββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Preserved Contracts
The refactor must preserve existing shipped behavior:
| Target Type | AllowFrom Behavior | Presence Behavior | Wire JID Source |
|---|---|---|---|
| Direct | Respects allowFrom | Enabled | Auth mapping or phone |
| Group | Bypassed | Enabled | Group JID |
| Newsletter | Bypassed | Disabled | Newsletter JID |
| LID-mapped | Uses LID JID | Enabled | Auth mapping |
| Prefixed | Strip prefix | Varies by target type | Resolved from base |
Risk Mitigation
The refactor is safe to execute because:
- Behavior preservation tests verify no regressions
- Interface boundaries remain stable
- WhatsApp-specific logic stays in the plugin
- No core dependencies are introduced
π οΈ Implementation Approach
Module Location
Create or deepen the module at:
plugins/whatsapp/src/target-facts.ts
Interface Design
typescript interface WhatsAppTargetFactsInput { rawTarget: string; // User-supplied target allowFrom?: string[]; // Current allowFrom policy authMapping?: Map<string, string>; // PhoneβLID JID mapping }
interface WhatsAppTargetFacts { // Normalization normalizedTarget: string; // Canonical form
// Classification chatType: ‘direct’ | ‘group’ | ‘channel’; isNewsletter: boolean;
// Authorization outboundAuthorized: boolean; authorizationReason?: string;
// Routing routePeer: RoutePeerFacts;
// Delivery wireJid: string; useComposingPresence: boolean;
// Metadata originalFormats: string[]; // What formats appeared in input }
Step-by-Step Implementation
Phase 1: Create Canonical Module
typescript // plugins/whatsapp/src/target-facts.ts
export function resolveTargetFacts( input: WhatsAppTargetFactsInput ): WhatsAppTargetFacts { // 1. Normalize raw target const normalized = normalizeTarget(input.rawTarget);
// 2. Classify chat type const chatType = classifyChatType(normalized); const isNewsletter = chatType === ‘channel’;
// 3. Determine authorization const authDecision = resolveOutboundAuthorization( normalized, chatType, input.allowFrom );
// 4. Compute route peer const routePeer = buildRoutePeer(normalized, chatType);
// 5. Select wire JID const wireJid = resolveWireJid(normalized, input.authMapping);
// 6. Determine presence behavior const useComposingPresence = !isNewsletter;
return { normalizedTarget: normalized, chatType, isNewsletter, outboundAuthorized: authDecision.authorized, authorizationReason: authDecision.reason, routePeer, wireJid, useComposingPresence, originalFormats: extractFormats(input.rawTarget) }; }
Phase 2: Migrate Callers
Replace local target interpretation in:
typescript // plugins/whatsapp/src/message-command.ts // BEFORE function parseCommand(raw: string) { const target = raw.includes(’:’) ? raw.split(’:’)[0] : raw; const normalized = target.startsWith(‘whatsapp:’) ? target.slice(9) : target; // … send path }
// AFTER function parseCommand(raw: string) { const facts = resolveTargetFacts({ rawTarget: raw }); // … send path using facts.wireJid, facts.chatType, etc. }
typescript // plugins/whatsapp/src/session-routing.ts // BEFORE function routeToSession(target: string) { const peer = target.includes(’@g.us’) ? { type: ‘group’, jid: target } : { type: ‘direct’, jid: normalizePhone(target) }; }
// AFTER function routeToSession(target: string) { const facts = resolveTargetFacts({ rawTarget: target }); return { type: facts.chatType, jid: facts.routePeer.jid }; }
Phase 3: Consolidate Authorization
typescript // plugins/whatsapp/src/outbound-auth.ts // BEFORE: Duplicated allowFrom logic in each caller
// AFTER export function checkOutboundAuth( facts: WhatsAppTargetFacts, allowFrom: string[] ): AuthResult { // Newsletter and group targets bypass direct allowFrom if (facts.chatType !== ‘direct’) { return { authorized: true, reason: ‘bypass_non_direct’ }; }
// Empty allowFrom preserves current behavior (allow all) if (!allowFrom?.length) { return { authorized: true, reason: ’no_policy’ }; }
// Wildcard handling if (allowFrom.includes(’*’)) { return { authorized: true, reason: ‘wildcard’ }; }
// Normalized matching return normalizeAndMatch(facts.normalizedTarget, allowFrom); }
Phase 4: Wire JID Resolution with LID Awareness
typescript function resolveWireJid( normalized: string, authMapping?: Map<string, string> ): string { // Newsletter and group JIDs pass through unchanged if (normalized.includes(’@newsletter’) || normalized.includes(’@g.us’)) { return normalized; }
// Check for LID auth mapping if (authMapping?.has(normalized)) { return authMapping.get(normalized); }
// Direct numbers need JID suffix
if (/^\d+$/.test(normalized)) {
return ${normalized}@s.whatsapp.net;
}
return normalized; }
Branch and Commit Strategy
bash
Create worktree
git worktree add ../whatsapp-target-refactor -b refactor/whatsapp-target-facts
Branch naming convention
refactor/whatsapp-target-facts
Commit messages (conventional commits)
git commit -m “refactor(whatsapp): add canonical target facts module” git commit -m “refactor(whatsapp): migrate message-command to target-facts” git commit -m “refactor(whatsapp): migrate session-routing to target-facts” git commit -m “refactor(whatsapp): consolidate authorization in target-facts” git commit -m “refactor(whatsapp): add LID-aware wire JID resolution” git commit -m “test(whatsapp): add behavior preservation tests for target-facts” git commit -m “refactor(whatsapp): remove scattered target helpers”
π§ͺ Verification & Acceptance Criteria
Test Scenarios for Behavior Preservation
Scenario 1: Direct Number Normalization
bash
Input: E.164 with prefix
/msg whatsapp:+15551234567 “Hello”
Expected: Same behavior as current shipped code
Verify: Target resolves to [email protected]
Exit code: 0
Scenario 2: Group JID Recognition
bash
Input: Group JID
/msg whatsapp:[email protected] “Hello”
Expected: Identified as group, bypasses allowFrom
Verify: facts.chatType === ‘group’
Verify: facts.outboundAuthorized === true regardless of allowFrom
Scenario 3: Newsletter JID Recognition
bash
Input: Newsletter JID
/msg whatsapp:newsletter@newsletter WA
Expected:
- Identified as channel/newsletter
- bypasses allowFrom
- useComposingPresence === false
Verify: facts.isNewsletter === true
Verify: facts.useComposingPresence === false
Scenario 4: Prefix Stripping
bash
Input: Prefixed target
/msg whatsapp:whatsapp:15551234567 “Hello”
Expected: Prefix stripped canonically
Verify: facts.normalizedTarget === ‘15551234567’
Verify: behavior matches unprefixed input
Scenario 5: AllowFrom Direct Restriction
bash
Config: allowFrom: [“15551234567”]
Input: /msg whatsapp:15551234567 “Hello”
Expected: Authorized (exact match)
Verify: facts.outboundAuthorized === true
Input: /msg whatsapp:1555987654 “Hello”
Expected: Blocked
Verify: facts.outboundAuthorized === false
Verify: actionable error message displayed
Scenario 6: Empty AllowFrom (Current Behavior)
bash
Config: allowFrom: []
Input: /msg whatsapp:15551234567 “Hello”
Expected: All direct targets allowed (no policy change)
Verify: facts.outboundAuthorized === true
Verify: matches shipped behavior pre-refactor
Scenario 7: LID-Aware Send
bash
Config: authMapping = { “15551234567” β “[email protected]” }
Input: /msg whatsapp:15551234567 “Hello”
Expected: Wire JID uses LID mapping
Verify: facts.wireJid === “[email protected]”
Verify: message lands in correct conversation
Scenario 8: Cross-Path Consistency
javascript // Message tool, channel send, action, and routing all use same facts const msgFacts = resolveTargetFacts({ rawTarget: target }); const channelFacts = resolveTargetFacts({ rawTarget: target }); const actionFacts = resolveTargetFacts({ rawTarget: target }); const routingFacts = resolveTargetFacts({ rawTarget: target });
// All must produce identical canonical facts assert.deepEqual(msgFacts, channelFacts); assert.deepEqual(msgFacts, actionFacts); assert.deepEqual(msgFacts, routingFacts);
Verification Commands
bash
Run target-facts specific tests
npm test – –grep “target-facts”
Run behavior preservation tests
npm test – –grep “whatsapp.*behavior”
Verify no regressions in integration
npm run test:integration – –plugin whatsapp
Manual verification script
node scripts/verify-target-consistency.js
Success Criteria Checklist
resolveTargetFacts()accepts raw target and returns canonical facts- All existing send paths produce identical output to pre-refactor behavior
- Group and newsletter targets bypass direct allowFrom checks
- Newsletter targets skip composing presence
- LID mappings correctly influence wire JID selection
- Invalid target errors remain user-facing with actionable messages
- Target tests collapse around the new interface as test surface
- No new core dependencies introduced
- WhatsApp-specific logic remains plugin-local
β οΈ Considerations & Edge Cases
Critical Edge Cases
1. Prefix Stacking
whatsapp:whatsapp:15551234567
Should normalize to 15551234567, not whatsapp:15551234567 or wa.me:15551234567.
2. Mixed Format Input
[email protected] // Is this a group or malformed direct?
The module should classify based on JID suffix, not assume phone format.
3. LID JID Handling When No Mapping
Input: [email protected] Config: authMapping = {}
Should preserve the LID JID as-is, not attempt phone normalization.
4. Legacy JID Formats
[email protected] // Old format 15551234567:[email protected] // With device ID
Both should resolve to same canonical target.
5. Empty and Whitespace Targets
Input: " " or ""
Should produce actionable error, not silent normalization.
Known Risks
| Risk | Mitigation |
|---|---|
| Callers caching raw target | Ensure facts object is returned, not mutated |
| Partial migration leaving callers | Use TypeScript to enforce interface compliance |
| Performance regression | Cache frequently-resolved targets (optional optimization) |
| Test gaps | Add property-based tests for format coverage |
Do Not Introduce
- Seam for single adapter: The target facts module should be deep, not a speculative interface
- Core dependencies: WhatsApp behavior stays in the WhatsApp plugin
- Breaking changes: All existing caller APIs must remain functional
π Related Context & Dependencies
Related Modules
| Module | Current Role | Post-Refactor Role |
|---|---|---|
message-command.ts | Target parsing for sends | Caller: use target-facts |
session-routing.ts | Peer selection for routing | Caller: use target-facts.routePeer |
outbound-auth.ts | AllowFrom checking | Caller: use target-facts.outboundAuthorized |
channel-send.ts | Channel message sends | Caller: use target-facts.wireJid |
actions.ts | Message reactions | Caller: use target-facts for authorization |
delivery.ts | Wire JID selection | Caller: use target-facts.wireJid |
presence.ts | Composing presence updates | Caller: use target-facts.useComposingPresence |
Dependencies
- No new core dependencies: WhatsApp-specific logic stays in plugin
- TypeScript: For interface enforcement across callers
- Existing test infrastructure: For behavior preservation tests
Commit History Context
The refactor preserves the conceptual history:
- Original target parsing (maintained)
- Auth normalization additions (absorbed into canonical module)
- LID mapping work (integrated into wire JID resolution)
- Newsletter handling (centralized in chatType classification)
Reviewer Checklist
- Implementation done on dedicated worktree and branch
- Branch name follows convention:
refactor/whatsapp-target-facts - Commit messages follow conventional commit style
- Behavior preserved through focused tests
- No regressions in shipped behavior
- Interface is the test surface
- Plugin boundaries respected (no accidental SDK contracts)