@msgly/core
v0.2.3
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 18+, Next.js (Node + Edge), and the browser.
@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
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' | '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>;
raw?: unknown; // present on inbound — original platform payload
}Content types
MessageContent is a discriminated union:
type MessageContent =
| { type: 'text'; text: string }
| { type: 'image' | 'video' | 'audio' | 'file';
mediaRef: MediaReference; caption?: string }
| { type: 'location'; latitude: number; longitude: number; name?: string; address?: string }
| { type: 'interactive'; text: string; buttons: { id: string; label: string }[] }
| { type: 'template'; templateName: string; language: string;
variables?: Record<string, string> };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.
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 | Gmail | Outlook | | -------------- | -------- | -------- | ---- | --------- | --------- | ------- | ----- | ----- | ------- | | text | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | image | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | | video | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | | audio | ✓ | ✓ | ✓ | ✓ | — | ✓ | — | — | — | | file | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | — | — | | buttons | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — | | quick replies | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | — | | templates | — | ✓ | — | — | — | — | — | — | — | | reactions | ✓ | ✓ | — | — | ✓ | — | — | — | — | | typing | ✓ | — | — | ✓ | — | — | ✓ | — | — |
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 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 18+ | ✓ | | 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 |
| 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
