@msgly/core
v1.5.0
Published
Core engine for the messaging hub — unified message model, adapter contract, event bus
Maintainers
Readme
@msgly/core
Core engine for Msgly — unified message model, the
createHubfactory, retry, idempotency, capability checks, and theAdaptercontract every channel package implements. Zero classes, runs in Node 20+, Next.js (Node + Edge), and the browser.
📖 Docs & channel reference: https://ayushjain070401.github.io/msgly/
@msgly/core is the runtime every channel adapter plugs into. You won't usually depend on it directly for application code — install it alongside one or more adapters:
Chat / messaging: @msgly/telegram, @msgly/whatsapp, @msgly/line, @msgly/messenger, @msgly/instagram, @msgly/discord, @msgly/msteams, @msgly/slack, @msgly/wechat
Email: @msgly/gmail, @msgly/outlook
Install
npm install @msgly/coreQuick start
import express from 'express';
import { createHub } from '@msgly/core';
import { createTelegramAdapter } from '@msgly/telegram';
import { createWhatsAppAdapter } from '@msgly/whatsapp';
const hub = createHub();
hub.register(
createTelegramAdapter({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
}),
);
hub.register(
createWhatsAppAdapter({
phoneNumberId: process.env.WA_PHONE_ID!,
accessToken: process.env.WA_TOKEN!,
appSecret: process.env.META_APP_SECRET!,
verifyToken: process.env.META_VERIFY_TOKEN!,
}),
);
// Verify credentials at startup — fail fast on bad tokens
await hub.connect({ throwOnFailure: true });
hub.on('message', async (msg) => {
if (msg.content.type === 'text') {
await hub.send({
channel: msg.channel,
account: msg.account,
contact: msg.contact,
content: { type: 'text', text: `You said: ${msg.content.text}` },
});
}
});
const app = express();
app.use(express.json({ verify: (req, _r, buf) => ((req as any).rawBody = new Uint8Array(buf)) }));
const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);
app.listen(3000);Concepts
The unified message
Every inbound and outbound message conforms to the same shape, regardless of channel:
interface UnifiedMessage {
id: string; // library-generated UUID, stable across retries
externalId?: string; // the platform's own message id
channel: ChannelName; // 'telegram' | 'whatsapp' | 'line' | 'messenger' | 'instagram'
// | 'discord' | 'msteams' | 'slack' | 'wechat' | 'gmail' | 'outlook'
account: AccountRef; // your business identity on that channel
contact: ContactRef; // the end user
content: MessageContent; // discriminated union, see below
timestamp: string; // ISO 8601
direction: 'inbound' | 'outbound';
metadata?: Record<string, unknown>;
// Inbound-only fields:
raw?: unknown; // original platform payload
interaction?: { // present when the user tapped a button / postback
id: string; // platform callback ID — ack within 10 s (Telegram)
data?: string; // button payload (button.id, postback.payload, etc.)
};
edited?: boolean; // true if this is an edit of a previous message
}Content types
MessageContent is a discriminated union:
type MessageContent =
| { type: 'text'; text: string;
/** 'markdown' or 'html' enables native rich-text rendering per channel. */
format?: 'plain' | 'markdown' | 'html' }
| { type: 'image' | 'video' | 'audio' | 'file';
mediaRef: MediaReference; caption?: string }
| { type: 'location'; latitude: number; longitude: number; name?: string; address?: string }
| { type: 'interactive'; text: string;
/** 1D = single row (back-compat). 2D = multi-row grid (e.g. Telegram inline keyboard). */
buttons: { id: string; label: string }[] | { id: string; label: string }[][];
/** 'inline' (default) = callback_data buttons. 'reply' = sends actual text (Telegram ReplyKeyboardMarkup). */
keyboardType?: 'inline' | 'reply' }
| { type: 'template'; templateName: string; language: string;
/** Positional body variables — `'1'` maps to `{{1}}`. */
variables?: Record<string, string>;
/** Rich component pass-through (headers, URL buttons, media). Wins over variables. */
components?: unknown[] };Message formatting
Each adapter exports a fmt helper object with channel-native formatting:
import { fmt } from '@msgly/telegram'; // MarkdownV2 escaping
import { fmt } from '@msgly/discord'; // Discord markdown
import { fmt } from '@msgly/gmail'; // HTML tags
import { fmt } from '@msgly/outlook'; // HTML tags
import { fmt } from '@msgly/msteams'; // Markdown
import { fmt } from '@msgly/whatsapp'; // WhatsApp markdown
// Messenger, Instagram, LINE — fmt returns text as-is (plain only)Pass format: 'markdown' or format: 'html' on TextContent to enable native rendering:
import { fmt } from '@msgly/telegram';
await hub.send({
channel: 'telegram',
account, contact,
content: {
type: 'text',
format: 'markdown', // tells the adapter: parse_mode = MarkdownV2
text: `${fmt.bold('Order confirmed')} — your tracking number is ${fmt.code('TRK-1234')}`,
},
});| Adapter | format: 'markdown' | format: 'html' | fmt helpers |
| ----------- | --------------------------------------- | ------------------------- | ------------------------------------------- |
| Telegram | parse_mode: MarkdownV2 | parse_mode: HTML | bold, italic, underline, strike, code, pre, link, spoiler |
| WhatsApp | always-on (auto-parsed) | — | bold, italic, strikethrough, monospace |
| Discord | always-on (auto-parsed) | — | bold, italic, underline, strike, code, codeBlock, spoiler, link |
| Slack | mrkdwn section block | — | bold, italic, strikethrough, code, codeBlock, link |
| Teams | textFormat: markdown | textFormat: markdown | bold, italic, strikethrough, code, codeBlock, link |
| Gmail | — | Content-Type: text/html | bold, italic, underline, strike, code, pre, link, color, br |
| Outlook | — | contentType: HTML | bold, italic, underline, strike, code, pre, link, color, br |
| Messenger | plain (not supported) | — | identity functions |
| Instagram | plain (not supported) | — | identity functions |
| LINE | plain (not supported) | — | identity functions |
| WeChat | plain (not supported) | — | identity functions |
AccountRef and ContactRef
interface AccountRef {
channel: ChannelName;
channelAccountId: string; // bot id / phone_number_id / page id
}
interface ContactRef {
channel: ChannelName;
channelUserId: string; // chat_id / phone number / page-scoped user id
displayName?: string;
globalContactId?: string; // your cross-channel identity if you have one
}createHub(options?)
function createHub(options?: HubOptions): Hub;
interface HubOptions {
store?: MessageStore; // default: in-memory
logger?: Logger; // default: console-based (warn + error only)
retry?: Partial<RetryOptions>; // default: 3 attempts, 500ms base, 8000ms cap
}hub.register(adapter)
Registers a channel adapter. Throws MsglyError with code: 'AdapterAlreadyRegistered' on duplicate registration. Returns hub for chaining.
hub.send(message)
Send a partial OutboundMessage — id, direction, and timestamp are filled in for you.
await hub.send({
channel: 'whatsapp',
account: { channel: 'whatsapp', channelAccountId: '...' },
contact: { channel: 'whatsapp', channelUserId: '919999999999' },
content: { type: 'text', text: 'hi' },
});Sends are wrapped in retry (see Retry) and validated against the target adapter's capabilities (see Capability checks).
hub.on(event, handler) — returns unsubscribe
const off = hub.on('message', (msg) => { /* handle inbound */ });
hub.on('delivery', (receipt) => { /* status updates */ });
hub.on('error', (err, ctx) => { /* observe failures */ });
// Later:
off();Unlike traditional EventEmitter-based libraries, hub.on() returns an unsubscribe function — no need to track handler references for cleanup.
hub.connect({ throwOnFailure? })
Calls every registered adapter's verifyCredentials() in parallel. Returns Record<ChannelName, CredentialsCheckResult>. Pass throwOnFailure: true to throw an aggregated error if any adapter fails — useful in boot scripts.
const report = await hub.connect();
// { telegram: { ok: true, accountInfo: '@my_bot' },
// whatsapp: { ok: false, reason: 'unauthorized', hint: '...' } }The hint is an actionable string explaining exactly which env var to fix and where to find the value.
hub.createWebhookHandler()
Returns { get, post } for use with any Express-like framework:
GET /webhook/:channel— handles the Meta-family subscription handshake (hub.verify_tokencheck)POST /webhook/:channel— verifies the channel's signature (HMAC / Ed25519 / RS256 JWT / shared-secret depending on adapter), optionally short-circuits with a platform-specific ack body (Discord PONG, GraphvalidationTokenecho), dispatches to the right adapter, deduplicates viaexternalId, emitsmessageevents
const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);Raw body is required. Signature verification needs the byte-exact request body as a
Uint8Array. Configure your body parser to expose it onreq.rawBody. For Express, capture across all content-types so platform handshakes that arrive astext/plain(e.g. Microsoft Graph'svalidationToken) aren't dropped:const captureRaw = (req, _res, buf) => { req.rawBody = new Uint8Array(buf); }; app.use(express.json({ verify: captureRaw })); app.use(express.urlencoded({ extended: true, verify: captureRaw })); app.use(express.raw({ type: '*/*', verify: captureRaw })); // fallback for text/plain etc.
hub.handleWebhook(channel, req)
The lower-level entry point used by createWebhookHandler. Useful when wiring webhooks into a framework that doesn't fit the Express shape, or directly inside Next.js Route Handlers / Server Actions.
hub.channels / hub.getAdapter(channel)
channels returns the list of registered channel names. getAdapter returns the registered adapter, or throws a MsglyError with code: 'AdapterNotRegistered'.
hub.start() / hub.stop()
Calls the optional start()/stop() lifecycle hooks on every registered adapter.
Campaigns — hub.sendBulk(options)
Fan one message out to many contacts, paced to the channel's rate limit.
const result = await hub.sendBulk({
channel: 'whatsapp',
account: { channel: 'whatsapp', channelAccountId: process.env.WA_PHONE_ID! },
recipients: customers.map((c) => ({
contact: { channel: 'whatsapp', channelUserId: c.phone },
metadata: { crmId: c.id },
})),
// A function, so each recipient gets their own variables:
content: (r) => ({
type: 'template',
templateName: 'order_update',
language: 'en_US',
variables: { '1': nameFor(r.contact) },
}),
concurrency: 8,
onProgress: (p) => console.log(`${p.completed}/${p.total}`),
signal: AbortSignal.timeout(60_000),
});
console.log(result.sent, result.failed, result.skipped);It resolves rather than rejects when individual recipients fail — one bad
number must not abort a 10,000-person campaign. result.results comes back in
input order so you can zip it against your own list, and result.failures holds
just the failed entries. Aborting via signal gives you partial results instead
of throwing them away.
Conservative per-channel rates ship built in (CHANNEL_RATE_LIMITS) and can be
overridden per call, per adapter, or process-wide via createHub({ rateLimits }).
Some channels don't need fan-out
LINE, WeChat, Viber and Telegram have a real broadcast primitive — one call
reaches the whole audience. sendBulk is the wrong tool there:
await line.broadcast({ type: 'text', text: 'Sale starts now' });
await wechat.massSend({ type: 'text', text: 'New arrivals' });
await viber.broadcast(ids, { type: 'text', text: 'Sale' });Opt-outs — SuppressionStore
Honouring opt-outs is a legal requirement: TCPA and TRAI/DLT for SMS, CAN-SPAM
and GDPR for email. sendBulk consults the store before every send.
import {
createHub,
createInMemorySuppressionStore, // or createKvSuppressionStore(redis)
applyConsentIntent,
applyDeliveryReceipt,
} from '@msgly/core';
const suppression = createInMemorySuppressionStore();
const hub = createHub({ suppressionStore: suppression });
// Capture STOP / UNSUBSCRIBE replies automatically
hub.on('message', (msg) => applyConsentIntent(msg, suppression));
// Retire hard-bounced and complained addresses
hub.on('delivery', (r) => applyDeliveryReceipt(r, 'resend', suppression));Three behaviours worth knowing:
- Suppressed recipients are reported as
skipped, neverfailed— nothing went wrong, and retrying them would be the violation. They also consume no rate-limit budget. - If the store is unreachable the send is skipped, not sent. Not sending is the recoverable mistake.
- Only permanent failures suppress. A deferral or full mailbox is left alone, and an unclassifiable failure suppresses nothing.
detectConsentIntent(text) matches whole messages only, in several languages —
so "please stop sending the weekly digest" is not treated as a global
opt-out.
List-Unsubscribe
Gmail and Yahoo have required one-click unsubscribe headers from bulk senders
since February 2024. Every email adapter emits them from an unsubscribe
config:
createResendAdapter({ ...cfg, unsubscribe: { url: 'https://acme.com/u?e={{contact}}' } });{{contact}} is replaced with the recipient's address, and
metadata.unsubscribeUrl overrides it per message for per-recipient tokens.
Retry
Sends are wrapped in exponential backoff with equal jitter:
maxAttempts: 3(configurable)initialDelayMs: 500maxDelayMs: 8000- Backoff:
min(initial * 2^(attempt-1), maxDelay)thendelay/2 + random(delay/2)
Auth errors (401/403/404) and "unauthorized" codes are never retried — the token is bad, retrying just wastes API calls. Network errors and 5xx are retried.
const hub = createHub({
retry: {
maxAttempts: 5,
initialDelayMs: 200,
maxDelayMs: 4000,
shouldRetry: (err, attempt) => attempt < 3,
},
});Capability checks
Every adapter advertises an AdapterCapabilities object:
interface AdapterCapabilities {
text: boolean;
media: { image: boolean; video: boolean; audio: boolean; file: boolean };
interactive: { buttons: boolean; quickReplies: boolean };
templates: boolean;
reactions: boolean;
typing: boolean;
}The hub checks content.type against these before dispatching. Unsupported sends throw a MsglyError with code: 'UnsupportedFeature':
import { isMsglyError } from '@msgly/core';
try {
await hub.send({ channel: 'instagram', /* ... */ content: { type: 'audio', mediaRef } });
} catch (err) {
if (isMsglyError(err, 'UnsupportedFeature')) {
// Instagram does not support audio
}
}Cross-channel matrix:
| Feature | Telegram | WhatsApp | LINE | Messenger | Instagram | Discord | Teams | Slack | WeChat | Gmail | Outlook | | -------------- | -------- | -------- | ---- | --------- | --------- | ------- | ----- | ----- | ------ | ----- | ------- | | text | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | image | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓(URL)| ✓ | — | — | | video | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | — | — | | audio | ✓ | ✓ | ✓ | ✓ | — | ✓ | — | — | ✓ | — | — | | file | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | — | — | — | — | | buttons | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | — | — | — | | quick replies | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | ✓ | — | — | | templates | — | ✓ | — | — | — | — | — | — | — | — | — | | reactions | ✓ | ✓ | — | — | ✓ | — | — | — | — | — | — | | typing | ✓ | ✓† | ✓ | ✓ | ✓ | — | ✓ | — | — | — | — |
† WhatsApp requires the inbound message's externalId — use adapter.sendTypingIndicator(contact, externalMessageId) rather than the generic sendTyping(contact).
Email adapters (Gmail, Outlook) are text-only in v1 — inbound attachments come through as best-effort plain-text body extraction, and outbound media is not yet supported.
Idempotency and storage
The hub uses a MessageStore for two things:
- Saving inbound and outbound messages.
- Deduplicating webhook deliveries by
externalId(platforms retry on 5xx, so the same message can arrive twice).
The default in-memory store is fine for development and tests but loses state on restart. Provide your own implementation for production:
interface MessageStore {
saveMessage(message: UnifiedMessage): Promise<void>;
getMessage(id: string): Promise<UnifiedMessage | null>;
hasExternalId(channel: string, externalId: string): Promise<boolean>;
}
const hub = createHub({ store: makePostgresStore(db) });Errors
All errors thrown by msgly are plain Error instances tagged with name: 'MsglyError' plus a machine-readable code. Detect them with isMsglyError:
import { isMsglyError, type MsglyErrorCode } from '@msgly/core';
try {
await hub.send(/* ... */);
} catch (err) {
if (isMsglyError(err, 'SendFailed')) {
console.log('channel:', err.channel);
console.log('receipt:', err.receipt);
}
}Possible codes:
| Code | Thrown when |
| ------------------------- | ---------------------------------------------------- |
| AdapterNotRegistered | hub.send() to a channel with no adapter |
| AdapterAlreadyRegistered| hub.register() called twice for the same channel |
| UnsupportedFeature | content.type is not in adapter capabilities |
| InvalidSignature | Webhook HMAC mismatch |
| SendFailed | adapter.send() failed after retries |
Constructors are also exported for adapter authors: adapterNotRegistered, unsupportedFeature, invalidSignature, sendFailed.
Writing a custom adapter
Implement the Adapter interface:
import type {
Adapter,
AdapterCapabilities,
CredentialsCheckResult,
WebhookRequest,
OutboundMessage,
InboundMessage,
DeliveryReceipt,
MediaFile,
MediaReference,
} from '@msgly/core';
interface MyConfig { apiToken: string; }
export function createMyAdapter(config: MyConfig): Adapter {
return {
channel: 'mychannel' as const,
capabilities: { /* ... */ },
async send(message: OutboundMessage): Promise<DeliveryReceipt> { /* ... */ },
async handleWebhook(req: WebhookRequest): Promise<InboundMessage[]> { /* ... */ },
async verifySignature(req: WebhookRequest): Promise<boolean> { /* HMAC check */ },
async uploadMedia(file: MediaFile): Promise<MediaReference> { /* ... */ },
async downloadMedia(ref: MediaReference): Promise<MediaFile> { /* ... */ },
async verifyCredentials(): Promise<CredentialsCheckResult> { /* ... */ },
// Optional — for Meta-style GET handshake (Messenger / Instagram / WhatsApp):
verifyWebhookChallenge(query) { /* ... */ return null; },
// Optional — for platforms whose POST webhook must reply with a
// specific body (Discord PING/PONG, Graph validationToken echo):
getInteractionAck(req) {
// return null to fall through
// return a string → sent as application/json
// return { body, contentType } for non-JSON responses (text/plain etc.)
return null;
},
// Optional — send a typing indicator (implement when the platform supports it):
async sendTyping(contact: ContactRef) { /* e.g. sendChatAction(contact.channelUserId, 'typing') */ },
// Optional lifecycle hooks:
async start() { /* ... */ },
async stop() { /* ... */ },
};
}Adding a new ChannelName requires extending the union in core/src/types.ts — 'mychannel' won't compile until you do.
Runtime compatibility
@msgly/core and every adapter use only Web Standard APIs:
fetch(noundici, nonode-fetch)- Web Crypto (
globalThis.crypto.subtle) for HMAC signatures TextEncoder/Uint8Arrayinstead ofBufferglobalThis.crypto.randomUUID()for ids (with a Math.random fallback)
This means msgly runs everywhere modern JS does:
| Runtime | Supported | | ----------------------------- | --------- | | Node 20+ | ✓ | | Next.js Node runtime | ✓ | | Next.js Edge runtime | ✓ | | Bun / Deno | ✓ | | Modern browsers (server-only adapters; not for client sends) | ✓ |
Server-side webhook handling needs the raw request bytes — most frameworks expose them; for Next.js Route Handlers, use await req.arrayBuffer() and pass new Uint8Array(...).
Adapters
| Channel | Package | Inbound auth | Setup notes |
| ---------------- | ------------------ | --------------------------------------------- | -------------------------------------------- |
| Telegram | @msgly/telegram | X-Telegram-Bot-Api-Secret-Token header | Easiest — @BotFather, no business approval |
| LINE | @msgly/line | HMAC-SHA256, constant-time | LINE Developers console |
| Messenger | @msgly/messenger | X-Hub-Signature-256 HMAC | Needs Meta App + Facebook Page |
| Instagram | @msgly/instagram | X-Hub-Signature-256 HMAC | IG Business linked to Page |
| WhatsApp | @msgly/whatsapp | X-Hub-Signature-256 HMAC | Meta WhatsApp Cloud API |
| Discord | @msgly/discord | Ed25519 over timestamp + rawBody | HTTP Interactions (slash commands + buttons) |
| Microsoft Teams | @msgly/msteams | RS256 JWT against Bot Framework JWKS | Azure Bot resource + Teams channel |
| Slack | @msgly/slack | HMAC-SHA256 X-Slack-Signature | Slack App — Events API + Block Kit |
| WeChat | @msgly/wechat | SHA-1 signature query param | WeChat Official Account (Service Account) |
| Gmail | @msgly/gmail | RS256 OIDC JWT (or shared token) | Pub/Sub push subscription, OAuth refresh token |
| Outlook / M365 | @msgly/outlook | clientState shared secret, constant-time | Graph change-notification subscription |
Email adapters (
gmail,outlook) are text-only in v1. Each is single-mailbox (one OAuth refresh token in config = one inbox). See the per-package READMEs for setup walkthroughs.
Documentation
Full quickstart, connection guides, and architecture overview: https://github.com/AyushJain070401/msgly
License
MIT
