Channels/Messaging — Message Normalization

Focus Deep-Dive

Platform-Specific Format Normalization & Context Extraction

Message normalization transforms the diverse message formats from 25+ platforms into a single internal representation. It extracts sender identity, chat metadata, thread context, media references, and command detection — producing a uniform MsgContext that the rest of the system consumes.

25+ Platforms Unified Context Thread Aware Command Detection

📚 What is this & why does it matter?

Every messaging platform structures messages differently. Telegram wraps messages in Update objects, Discord uses Message events, WhatsApp has its own webhook format. Without normalization, every downstream module would need to understand every platform’s format. The normalizer acts as the universal translator.

💡 Real-world analogy

Think of an international news agency. Correspondents in 25 countries send stories in different formats, languages, and styles. The central editorial desk (normalizer) rewrites every story into a standard format with consistent metadata (byline, dateline, categories) before publishing to subscribers. The subscribers never see the raw format — they always get a clean, uniform article.

Contents

Normalization Pipeline

flowchart TD
    RAW["Raw platform message"]
    RAW --> ADAPTER["Channel-specific adapter"]
    ADAPTER --> IDENTITY["Extract sender identity"]
    ADAPTER --> CHAT["Determine chat type"]
    ADAPTER --> THREAD["Extract thread context"]
    ADAPTER --> MEDIA["Detect media attachments"]
    ADAPTER --> CMD["Detect control commands"]
    ADAPTER --> TEXT["Extract text content"]

    IDENTITY --> CTX["MsgContext"]
    CHAT --> CTX
    THREAD --> CTX
    MEDIA --> CTX
    CMD --> CTX
    TEXT --> CTX

    CTX --> DEBOUNCE["Debounce Policy"]
    CTX --> ALLOWLIST["Allowlist Check"]
    CTX --> SESSION["Session Recording"]

MsgContext Structure

The normalized message context contains everything downstream modules need:

// Conceptual MsgContext (defined in src/auto-reply/templating.ts)
type MsgContext = {
  // Sender identity
  senderId: string;
  senderName?: string;
  senderUsername?: string;

  // Chat context
  chatType: ChatType;           // "direct" | "group" | "channel"
  chatId: string;
  chatTitle?: string;

  // Thread context
  threadId?: string | number;
  isThreadReply: boolean;

  // Message content
  text: string;
  hasMedia: boolean;
  mediaType?: string;

  // Command detection
  hasControlCommand: boolean;
  controlCommand?: string;

  // Platform metadata
  channelId: string;            // "telegram" | "whatsapp" | etc.
  accountId?: string;
  rawEvent: unknown;            // Original platform event
};

Sender Identity Extraction

Each platform provides sender information differently. The normalizer extracts multiple identity candidates for allowlist matching:

PlatformPrimary IDUsernameDisplay Name
TelegramNumeric user ID@usernameFirst + last name
WhatsAppPhone number (JID)PhonePush name
DiscordSnowflake IDusername#discriminatorDisplay name
SlackUser ID (U...)handleReal name
SignalPhone/UUIDN/AProfile name
IRCNick!user@hostnickRealname

Session Recording

After normalization, the session module records the inbound message metadata for /continue support:

export async function recordInboundSession(params: {
  storePath: string;
  sessionKey: string;
  ctx: MsgContext;
  groupResolution?: GroupKeyResolution | null;
  createIfMissing?: boolean;
  updateLastRoute?: InboundLastRouteUpdate;
  onRecordError: (err: unknown) => void;
}): Promise<void>

Last Route Tracking

export type InboundLastRouteUpdate = {
  sessionKey: string;
  channel: SessionEntry["lastChannel"];
  to: string;
  accountId?: string;
  threadId?: string | number;
  mainDmOwnerPin?: {
    ownerRecipient: string;
    senderRecipient: string;
    onSkip?: (params: { ownerRecipient: string; senderRecipient: string }) => void;
  };
};
flowchart LR
    MSG["Inbound message"] --> NORMALIZE["Normalize to MsgContext"]
    NORMALIZE --> SESSION["recordInboundSession()"]
    SESSION --> STORE["Session store
(file-based)"]
    STORE --> CONTINUE["/continue command
reads last route"]

Debounce Policy Integration

The normalized MsgContext feeds into the debounce policy, which decides whether to process immediately or batch:

ConditionDebounce Behavior
Media present (hasMedia = true)Skip debounce, process immediately
Control command detectedSkip debounce, process immediately
allowDebounce = falseSkip debounce
Text-only messageBatch within debounce window

Markdown Capability

Outbound messages are formatted based on the channel’s markdownCapable flag:

ChannelMarkdown Support
TelegramMarkdownV2 (subset)
DiscordFull markdown
Slackmrkdwn (Slack-specific)
WhatsAppBasic (bold, italic, code)
IRCNone (plain text)
SignalNone (plain text)