Channels/Messaging — Message Normalization
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:
| Platform | Primary ID | Username | Display Name |
|---|---|---|---|
| Telegram | Numeric user ID | @username | First + last name |
| Phone number (JID) | Phone | Push name | |
| Discord | Snowflake ID | username#discriminator | Display name |
| Slack | User ID (U...) | handle | Real name |
| Signal | Phone/UUID | N/A | Profile name |
| IRC | Nick!user@host | nick | Realname |
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:
| Condition | Debounce Behavior |
|---|---|
Media present (hasMedia = true) | Skip debounce, process immediately |
| Control command detected | Skip debounce, process immediately |
allowDebounce = false | Skip debounce |
| Text-only message | Batch within debounce window |
Markdown Capability
Outbound messages are formatted based on the channel’s markdownCapable flag:
| Channel | Markdown Support |
|---|---|
| Telegram | MarkdownV2 (subset) |
| Discord | Full markdown |
| Slack | mrkdwn (Slack-specific) |
| Basic (bold, italic, code) | |
| IRC | None (plain text) |
| Signal | None (plain text) |