mirror of
https://github.com/vercel/chat.git
synced 2026-09-14 18:32:29 +08:00
60f5d8e19f
* feat: add WhatsApp Business Cloud API adapter
Add @chat-adapter/whatsapp with support for sending/receiving messages,
reactions, interactive reply buttons, typing indicators, and webhook
verification via the Meta Graph API. Includes full test suite,
documentation updates, and workspace/turbo configuration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add media download, attachments, and location support to WhatsApp adapter
- Add downloadMedia() public method for fetching images, documents,
audio, video, and stickers via the Graph API (two-step: URL then binary)
- Populate message attachments with lazy fetchData() for all media types
- Add location support with Google Maps URL and structured text
- Add audio, video, sticker, and location fields to WhatsAppInboundMessage
- Set isMention: true on all messages (WhatsApp DMs are always direct)
- Update parseMessage to include attachments and isMention
- Add 10 new tests covering all media types, locations, and isMention
- Update docs feature matrix to reflect media receive support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review feedback for WhatsApp adapter
- Validate Graph API response before accessing messages[0].id in
sendTextMessage and sendInteractiveMessage
- Escape backticks and backslashes in escapeWhatsApp()
- Apply escapeWhatsApp() to renderText() content in all style branches
- Use webhook phoneNumberId in buildMessage() instead of this.phoneNumberId
- Encode proper threadId in parseMessage() instead of empty string
- Strict decodeThreadId() validation (exactly 2 segments after prefix)
- Add tests for extra segments in decodeThreadId and threadId in parseMessage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Migrate improvements from #179
Bring over several enhancements from chitru's WhatsApp adapter PR (#179):
- Voice message support (separate from audio)
- Legacy button response handling (template quick replies)
- Callback data encoding/decoding for interactive reply round-trips
- Message truncation at WhatsApp's 4096 char limit
- Example app integration (adapters, webhook route, package.json)
- GET webhook forwarding for WhatsApp verification challenges
- Package README and changeset
- Tests for all new functionality (68 total)
Co-Authored-By: Chitru Shrestha <chitra.shrestha@akuru.com.au>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): add error handling for inbound message processing
Wrap handleInboundMessage calls in try/catch to log errors if
synchronous processing fails (e.g., thread ID encoding). The async
processing already has its own error handling in Chat.processMessage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): prevent markdown regex from matching across newlines
Use [^\n*] and [^\n~] in fromWhatsAppFormat regex to prevent bold/strike
spans from merging across line boundaries. Adds a regression test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): use WhatsAppInteractiveMessage type instead of object
Replace the untyped `object` parameter in sendInteractiveMessage with
the proper WhatsAppInteractiveMessage type for full type safety.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): hoist emoji mapping to module-level constant
Move the emoji name-to-unicode mapping out of resolveEmoji() so it is
not re-allocated on every call.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): remove duplicate JSDoc comment in types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(example): add startTyping to WhatsApp recording methods
The adapter supports typing indicators but the method was missing from
the recording proxy list.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): fix formatting and add package to readme test allowlist
Fix line-length formatting in markdown.ts regex and add
@chat-adapter/whatsapp to the valid packages list in readme tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): use defaultEmojiResolver instead of custom emoji map
Replace the hand-rolled EMOJI_MAP with the shared defaultEmojiResolver
from the chat SDK. WhatsApp uses unicode emoji like GChat, so toGChat()
provides the correct mapping with broader coverage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): make Graph API version configurable
Add apiVersion option to WhatsAppAdapterConfig (defaults to v21.0)
so users can upgrade without waiting for a package release.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): validate lat/lng before constructing Google Maps URL
Coerce and validate latitude/longitude with Number.isFinite() to
prevent unexpected URL construction from malformed webhook payloads.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): throw on editMessage instead of silently sending new message
Callers expecting an edit would get duplicate messages with the silent
fallback. Throwing makes the unsupported operation explicit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): document regex asymmetry between toWhatsApp and fromWhatsApp
Explain why toWhatsAppFormat doesn't need newline guards like
fromWhatsAppFormat does — the standard markdown parser output
never produces spans crossing line boundaries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): document callback data passthrough behavior
Add comments explaining that non-prefixed and malformed callback data
is intentionally passed through for legacy/external button IDs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): add editMessage and deleteMessage to recording methods
Include all adapter methods in the recording list for complete
debugging traces, even for unsupported operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): preserve escaped formatting chars in toWhatsAppFormat
Escaped asterisks and tildes in standard markdown (e.g. \* and \~) are
now preserved through the conversion pipeline so WhatsApp renders them
as literal characters instead of misinterpreting them as formatting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): split long messages instead of truncating
Replace silent truncation at 4096 chars with message splitting that
breaks on paragraph (\n\n) then line (\n) boundaries, sending multiple
messages so no content is lost. Adds 8 tests for the splitting logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(whatsapp): align editMessage/deleteMessage behavior and docs
- Fix README: editMessage/deleteMessage both throw, not fallback/no-op
- Fix editMessage JSDoc to reflect it throws
- Make deleteMessage throw instead of silently warning (consistent with editMessage)
- Bump @types/node to ^25.3.2 to match monorepo
- Add sample-messages.md with webhook payload examples
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(whatsapp): add adapter documentation page
Add whatsapp.mdx covering installation, usage, Meta app setup,
webhook config, interactive messages, media attachments, 24-hour
messaging window, configuration, features, and troubleshooting.
Also add WhatsApp to the adapters navigation in meta.json.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add whatsapp adapter debug logging and try/catch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(whatsapp): convert emoji placeholders in outgoing messages
WhatsApp adapter was sending raw {{emoji:wave}} placeholders instead of
Unicode emoji. Apply convertEmojiPlaceholders on all outgoing paths:
text messages, card fallback text, and interactive message fields.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix button rendering and streaming (needs to buffer)
* fix(example): handle editMessage failure on WhatsApp
WhatsApp Cloud API doesn't support message editing. Catch the error
in the demo "processing" animation and send a follow-up instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(chat): add onDirectMessage handler, stop treating DMs as mentions
DMs now route to dedicated onDirectMessage handlers instead of being
forced through onNewMention. If no DM handlers registered, DMs fall
through to onNewMention for backward compat. Adapters no longer set
isMention=true for DMs — the Chat SDK handles routing via adapter.isDM().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(chat): always route DMs to onDirectMessage regardless of subscription
Previously, onDirectMessage only fired for unsubscribed DM threads.
Subscribed DMs were routed to onSubscribedMessage, which was confusing
on non-threaded platforms (WhatsApp, Telegram) where all DMs share one
threadId — after the first message, onDirectMessage never fired again.
Now, DMs always route to onDirectMessage first, and onSubscribedMessage
only handles non-DM subscribed threads. Backward compat is preserved:
if no onDirectMessage handlers are registered, DMs fall through as
mentions.
The example bot is simplified accordingly — onDirectMessage now fetches
conversation history via fetchMessages each time instead of relying on
subscribe() and stored state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(chat): pass channel as third argument to DirectMessageHandler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(example): reply to channel instead of thread in DM handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(example): use thread instead of channel for DM operations
Channel ID is only two parts (whatsapp:{phoneNumberId}) which isn't a
valid conversation target on WhatsApp. The thread ID includes the user
phone and is required for startTyping/post/fetchMessages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "fix(example): use thread instead of channel for DM operations"
This reverts commit f4801d7015.
* fix(adapters): return valid thread IDs from channelIdFromThreadId
WhatsApp's channelIdFromThreadId was stripping the user WA ID, producing
an invalid ID that caused ValidationError on channel operations like
startTyping(). Since every WhatsApp conversation is a 1:1 DM, channel
and thread are identical.
Telegram's channelIdFromThreadId was returning a raw chatId without the
telegram: prefix, which is not a valid thread ID for adapter operations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(chat): normalize fullStream in Channel.post() to extract text deltas
Channel.post() was coercing AI SDK fullStream objects to strings via +=,
producing "[object Object]" output. Now uses fromFullStream() to extract
text-delta events, matching how Thread.post() already handles streams.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(example): use thread.allMessages for DM history instead of adapter directly
The DM handler was calling channel.adapter.fetchMessages() which always
returns empty on WhatsApp (no native history API). Now uses
thread.allMessages which falls back to the persisted message history
cache, giving the AI conversation context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(chat): add message history support to Channel for DM platforms
Channel now falls back to the persisted message history cache when the
adapter lacks native message fetch (e.g. WhatsApp, Telegram). Incoming
messages are persisted under both thread and channel IDs. Outgoing
messages from channel.post() are also persisted.
The example DM handler now uses channel.messages instead of calling the
adapter directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(whatsapp): improve markdown rendering and remove broken typing indicator
- Convert headings to bold text, thematic breaks to text separators,
and tables to code blocks (WhatsApp doesn't support these)
- Convert standard italic (*text*) to WhatsApp italic (_text_) since
WhatsApp uses *text* for bold
- Make startTyping a no-op (Cloud API doesn't support typing indicators)
- Update channelIdFromThreadId test for channel===thread change
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(example): reverse channel.messages to chronological order for AI
channel.messages yields newest first but AI expects chronological order.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(chat): auto-sort messages chronologically in toAiMessages
toAiMessages now sorts by dateSent (oldest first) so callers don't need
to worry about iteration order from channel.messages or thread.messages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(chat): pass accumulated stream text as markdown in Channel.post()
Stream text was posted as a plain string, bypassing the adapter's format
converter. Now wraps it as { markdown: accumulated } so headings, bold,
italic etc. are properly converted for each platform.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(whatsapp): use stringifier options for emphasis and bullets
Use emphasis: '_' and bullet: '-' options in stringifyMarkdown so the
only * in output is **strong**, avoiding conflicts between list bullets
and italic markers. Simplifies toWhatsAppFormat to only convert
**bold** -> *bold* and ~~strike~~ -> ~strike~.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(whatsapp): flatten bold inside headings to avoid triple asterisks
When AI outputs headings with bold text like `## **Choose React if:**`,
the heading-to-bold conversion created nested strong nodes producing
`***text***`. Now flattens strong children in headings so they merge
into a single bold span.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(whatsapp): add full toBe assertion for complex markdown conversion
Also use ━━━ for thematic breaks instead of --- to avoid remark escaping.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add WhatsApp replay tests from production recordings
Adds WhatsApp DM replay test infrastructure:
- Fixture from real webhook recordings (dm/whatsapp.json)
- WhatsApp test utilities with HMAC-signed request factory and
Graph API fetch mock (whatsapp-utils.ts)
- 6 replay tests covering DM handling, thread/channel IDs, message
sending, status update filtering, sequential messages, and
message history persistence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(whatsapp): fix type narrowing in replay test after merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update WhatsApp logo
* Update adapters.json
* Update logos.tsx
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com>
Co-authored-by: Chitru Shrestha <chitra.shrestha@akuru.com.au>
Co-authored-by: Malte Ubl <malte.ubl@gmail.com>
598 lines
18 KiB
TypeScript
598 lines
18 KiB
TypeScript
import type {
|
||
CustomEmojiMap,
|
||
EmojiFormats,
|
||
EmojiMapConfig,
|
||
EmojiValue,
|
||
WellKnownEmoji,
|
||
} from "./types";
|
||
|
||
// Re-export EmojiValue for convenience
|
||
export type { EmojiValue } from "./types";
|
||
|
||
// =============================================================================
|
||
// EmojiValue - Immutable singleton emoji objects with object identity
|
||
// =============================================================================
|
||
|
||
/** Internal emoji registry for singleton instances */
|
||
const emojiRegistry = new Map<string, EmojiValue>();
|
||
|
||
/**
|
||
* Get or create an immutable singleton EmojiValue.
|
||
*
|
||
* Always returns the same frozen object for the same name,
|
||
* enabling `===` comparison for emoji identity.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const e1 = getEmoji("thumbs_up");
|
||
* const e2 = getEmoji("thumbs_up");
|
||
* console.log(e1 === e2); // true - same object
|
||
* ```
|
||
*/
|
||
export function getEmoji(name: string): EmojiValue {
|
||
let emojiValue = emojiRegistry.get(name);
|
||
if (!emojiValue) {
|
||
emojiValue = Object.freeze({
|
||
name,
|
||
toString: () => `{{emoji:${name}}}`,
|
||
toJSON: () => `{{emoji:${name}}}`,
|
||
});
|
||
emojiRegistry.set(name, emojiValue);
|
||
}
|
||
return emojiValue;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Emoji Map - Platform-specific formats
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Default emoji map for well-known emoji.
|
||
* Maps normalized emoji names to platform-specific formats.
|
||
*/
|
||
export const DEFAULT_EMOJI_MAP: Record<string, EmojiFormats> = {
|
||
// Reactions & Gestures
|
||
thumbs_up: { slack: ["+1", "thumbsup"], gchat: "👍" },
|
||
thumbs_down: { slack: ["-1", "thumbsdown"], gchat: "👎" },
|
||
clap: { slack: "clap", gchat: "👏" },
|
||
wave: { slack: "wave", gchat: "👋" },
|
||
pray: { slack: "pray", gchat: "🙏" },
|
||
muscle: { slack: "muscle", gchat: "💪" },
|
||
ok_hand: { slack: "ok_hand", gchat: "👌" },
|
||
point_up: { slack: "point_up", gchat: "👆" },
|
||
point_down: { slack: "point_down", gchat: "👇" },
|
||
point_left: { slack: "point_left", gchat: "👈" },
|
||
point_right: { slack: "point_right", gchat: "👉" },
|
||
raised_hands: { slack: "raised_hands", gchat: "🙌" },
|
||
shrug: { slack: "shrug", gchat: "🤷" },
|
||
facepalm: { slack: "facepalm", gchat: "🤦" },
|
||
|
||
// Emotions & Faces
|
||
heart: { slack: "heart", gchat: ["❤️", "❤"] },
|
||
smile: { slack: ["smile", "slightly_smiling_face"], gchat: "😊" },
|
||
laugh: { slack: ["laughing", "satisfied", "joy"], gchat: ["😂", "😆"] },
|
||
thinking: { slack: "thinking_face", gchat: "🤔" },
|
||
sad: { slack: ["cry", "sad", "white_frowning_face"], gchat: "😢" },
|
||
cry: { slack: "sob", gchat: "😭" },
|
||
angry: { slack: "angry", gchat: "😠" },
|
||
love_eyes: { slack: "heart_eyes", gchat: "😍" },
|
||
cool: { slack: "sunglasses", gchat: "😎" },
|
||
wink: { slack: "wink", gchat: "😉" },
|
||
surprised: { slack: "open_mouth", gchat: "😮" },
|
||
worried: { slack: "worried", gchat: "😟" },
|
||
confused: { slack: "confused", gchat: "😕" },
|
||
neutral: { slack: "neutral_face", gchat: "😐" },
|
||
sleeping: { slack: "sleeping", gchat: "😴" },
|
||
sick: { slack: "nauseated_face", gchat: "🤢" },
|
||
mind_blown: { slack: "exploding_head", gchat: "🤯" },
|
||
relieved: { slack: "relieved", gchat: "😌" },
|
||
grimace: { slack: "grimacing", gchat: "😬" },
|
||
rolling_eyes: { slack: "rolling_eyes", gchat: "🙄" },
|
||
hug: { slack: "hugging_face", gchat: "🤗" },
|
||
zany: { slack: "zany_face", gchat: "🤪" },
|
||
|
||
// Status & Symbols
|
||
check: {
|
||
slack: ["white_check_mark", "heavy_check_mark"],
|
||
gchat: ["✅", "✔️"],
|
||
},
|
||
x: { slack: ["x", "heavy_multiplication_x"], gchat: ["❌", "✖️"] },
|
||
question: { slack: "question", gchat: ["❓", "?"] },
|
||
exclamation: { slack: "exclamation", gchat: "❗" },
|
||
warning: { slack: "warning", gchat: "⚠️" },
|
||
stop: { slack: "octagonal_sign", gchat: "🛑" },
|
||
info: { slack: "information_source", gchat: "ℹ️" },
|
||
"100": { slack: "100", gchat: "💯" },
|
||
fire: { slack: "fire", gchat: "🔥" },
|
||
star: { slack: "star", gchat: "⭐" },
|
||
sparkles: { slack: "sparkles", gchat: "✨" },
|
||
lightning: { slack: "zap", gchat: "⚡" },
|
||
boom: { slack: "boom", gchat: "💥" },
|
||
eyes: { slack: "eyes", gchat: "👀" },
|
||
|
||
// Status Indicators (colored circles)
|
||
green_circle: { slack: "large_green_circle", gchat: "🟢" },
|
||
yellow_circle: { slack: "large_yellow_circle", gchat: "🟡" },
|
||
red_circle: { slack: "red_circle", gchat: "🔴" },
|
||
blue_circle: { slack: "large_blue_circle", gchat: "🔵" },
|
||
white_circle: { slack: "white_circle", gchat: "⚪" },
|
||
black_circle: { slack: "black_circle", gchat: "⚫" },
|
||
|
||
// Objects & Tools
|
||
rocket: { slack: "rocket", gchat: "🚀" },
|
||
party: { slack: ["tada", "partying_face"], gchat: ["🎉", "🥳"] },
|
||
confetti: { slack: "confetti_ball", gchat: "🎊" },
|
||
balloon: { slack: "balloon", gchat: "🎈" },
|
||
gift: { slack: "gift", gchat: "🎁" },
|
||
trophy: { slack: "trophy", gchat: "🏆" },
|
||
medal: { slack: "first_place_medal", gchat: "🥇" },
|
||
lightbulb: { slack: "bulb", gchat: "💡" },
|
||
gear: { slack: "gear", gchat: "⚙️" },
|
||
wrench: { slack: "wrench", gchat: "🔧" },
|
||
hammer: { slack: "hammer", gchat: "🔨" },
|
||
bug: { slack: "bug", gchat: "🐛" },
|
||
link: { slack: "link", gchat: "🔗" },
|
||
lock: { slack: "lock", gchat: "🔒" },
|
||
unlock: { slack: "unlock", gchat: "🔓" },
|
||
key: { slack: "key", gchat: "🔑" },
|
||
pin: { slack: "pushpin", gchat: "📌" },
|
||
memo: { slack: "memo", gchat: "📝" },
|
||
clipboard: { slack: "clipboard", gchat: "📋" },
|
||
calendar: { slack: "calendar", gchat: "📅" },
|
||
clock: { slack: "clock1", gchat: "🕐" },
|
||
hourglass: { slack: "hourglass", gchat: "⏳" },
|
||
bell: { slack: "bell", gchat: "🔔" },
|
||
megaphone: { slack: "mega", gchat: "📢" },
|
||
speech_bubble: { slack: "speech_balloon", gchat: "💬" },
|
||
email: { slack: "email", gchat: "📧" },
|
||
inbox: { slack: "inbox_tray", gchat: "📥" },
|
||
outbox: { slack: "outbox_tray", gchat: "📤" },
|
||
package: { slack: "package", gchat: "📦" },
|
||
folder: { slack: "file_folder", gchat: "📁" },
|
||
file: { slack: "page_facing_up", gchat: "📄" },
|
||
chart_up: { slack: "chart_with_upwards_trend", gchat: "📈" },
|
||
chart_down: { slack: "chart_with_downwards_trend", gchat: "📉" },
|
||
coffee: { slack: "coffee", gchat: "☕" },
|
||
pizza: { slack: "pizza", gchat: "🍕" },
|
||
beer: { slack: "beer", gchat: "🍺" },
|
||
|
||
// Arrows & Directions
|
||
arrow_up: { slack: "arrow_up", gchat: "⬆️" },
|
||
arrow_down: { slack: "arrow_down", gchat: "⬇️" },
|
||
arrow_left: { slack: "arrow_left", gchat: "⬅️" },
|
||
arrow_right: { slack: "arrow_right", gchat: "➡️" },
|
||
refresh: { slack: "arrows_counterclockwise", gchat: "🔄" },
|
||
|
||
// Nature & Weather
|
||
sun: { slack: "sunny", gchat: "☀️" },
|
||
cloud: { slack: "cloud", gchat: "☁️" },
|
||
rain: { slack: "rain_cloud", gchat: "🌧️" },
|
||
snow: { slack: "snowflake", gchat: "❄️" },
|
||
rainbow: { slack: "rainbow", gchat: "🌈" },
|
||
};
|
||
|
||
/**
|
||
* Emoji resolver that handles conversion between platform formats and normalized names.
|
||
*/
|
||
export class EmojiResolver {
|
||
private readonly emojiMap: Record<string, EmojiFormats>;
|
||
private readonly slackToNormalized: Map<string, string>;
|
||
private readonly gchatToNormalized: Map<string, string>;
|
||
|
||
constructor(customMap?: EmojiMapConfig) {
|
||
this.emojiMap = { ...DEFAULT_EMOJI_MAP, ...customMap };
|
||
this.slackToNormalized = new Map();
|
||
this.gchatToNormalized = new Map();
|
||
this.buildReverseMaps();
|
||
}
|
||
|
||
private buildReverseMaps(): void {
|
||
for (const [normalized, formats] of Object.entries(this.emojiMap)) {
|
||
// Build Slack reverse map
|
||
const slackFormats = Array.isArray(formats.slack)
|
||
? formats.slack
|
||
: [formats.slack];
|
||
for (const slack of slackFormats) {
|
||
this.slackToNormalized.set(slack.toLowerCase(), normalized);
|
||
}
|
||
|
||
// Build GChat reverse map
|
||
const gchatFormats = Array.isArray(formats.gchat)
|
||
? formats.gchat
|
||
: [formats.gchat];
|
||
for (const gchat of gchatFormats) {
|
||
this.gchatToNormalized.set(gchat, normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert a Slack emoji name to normalized EmojiValue.
|
||
* Returns an EmojiValue for the raw emoji if no mapping exists.
|
||
*/
|
||
fromSlack(slackEmoji: string): EmojiValue {
|
||
// Remove colons if present (e.g., ":+1:" -> "+1")
|
||
const cleaned = slackEmoji.replace(/^:|:$/g, "").toLowerCase();
|
||
const normalized = this.slackToNormalized.get(cleaned) ?? slackEmoji;
|
||
return getEmoji(normalized);
|
||
}
|
||
|
||
/**
|
||
* Convert a Google Chat unicode emoji to normalized EmojiValue.
|
||
* Returns an EmojiValue for the raw emoji if no mapping exists.
|
||
*/
|
||
fromGChat(gchatEmoji: string): EmojiValue {
|
||
const normalized = this.gchatToNormalized.get(gchatEmoji) ?? gchatEmoji;
|
||
return getEmoji(normalized);
|
||
}
|
||
|
||
/**
|
||
* Convert a Teams reaction type to normalized EmojiValue.
|
||
* Teams uses specific names: like, heart, laugh, surprised, sad, angry
|
||
* Returns an EmojiValue for the raw reaction if no mapping exists.
|
||
*/
|
||
fromTeams(teamsReaction: string): EmojiValue {
|
||
const teamsMap: Record<string, string> = {
|
||
like: "thumbs_up",
|
||
heart: "heart",
|
||
laugh: "laugh",
|
||
surprised: "surprised",
|
||
sad: "sad",
|
||
angry: "angry",
|
||
};
|
||
const normalized = teamsMap[teamsReaction] ?? teamsReaction;
|
||
return getEmoji(normalized);
|
||
}
|
||
|
||
/**
|
||
* Convert a normalized emoji (or EmojiValue) to Slack format.
|
||
* Returns the first Slack format if multiple exist.
|
||
*/
|
||
toSlack(emoji: EmojiValue | string): string {
|
||
const name = typeof emoji === "string" ? emoji : emoji.name;
|
||
const formats = this.emojiMap[name];
|
||
if (!formats) {
|
||
return name;
|
||
}
|
||
return Array.isArray(formats.slack) ? formats.slack[0] : formats.slack;
|
||
}
|
||
|
||
/**
|
||
* Convert a normalized emoji (or EmojiValue) to Google Chat format.
|
||
* Returns the first GChat format if multiple exist.
|
||
*/
|
||
toGChat(emoji: EmojiValue | string): string {
|
||
const name = typeof emoji === "string" ? emoji : emoji.name;
|
||
const formats = this.emojiMap[name];
|
||
if (!formats) {
|
||
return name;
|
||
}
|
||
return Array.isArray(formats.gchat) ? formats.gchat[0] : formats.gchat;
|
||
}
|
||
|
||
/**
|
||
* Convert a normalized emoji (or EmojiValue) to Discord format (unicode).
|
||
* Discord uses unicode emoji, same as Google Chat.
|
||
*/
|
||
toDiscord(emoji: EmojiValue | string): string {
|
||
// Discord uses unicode emoji like GChat
|
||
return this.toGChat(emoji);
|
||
}
|
||
|
||
/**
|
||
* Check if an emoji (in any format) matches a normalized emoji name or EmojiValue.
|
||
*/
|
||
matches(rawEmoji: string, normalized: EmojiValue | string): boolean {
|
||
const name = typeof normalized === "string" ? normalized : normalized.name;
|
||
const formats = this.emojiMap[name];
|
||
if (!formats) {
|
||
return rawEmoji === name;
|
||
}
|
||
|
||
const slackFormats = Array.isArray(formats.slack)
|
||
? formats.slack
|
||
: [formats.slack];
|
||
const gchatFormats = Array.isArray(formats.gchat)
|
||
? formats.gchat
|
||
: [formats.gchat];
|
||
|
||
const cleanedRaw = rawEmoji.replace(/^:|:$/g, "").toLowerCase();
|
||
|
||
return (
|
||
slackFormats.some((s) => s.toLowerCase() === cleanedRaw) ||
|
||
gchatFormats.includes(rawEmoji)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Add or override emoji mappings.
|
||
*/
|
||
extend(customMap: EmojiMapConfig): void {
|
||
Object.assign(this.emojiMap, customMap);
|
||
this.buildReverseMaps();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Default emoji resolver instance.
|
||
*/
|
||
export const defaultEmojiResolver = new EmojiResolver();
|
||
|
||
/** Placeholder pattern for emoji in text: {{emoji:name}} */
|
||
const EMOJI_PLACEHOLDER_REGEX = /\{\{emoji:([a-z0-9_]+)\}\}/gi;
|
||
|
||
/**
|
||
* Convert emoji placeholders in text to platform-specific format.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* convertEmojiPlaceholders("Thanks! {{emoji:thumbs_up}}", "slack");
|
||
* // Returns: "Thanks! :+1:"
|
||
*
|
||
* convertEmojiPlaceholders("Thanks! {{emoji:thumbs_up}}", "gchat");
|
||
* // Returns: "Thanks! 👍"
|
||
* ```
|
||
*/
|
||
export function convertEmojiPlaceholders(
|
||
text: string,
|
||
platform:
|
||
| "slack"
|
||
| "gchat"
|
||
| "teams"
|
||
| "discord"
|
||
| "github"
|
||
| "linear"
|
||
| "whatsapp",
|
||
resolver: EmojiResolver = defaultEmojiResolver
|
||
): string {
|
||
return text.replace(EMOJI_PLACEHOLDER_REGEX, (_, emojiName: string) => {
|
||
switch (platform) {
|
||
case "slack":
|
||
return `:${resolver.toSlack(emojiName)}:`;
|
||
case "gchat":
|
||
return resolver.toGChat(emojiName);
|
||
case "teams":
|
||
// Teams uses unicode emoji
|
||
return resolver.toGChat(emojiName);
|
||
case "discord":
|
||
// Discord uses unicode emoji
|
||
return resolver.toDiscord(emojiName);
|
||
case "github":
|
||
// GitHub uses unicode emoji
|
||
return resolver.toGChat(emojiName);
|
||
case "linear":
|
||
// Linear uses unicode emoji
|
||
return resolver.toGChat(emojiName);
|
||
case "whatsapp":
|
||
// WhatsApp uses unicode emoji
|
||
return resolver.toGChat(emojiName);
|
||
default:
|
||
return resolver.toGChat(emojiName);
|
||
}
|
||
});
|
||
}
|
||
|
||
// =============================================================================
|
||
// Emoji Helper Types
|
||
// =============================================================================
|
||
|
||
/** Base emoji object with well-known emoji as EmojiValue singletons */
|
||
type BaseEmojiHelper = {
|
||
[K in WellKnownEmoji]: EmojiValue;
|
||
} & {
|
||
/** Create an EmojiValue for a custom emoji name */
|
||
custom: (name: string) => EmojiValue;
|
||
};
|
||
|
||
/** Extended emoji object including custom emoji from module augmentation */
|
||
type ExtendedEmojiHelper = BaseEmojiHelper & {
|
||
[K in keyof CustomEmojiMap]: EmojiValue;
|
||
};
|
||
|
||
/**
|
||
* Create a type-safe emoji helper with custom emoji.
|
||
*
|
||
* Returns immutable singleton EmojiValue objects that support:
|
||
* - Object identity comparison (`event.emoji === emoji.thumbs_up`)
|
||
* - Template string interpolation (`${emoji.thumbs_up}` → "{{emoji:thumbs_up}}")
|
||
*
|
||
* Custom emoji are automatically registered with the default resolver,
|
||
* so placeholders will convert correctly in messages.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* // First, extend the CustomEmojiMap type (usually in a .d.ts file)
|
||
* declare module "chat" {
|
||
* interface CustomEmojiMap {
|
||
* unicorn: EmojiFormats;
|
||
* company_logo: EmojiFormats;
|
||
* }
|
||
* }
|
||
*
|
||
* // Then create the emoji helper with your custom emoji
|
||
* const emoji = createEmoji({
|
||
* unicorn: { slack: "unicorn_face", gchat: "🦄" },
|
||
* company_logo: { slack: "company", gchat: "🏢" },
|
||
* });
|
||
*
|
||
* // Object identity works for comparisons
|
||
* if (event.emoji === emoji.unicorn) { ... }
|
||
*
|
||
* // Template strings work for messages
|
||
* await thread.post(`${emoji.unicorn} Magic!`);
|
||
* // Slack: ":unicorn_face: Magic!"
|
||
* // GChat: "🦄 Magic!"
|
||
* ```
|
||
*/
|
||
export function createEmoji<
|
||
T extends Record<
|
||
string,
|
||
{ slack: string | string[]; gchat: string | string[] }
|
||
>,
|
||
>(customEmoji?: T): BaseEmojiHelper & { [K in keyof T]: EmojiValue } {
|
||
// All well-known emoji names
|
||
const wellKnownEmoji: WellKnownEmoji[] = [
|
||
// Reactions & Gestures
|
||
"thumbs_up",
|
||
"thumbs_down",
|
||
"clap",
|
||
"wave",
|
||
"pray",
|
||
"muscle",
|
||
"ok_hand",
|
||
"point_up",
|
||
"point_down",
|
||
"point_left",
|
||
"point_right",
|
||
"raised_hands",
|
||
"shrug",
|
||
"facepalm",
|
||
// Emotions & Faces
|
||
"heart",
|
||
"smile",
|
||
"laugh",
|
||
"thinking",
|
||
"sad",
|
||
"cry",
|
||
"angry",
|
||
"love_eyes",
|
||
"cool",
|
||
"wink",
|
||
"surprised",
|
||
"worried",
|
||
"confused",
|
||
"neutral",
|
||
"sleeping",
|
||
"sick",
|
||
"mind_blown",
|
||
"relieved",
|
||
"grimace",
|
||
"rolling_eyes",
|
||
"hug",
|
||
"zany",
|
||
// Status & Symbols
|
||
"check",
|
||
"x",
|
||
"question",
|
||
"exclamation",
|
||
"warning",
|
||
"stop",
|
||
"info",
|
||
"100",
|
||
"fire",
|
||
"star",
|
||
"sparkles",
|
||
"lightning",
|
||
"boom",
|
||
"eyes",
|
||
// Status Indicators
|
||
"green_circle",
|
||
"yellow_circle",
|
||
"red_circle",
|
||
"blue_circle",
|
||
"white_circle",
|
||
"black_circle",
|
||
// Objects & Tools
|
||
"rocket",
|
||
"party",
|
||
"confetti",
|
||
"balloon",
|
||
"gift",
|
||
"trophy",
|
||
"medal",
|
||
"lightbulb",
|
||
"gear",
|
||
"wrench",
|
||
"hammer",
|
||
"bug",
|
||
"link",
|
||
"lock",
|
||
"unlock",
|
||
"key",
|
||
"pin",
|
||
"memo",
|
||
"clipboard",
|
||
"calendar",
|
||
"clock",
|
||
"hourglass",
|
||
"bell",
|
||
"megaphone",
|
||
"speech_bubble",
|
||
"email",
|
||
"inbox",
|
||
"outbox",
|
||
"package",
|
||
"folder",
|
||
"file",
|
||
"chart_up",
|
||
"chart_down",
|
||
"coffee",
|
||
"pizza",
|
||
"beer",
|
||
// Arrows & Directions
|
||
"arrow_up",
|
||
"arrow_down",
|
||
"arrow_left",
|
||
"arrow_right",
|
||
"refresh",
|
||
// Nature & Weather
|
||
"sun",
|
||
"cloud",
|
||
"rain",
|
||
"snow",
|
||
"rainbow",
|
||
];
|
||
|
||
// Build the emoji helper object with EmojiValue singletons
|
||
const helper: Record<string, EmojiValue | ((name: string) => EmojiValue)> = {
|
||
custom: (name: string): EmojiValue => getEmoji(name),
|
||
};
|
||
|
||
// Add all well-known emoji
|
||
for (const name of wellKnownEmoji) {
|
||
helper[name] = getEmoji(name);
|
||
}
|
||
|
||
// Add custom emoji if provided
|
||
if (customEmoji) {
|
||
for (const key of Object.keys(customEmoji)) {
|
||
helper[key] = getEmoji(key);
|
||
}
|
||
// Extend the default resolver so placeholders convert correctly
|
||
defaultEmojiResolver.extend(customEmoji as EmojiMapConfig);
|
||
}
|
||
|
||
return helper as BaseEmojiHelper & {
|
||
[K in keyof T]: EmojiValue;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Type-safe emoji helper for embedding emoji in messages.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* import { emoji } from "chat";
|
||
*
|
||
* await thread.post(`Great job! ${emoji.thumbs_up} ${emoji.fire}`);
|
||
* // Slack: "Great job! :+1: :fire:"
|
||
* // GChat: "Great job! 👍 🔥"
|
||
* ```
|
||
*
|
||
* For custom emoji, use `createEmoji()` with module augmentation:
|
||
* @example
|
||
* ```typescript
|
||
* // types.d.ts
|
||
* declare module "chat" {
|
||
* interface CustomEmojiMap {
|
||
* unicorn: EmojiFormats;
|
||
* }
|
||
* }
|
||
*
|
||
* // bot.ts
|
||
* const emoji = createEmoji({ unicorn: { slack: "unicorn", gchat: "🦄" } });
|
||
* await thread.post(`${emoji.unicorn} Magic!`);
|
||
* ```
|
||
*/
|
||
export const emoji: ExtendedEmojiHelper = createEmoji() as ExtendedEmojiHelper;
|