English | 简体中文
Channel SDK — let agents and external services integrate with the Feishu/Lark
messaging system without touching the WebSocket lifecycle, the dozen-plus
msg_type branches, or @-mention placeholder wiring.
It sits on top of @larksuiteoapi/node-sdk
and gives you one entry point that reliably receives & normalizes events,
applies policy/safety, and sends streaming replies, media, and cards.
npm install @larksuite/channel
# or: pnpm add @larksuite/channelimport { createLarkChannel } from '@larksuite/channel';
const channel = createLarkChannel({
appId: process.env.LARK_APP_ID!,
appSecret: process.env.LARK_APP_SECRET!,
});
channel.on('message', async (msg) => {
await channel.send(
msg.chatId,
{ markdown: `received: ${msg.content}` },
{ replyTo: msg.messageId },
);
});
await channel.connect();No WebSocket reconnect logic, no text / post / merge_forward parsing —
the channel hands you a NormalizedMessage and takes a SendInput.
- Transport — WebSocket connection management, auto-reconnect, keepalive, handshake timeout, webhook mode.
- Normalization — a dozen-plus
msg_typevalues folded into a singleNormalizedMessage; @-mention handling,merge_forwardexpansion, card / reaction / comment / botAdded event normalization. - Policy & safety —
requireMention, allowlists, dedup, stale-drop, per-chat serialization. - Outbound —
send(text / markdown / post / card / image / file / audio / video / share / sticker), streaming typewriter cards,updateCard, reactions, media upload with SSRF guard, automatic fallbacks.
| API | Description |
|---|---|
createLarkChannel(opts: LarkChannelOptions): LarkChannel |
Factory (recommended) |
new LarkChannel(opts) |
Class form, equivalent |
Read-only instance members: channel.comments (comment surface),
channel.rawClient (underlying Client, escape hatch), channel.rawWsClient
(underlying WSClient), channel.botIdentity (available after connect()).
Bootstrap an app's appId / appSecret via a QR-code device flow (no
pre-existing credentials needed). You get a QR URL through onQRCodeReady;
after the user scans it and creates / authorizes the app, it resolves with the
credentials — feed them straight into createLarkChannel.
import { registerApp, createLarkChannel } from '@larksuite/channel';
const { client_id, client_secret } = await registerApp({
onQRCodeReady: ({ url, expireIn }) => console.log('scan to register:', url),
onStatusChange: (s) => console.log('status:', s.status),
});
const channel = createLarkChannel({ appId: client_id, appSecret: client_secret });RegisterAppOptions: onQRCodeReady (required) · onStatusChange? ·
appPreset? (pre-fill app name/desc/avatar) · domain? / larkDomain? ·
signal? (AbortSignal) · source? — an optional attribution tag appended to
the QR URL as source/<name> (passed through as-is, not defaulted).
| Option | Type | Default | Description |
|---|---|---|---|
appId / appSecret |
string |
— | Required |
transport |
'websocket' | 'webhook' |
'websocket' |
Transport mode |
webhook |
WebhookOptions |
— | Webhook-mode config (verification token / encrypt key / adapter) |
policy |
PolicyConfig |
— | Who may trigger the bot (inbound gate) |
safety |
SafetyConfig |
— | Dedup / stale / per-chat queue / batching |
outbound |
OutboundConfig |
— | Outbound behavior (chunking, streaming, SSRF, retry) |
resolveChatMode |
boolean |
false |
Populate NormalizedMessage.chatMode (one cached chat.get per chat) |
resolveSenderNames |
boolean |
false |
Populate NormalizedMessage.senderName from the chat roster (one cached getChatMembers per chat) |
resolveChatMembers |
(chatId) => ChatMember[] | undefined | Promise<…> |
— | Override how getChatMembers sources the roster (return undefined to fall back to the API) |
keepalive |
{ enabled; onUnrecoverable?; intervalMs? } |
— | Connection watchdog (WS only) |
respectProxyEnv |
boolean |
false |
Route WS + REST through HTTPS_PROXY / HTTP_PROXY |
httpTimeoutMs |
number |
— | Per-request REST timeout |
agent |
http(s).Agent |
— | Custom WS agent (wins over respectProxyEnv) |
handshakeTimeoutMs |
number |
— | WS handshake timeout |
wsConfig |
WSConfigOverrides |
— | Client-only WS settings (pingTimeout) |
domain |
Domain | string |
Feishu |
Feishu / Lark domain |
cache |
Cache |
built-in | Cache instance (dedup / credentials) |
logger / loggerLevel |
Logger / LoggerLevel |
info |
Logging |
httpInstance |
HttpInstance |
shared default | Custom HTTP instance (then configure timeout/proxy yourself) |
source |
string |
— | User-Agent tag |
includeRawEvent |
boolean |
false |
Attach the raw event payload as evt.raw |
PolicyConfig: requireMention · dmMode ('open' \| 'allowlist' \| 'pair' \| 'disabled') · dmAllowlist · groupAllowlist · respondToMentionAll · botLoopGuard (see Bot-at-bot). dmAllowlist takes sender ids (ou_… / user_id / union_id), groupAllowlist takes chat ids (oc_…) — an app id (cli_…) belongs in neither and is warned about.
SafetyConfig: dedup (ttl/maxEntries/sweepIntervalMs) · chatQueue (enabled, mergeWhileBusy) · batch.text / batch.media · staleMessageWindowMs.
| Method | Signature | Description |
|---|---|---|
connect |
connect(): Promise<void> |
Connect; resolves after the first WS handshake |
disconnect |
disconnect(): Promise<void> |
Disconnect and clean up |
getConnectionStatus |
(): WSConnectionStatus | undefined |
Connection snapshot (undefined in webhook mode / before connect) |
on('message', fn) for a single event, or on({ message, cardAction }) for
several; returns an unsubscribe function.
| Event | Payload | When |
|---|---|---|
message |
NormalizedMessage |
Inbound message (after policy / safety / batching) |
cardAction |
CardActionEvent |
Card button / form submit (handler may return a CardActionResponse — see below) |
reaction |
ReactionEvent |
Message reaction add/remove |
botAdded |
BotAddedEvent |
Bot added to a chat |
comment |
CommentEvent |
Cloud-doc comment @-mentioning the bot |
reject |
RejectEvent |
Message rejected by policy (reason) |
error |
LarkChannelError |
Internal error |
reconnecting / reconnected |
() |
WS reconnect lifecycle |
interface NormalizedMessage {
messageId: string;
chatId: string;
chatType: 'p2p' | 'group';
chatMode?: 'p2p' | 'group' | 'topic'; // requires resolveChatMode
senderId: string;
senderName?: string; // requires resolveSenderNames
senderType?: string; // 'user' | 'bot' | 'system' | 'anonymous' (from the raw event; undefined if absent)
senderIsBot?: boolean; // true when senderType === 'bot'; undefined when senderType is absent
content: string; // normalized, readable content
rawContentType: string; // original msg_type
resources: ResourceDescriptor[];
mentions: MentionInfo[];
mentionAll: boolean;
mentionedBot: boolean;
rootId?: string;
threadId?: string;
replyToMessageId?: string;
createTime: number;
raw?: unknown; // present when includeRawEvent is set
}
interface CardActionEvent {
messageId: string; chatId: string;
operator: { openId: string; userId?: string; name?: string };
action: { value: unknown; tag: string; name?: string; option?: string; formValue?: Record<string, unknown> };
}
interface ReactionEvent { messageId: string; operator: { openId: string; userId?: string }; emojiType: string; action: 'added' | 'removed'; actionTime?: number; }
interface BotAddedEvent { chatId: string; operator: { openId: string; userId?: string }; botName?: string; external?: boolean; }
interface CommentEvent { fileToken: string; fileType: string; commentId: string; replyId?: string; operator: { openId: string; userId?: string; unionId?: string }; mentionedBot: boolean; timestamp: number; }
interface RejectEvent { messageId: string; chatId: string; senderId: string; reason: RejectReason; }
type RejectReason = 'group_not_allowed' | 'sender_not_allowed' | 'no_mention' | 'dm_disabled' | 'mention_all_blocked' | 'bot_loop';A cardAction handler may return a CardActionResponse to give the
clicking user native, immediate feedback — most commonly a toast — without
having to update the whole card:
channel.on('cardAction', async (evt) => {
await handleAction(evt);
return { toast: { type: 'success', content: 'Submitted' } };
// or update the card in place: { card: { type: 'raw', data: { ... } } }
});The returned object is passed back to Feishu/Lark verbatim as the callback
response for that click. Returning nothing (undefined) means "no immediate
response" — the original behavior, so existing handlers keep working unchanged.
Notes:
- The response is sent synchronously, and card actions run serially per chat (after any in-flight work for that chat). A slow handler can therefore delay the response past Feishu's callback timeout — for heavy work, prefer detaching it and reflecting progress via a card update.
- The object is sent to Feishu as-is: do not include internal secrets / PII, and make sure it is JSON-serializable.
| Method | Signature | Notes |
|---|---|---|
send |
send(to: string, input: SendInput, opts?: SendOptions): Promise<SendResult> |
to accepts open_id / chat_id / user_id (auto-detected) |
reply |
reply(msg, input: SendInput, opts?: SendOptions): Promise<SendResult> |
Reply to a received message — defaults replyTo to it and stays in-thread when it was (Bot-at-bot) |
stream |
stream(to, input: StreamInput, opts?): Promise<SendResult> |
Streaming reply |
updateCard |
updateCard(messageId, card): Promise<void> |
Replace a card |
editMessage |
editMessage(messageId, text): Promise<void> |
Edit text/post |
recallMessage |
recallMessage(messageId): Promise<void> |
Recall |
addReaction |
addReaction(messageId, emojiType): Promise<string> |
Returns reaction_id |
removeReaction |
removeReaction(messageId, reactionId): Promise<void> |
Remove by id |
removeReactionByEmoji |
removeReactionByEmoji(messageId, emojiType): Promise<boolean> |
Remove the bot's own |
downloadResource |
downloadResource(messageId, fileKey, type): Promise<Buffer> |
Download media from a received message; type: 'image' / 'file'. Resources forwarded in a merge_forward use the same top-level msg.messageId |
getChatInfo |
getChatInfo(chatId): Promise<ChatInfo> |
Chat info |
getChatMode |
getChatMode(chatId): Promise<'p2p' | 'group' | 'topic'> |
Chat mode |
getChatMembers |
getChatMembers(chatId, opts?): Promise<ChatMember[]> |
Roster (users only — Feishu filters bots), paginated + cached (Bot-at-bot) |
getChatBots |
getChatBots(chatId, opts?): Promise<ChatMember[]> |
The chat's bots (isBot: true); cached; seeds the roster so bots are @-able by name (Bot-at-bot) |
getBotIdentity |
getBotIdentity(): BotIdentity |
This bot's own { openId, name }; throws not_connected before connect() |
fetchMessage |
fetchMessage(messageId): Promise<NormalizedMessage | undefined> |
Fetch + normalize a message |
type SendInput =
| { markdown: string } | { text: string } | { post: object }
| { image: { source: string | Buffer } }
| { file: { source: string | Buffer; fileName: string } }
| { audio: { source: string | Buffer; duration?: number } }
| { video: { source: string | Buffer; duration?: number; coverImageKey?: string } }
| { card: object }
| { shareChat: { chatId: string } } | { shareUser: { userId: string } }
| { sticker: { fileKey: string } };
interface SendOptions { replyTo?: string; replyInThread?: boolean; mentions?: MentionInfo[]; resolveMentionsInText?: boolean; }
interface SendResult { messageId: string; chunkIds?: string[]; }
type StreamInput =
| { markdown: (c: MarkdownStreamController) => Promise<void> } // c.append(chunk) / c.setContent(full)
| { card: { initial: object; producer: (c: CardStreamController) => Promise<void> } }; // c.update(next)Media source accepts a URL / local path / Buffer, with a built-in SSRF guard.
| Method | Signature | Description |
|---|---|---|
updatePolicy |
updatePolicy(partial: Partial<PolicyConfig>): void |
Hot-update policy (partial merge, effective immediately) |
getPolicy |
getPolicy(): Readonly<PolicyConfig> |
Read the current policy |
| Method | Signature | Notes |
|---|---|---|
resolveTarget |
resolveTarget(fileToken, fileType): Promise<CommentTarget | null> |
Resolves a wiki node to its obj_token; null for unsupported types |
fetch |
fetch(target, commentId): Promise<FetchedComment | null> |
Falls back from .get to .list pagination |
reply |
reply(target, commentId, text): Promise<void> |
Falls back to a fresh top-level comment for whole-doc comments |
addReaction / removeReaction |
(target, replyId, emojiType = 'Typing') |
Comment reactions |
The two entry points correspond to the two identities. Both return a
MeetingSession with the same events and methods.
| Method | Signature | Identity |
|---|---|---|
followMyMeeting |
(opts: FollowMeetingOptions): Promise<MeetingSession> |
User access token. Follows the meeting its owner is in; nothing appears in the meeting |
joinMeeting |
(meetingNo: string, opts?: JoinMeetingOptions): Promise<MeetingSession> |
App (tenant) credentials. The bot joins as a visible participant |
getMeetingEventHealth |
(): MeetingEventHealth |
Diagnostics for the in-meeting event path |
getRetainedMeetings |
(): MeetingMembership[] |
Meetings the bot is in with no session listening |
// User identity — no connect() needed, this path is REST polling only.
const m = await channel.followMyMeeting({
userAccessToken: () => myAuth.freshToken(), // re-read before every poll
stabilizeMs: 800, // deliver a sentence once it settles
});
// App identity — requires connect(), it is driven by event pushes.
channel.on('meetingInvited', async (invite) => {
const m = await channel.joinMeeting(invite.meetingNo, { callId: invite.callId });
m.on('chat', async ({ content, selfEcho }) => {
if (selfEcho) return; // see "Answering yourself" below
await m.sendMessage(`heard: ${content}`);
});
});| Session event | Payload | Notes |
|---|---|---|
transcript |
{ actor, text, sentenceId, language, startMs, endMs, selfEcho } |
Captions. A later delivery of the same sentenceId supersedes the earlier text; requires captions / transcription to be on in the meeting |
chat |
{ actor, content, messageId, messageType, sendTime, selfEcho } |
In-meeting chat. The bot's own messages come back here with selfEcho: true |
participant |
{ actor, action: 'joined' | 'left', joinTime, leaveTime, leaveReason } |
Participants arriving and leaving. leaveReason is the platform's raw value |
share |
{ actor, action: 'started' | 'ended', shareId, doc, time } |
Document sharing started / ended. A hand-off arrives as a pair in one delivery, and the order carries the meaning |
documentContext |
{ contextType, commentFocus | sectionLocation | elementPreview, … } |
A context change inside a shared document (comment focus / section location / element preview). Identifiers only — no body text or assets |
end |
{ meetingId, reason } |
The session ended. reason is a MeetingEndReason: meeting_ended / no_longer_active / idle_timeout / error / left / disposed |
error |
LarkChannelError |
An error inside the session (poll failure, unpacking, a throwing handler). With no handler registered it degrades to a log rather than an unhandled rejection |
session.on() is multicast (unlike channel.on(), which is single-slot):
several handlers per event, and the returned function removes only its own.
| Method | Notes |
|---|---|
sendMessage(text) |
In-meeting message. Rejects with not_supported in follow mode — the bot is not in the meeting |
leave() |
Leave the meeting and free the slot, then end the session. Idempotent, reclaims even if the API call fails, and still works after the session has ended |
dispose() |
Stop timers and subscriptions without leaving. Idempotent |
getStats() |
Per-activity-type parse counters for this session |
dispose() and leave() — dispose() stops the session's timers and event
subscriptions without calling any API; the bot stays in the meeting. leave() calls
bots/leave to remove the bot from the meeting, returns the concurrency slot, and then
ends the session; it remains callable after the session has already ended, including
after dispose() or disconnect(). disconnect() disposes every live session.
After disconnect(), re-attach — disconnect() disposes sessions but does not leave
their meetings, so the bot stays in them; a later connect() re-registers the event
handlers but does not rebuild the sessions, and pushes for those meetings are then
dropped. Sessions signal it by ending with reason: 'disposed'.
getRetainedMeetings() lists them as { meetingId, meetingNo }; re-attach with
joinMeeting(meetingNo), which does not consume a new concurrency slot for a meeting
already held, or call leave() on the new session to give the slot back. Ignoring an
entry leaves the bot in a meeting deaf, holding a slot, until the meeting ends.
meeting.idleTimeoutMs — Idle reclamation threshold in ms, default 0 (off). With
a positive value, a session that receives no in-meeting activity for that long ends,
calls bots/leave, and returns its slot. App identity only.
meeting.livenessProbeIntervalMs — Probe interval in ms, default 300000.
Periodically confirms the bot is still in the meeting, covering the cases that produce
no meeting_ended_v1 (removed by a host, meeting handed over). App identity only.
meeting.maxConcurrentSessions — Ceiling on concurrent sessions, default 32. At
the ceiling joinMeeting() throws too_many_sessions and no bots/join request is
sent. The count tracks meetings joined and not yet left, so disconnect() does not
release it and leave() does.
meeting.sendRateLimitPerMinute — In-meeting messages per session per minute,
default 20. Beyond it sendMessage() throws rate_limited.
selfEcho — Marks an item produced by this bot: messages it sends come back as
chat, and its speech is transcribed into transcript. Flagged items are delivered as
usual, leaving the decision to ignore them to the caller. It reports true while the
bot's own open_id is unresolved, and is always false in follow mode.
Delivery order — Items within one delivery are handed over serially in array order, and an async handler's return value is awaited before the next item is delivered.
stabilizeMs — Caption settling window in ms, default 0. With 0 every text
change is delivered; with a positive value a sentenceId is delivered once it has
received no new content for that long. Later deliveries of the same sentenceId
supersede earlier text, so callers should upsert on it.
Which send is later is decided by endMs, not by arrival order: a session ingests from
event pushes and from the liveness probe's REST read, so an earlier, shorter version of a
sentence can arrive last. While settling, a send whose endMs is older than the one held
is ignored. Equal values keep last-arrival-wins, since a transcription fix rewrites a
sentence without extending it. With stabilizeMs: 0 there is no buffer to compare
against, so ordering is the caller's to handle.
getMeetingEventHealth() — Returns counters for each of the two inbound links,
{ push, poll }. Both carry received (activities received), lastAt, and per activity
type a { received, empty } pair; empty counts activities of that type that unpacked
to zero items, which distinguishes "the platform never sent it" from "it arrived and
could not be read".
push additionally carries registered (whether the channel's internal vc.bot.*
handlers are registered, set by connect()) and reason when it is not. registered
describes registration, not connectivity: it stays true across a dropped and
reconnecting WebSocket. poll additionally carries sessions, the number of live follow
sessions, so received: 0 with sessions: 0 reads as "nothing to poll" rather than a
fault.
The links are counted separately because they fail independently — pushes can stop while
polling keeps working, and one total would let either link's traffic stand in for the
other's health. The split is by transport, not by session identity: an app-identity
session's liveness probe reads over REST, so what it recovers counts under poll.
Follow-mode visibility — Follow mode does not join the meeting: no bot appears in the participant list, while everything every participant says is readable. Informing participants and obtaining their agreement is the integrator's responsibility; the SDK surfaces nothing on their behalf.
Examples: examples/10-meeting-follow.ts,
examples/11-meeting-join.ts.
const off = channel.onRawEvent('vc.bot.meeting_started_v1', (payload) => { … });
off(); // removes this one handlerWhat it does — registers a callback under a Feishu event type name and hands it the decrypted event payload as the platform sent it. Several handlers can share one event type without replacing each other; the return value removes the one you just registered.
The problem it solves — the channel wraps a fixed set of event types: IM
messages, card actions, reactions, bot-added, Drive comments, and the three meeting
pushes the meeting channel runs on (vc.bot.meeting_invited_v1, _activity_v1,
_ended_v1). Everything else — approvals, calendar, contact changes, and
vc.bot.meeting_started_v1 in the snippet above — has no entry point of its own.
The two workarounds are both bad: writing into the dispatcher's private handler map
breaks on a version bump,
and opening a second connection for the same app makes Feishu split delivery
between the two connections, so the channel's own IM messages start arriving
intermittently. onRawEvent puts these event types on the connection the channel
already holds.
Side effects — the callback receives the event unprocessed, so the channel's
inbound protections do not apply to it. Signature verification and decryption still
happen (they are a layer earlier), but every step after normalization is skipped:
PolicyGate (dmMode, dmAllowlist, groupAllowlist, requireMention), dedup,
the per-chat serialization lock, the loop guard, stale-event dropping. In practice:
- Registering a raw handler for a type the channel already wraps opens a second,
unguarded entry point for it.
im.message.receive_v1is the one to watch: a message your allowlist rejects on the built-in path still reaches the raw handler. - The payload is not redacted and
includeRawEvent: falsedoes not affect it — it carriestenant_key, full user ids and message bodies. Logging it or forwarding it to a third party is on you. - A raw handler is a pure observer: its return value is always discarded (the
signature is
=> void | Promise<void>), so it cannot change what goes back to Feishu — only delay when that is sent. One event is processed in this order: the built-in handler finishes, then your raw handlers finish one by one, and only then is the response returned to Feishu. The response can only come from a built-in handler; an unwrapped event type has none, so Feishu always gets "no response" —onRawEventcannot be used to reply to Feishu. For most event types that is irrelevant, since Feishu only wants an acknowledgement. But acard.action.triggerresponse is what the user sees after clicking (a toast, an updated card), and Feishu puts a timeout on it: a raw handler that spends a few seconds on a request will make the click look failed even though the built-in handler produced the right result immediately. Raw handlers on that event type must return at once and leave real work to a queue.
normalize / normalizeCardAction / normalizeReaction / normalizeBotAdded
/ normalizeComment turn a raw Feishu event payload into a normalized object —
for custom transports or tests. normalize always resolves; the other four
return null when the payload is missing the required identity fields.
Outbound / connection failures reject with a LarkChannelError carrying a
stable code:
| code | Meaning |
|---|---|
format_error |
Bad content format (a plain-text downgrade was attempted) |
target_revoked |
Reply target gone (a resend without replyTo was attempted) |
rate_limited |
Rate limited |
permission_denied |
Auth / permission failure |
upload_failed / ssrf_blocked |
Media upload failed / URL blocked by the SSRF guard |
send_timeout / not_connected / unknown |
Timeout / not connected / other |
not_supported |
Unavailable in this mode (e.g. sendMessage on a followed meeting) |
meeting_not_found |
No active meeting to follow, or the target is no longer active |
too_many_sessions |
meeting.maxConcurrentSessions reached |
On a permission failure the meeting path may attach context.consoleUrl — the
signed one-click authorization link Feishu returns. Treat it as a credential:
it is passed through byte for byte (re-encoding invalidates the signature) and is
dropped entirely unless it is an https: URL. The SDK does not write it to a log of
its own, but it does not scrub logs either — log hygiene, including anything
node-sdk writes about a failed request, is the integrator's responsibility. Hand it
to an operator; do not echo it into a chat, a UI, or a support ticket.
try {
await channel.send(chatId, { markdown });
} catch (e) {
const err = e as LarkChannelError;
console.log(err.code, err.message, err.context); // err.cause holds the raw error
}Errors thrown inside inbound handlers don't reject your
await— they surface on theerrorevent instead.
Multiple bots collaborating in one chat (@-ing each other to hand off work) need a few extra signals and guards. Everything here is opt-in / additive — off by default, existing behavior unchanged.
Know who sent it. Every message carries senderType ('user' / 'bot' /
…) and the convenience senderIsBot, so an agent can tell a human, itself, and
another bot apart. Get the bot's own identity for its system prompt with
getBotIdentity(). Enable resolveSenderNames to fill senderName from the
chat roster.
Receiving events from other bots. Feishu does not deliver "another bot
@-ed me" events unless your app has the im:message.group_at_msg /
include_bot permission enabled — and the failure is silent. There is no API to
self-check this; if bot-to-bot @ mentions never arrive, verify that permission
first. An @-only ping still wakes the bot. When someone @-mentions the bot
but types nothing else, the message is still delivered (not dropped as empty):
mentionedBot is true while content is empty. Detect this "just poke the
bot" case with mentionedBot && !content.trim().
Replying to the right place. Use channel.reply(msg, input) instead of
computing the reply target by hand. It replies to msg and follows the
triggering message's shape: replyTo defaults to msg.messageId, and
replyInThread defaults to Boolean(msg.threadId) — so a reply stays in a
thread when the message was in one, and stays flat when it wasn't.
| Triggering message | Default replyInThread |
Result |
|---|---|---|
| Topic group (every message is threaded) | true |
reply lands back in the same topic |
| Ordinary group, flat message (no thread) | false |
plain quote-reply — does not start a thread |
| Ordinary group, message already in a thread | true |
reply stays in that existing thread |
reply() only follows the trigger — it never promotes a flat message into a
thread on its own. Override either default via opts:
channel.reply(msg, input, { replyInThread: true }); // start a thread from a flat message
channel.reply(msg, input, { replyInThread: false }); // plain reply even inside a thread@-mentioning by name. To @ someone back, either pass structured
mentions (a name-only { name } is resolved to an open_id via the chat
roster) or set resolveMentionsInText: true to rewrite @name tokens in a
text/markdown body. Names resolve from the chat roster, which is seeded from
getChatMembers (users), getChatBots (bots), and bots observed in earlier
inbound mentions. So to @ another bot by name, either call getChatBots(chatId)
once to preload it, or rely on it having already appeared in the chat; otherwise
pass its open_id explicitly. A name that is unknown or shared by more than one
member is left as plain text and never mentioned — so for security-sensitive
handoffs, pass an explicit open_id.
Restrict who can trigger the bot — by chat, not by listing each sender.
Sender open_ids are hard to get up front, so enumerating them in dmAllowlist
is impractical. Instead, allow only specific chats with
groupAllowlist: ['oc_…'] and require an @ with requireMention: true — this
scopes the bot to those chats without caring who sent the message.
Breaking ping-pong loops. Two bots can @ each other endlessly. The opt-in
policy.botLoopGuard counts only "another bot @-ed me" messages in a sliding
window (a human message resets it) and trips past a threshold:
policy: {
botLoopGuard: {
enabled: true,
windowMs: 60_000, // sliding window W
maxBotMentions: 5, // trip at N bot @-mentions in W
scope: 'chat', // or 'chat+sender'
onTrip: 'reject', // 'drop' (default) silently mutes; 'reject' emits a reject event
},
}Tune windowMs / maxBotMentions to your collaboration tempo — set them too
low and legitimate high-tempo handoffs trip it. The default onTrip: 'drop'
silently stops replying (it logs one warning on the first trip); prefer
'reject' (emits a reject event with reason: 'bot_loop') when the app needs
to know it was muted. This is a heuristic backstop, not a protocol-level
guarantee.
MIT