Files
SYNITYandDangTinh311 d07dca440a feat(bitrix24): status-reaction support via imbot.v2 reactions [B24:2794] (#1446)
Bitrix24 was the only channel that never showed the agent's progress as an
emoji reaction on the user's message (Telegram/Slack/Feishu/Discord/... all do).
Two things were missing: the channel didn't implement channels.ReactionChannel,
and its inbound metadata omitted the generic "message_id" key the gateway
consumer reads to thread status/streaming events back to a message.

- handle.go: set meta["message_id"] = MESSAGE_ID (every other channel does this;
  without it the run has no message id to react to). Kept the existing
  bitrix_message_id key for reply-link routing.
- reactions.go (new): implement OnReactionEvent + ClearReaction. Map agent
  status → a Bitrix v2 reaction code (thinkingFace / fire / whiteHeavyCheckMark /
  crossMark / ...), debounce intermediate updates (terminal states bypass), and
  replace the previous reaction via imbot.v2.Chat.Message.Reaction.delete +
  .add (Bitrix has no in-place replace). REACTION_ALREADY_SET is treated as
  success. ReactionLevel off/minimal/full already existed in the channel config.
- channel.go: add the per-message reactions state map + compile-time assertion.

Tests: full flow (add → delete+add on status change), minimal skips
intermediate, off/invalid/unknown are no-ops, ClearReaction removes current.

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
2026-07-16 18:54:40 +07:00

695 lines
30 KiB
Go

package bitrix24
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
)
// mentionMatcher is the compiled-once regex + rendered tag string used to
// detect whether a group message @-tags this bot. Cached on the Channel
// and invalidated automatically when the channel's bot_id changes.
type mentionMatcher struct {
// botID is what this matcher was compiled for. Used to detect staleness
// after a Reload() that re-registered with a different imbot id.
botID int
// stripRe matches `[USER=<id>] ... [/USER]` (or BOT= variant) so we can
// remove the mention before passing to the agent. Scoped to THIS bot_id
// — mentions of other users/bots stay intact.
stripRe *regexp.Regexp
// tags are the literal `[USER=123]` / `[BOT=123]` openers we look for
// in the fast-path Contains check (regexp alloc avoided on happy path).
tags []string
}
// DispatchEvent implements BotDispatcher. Called from Router.handleEvent in
// its own goroutine — we still return quickly (no synchronous Bitrix call
// back to the portal) so the router can move on to the next webhook.
//
// Events are dispatched by type:
// - ONIMBOTMESSAGEADD → handleMessage (policy + HandleMessage → bus)
// - ONIMBOTJOINCHAT → handleJoin (send welcome)
// - ONIMBOTDELETE → unregister bot and mark health stopped
//
// Unknown event types are logged at Info so we notice new Bitrix24 payloads
// without spamming the error log.
func (c *Channel) DispatchEvent(ctx context.Context, evt *Event) {
if evt == nil {
return
}
switch evt.Type {
case EventMessageAdd:
c.handleMessage(ctx, evt)
case EventJoinChat:
c.handleJoin(ctx, evt)
case EventBotDelete:
// Defense in depth: only teardown when the delete is for OUR bot. Router
// shouldn't dispatch a mismatched event here, but if it ever does, don't
// unregister someone else's entry by mistake. Snapshot the bot id once
// so the compare + log don't re-acquire startMu.
ourBotID := c.BotID()
if evt.Params.BotID != ourBotID {
slog.Warn("bitrix24: ONIMBOTDELETE for a different bot id — ignoring",
"event_bot_id", evt.Params.BotID, "channel_bot_id", ourBotID,
"portal", c.cfg.Portal)
return
}
// Reuse Stop() so the teardown path matches channel-shutdown exactly
// (Router unregister + SetRunning(false) + MarkStopped("") + close(stopCh)).
// Stop() records the generic "Stopped" summary; immediately overwrite it
// with the ONIMBOTDELETE-specific reason so operators viewing channel
// health can distinguish "user deleted our bot on the portal" from a
// normal shutdown. Stop() currently can't return an error — ignored on
// purpose; if that changes we'll need to decide whether teardown errors
// should block the health-override or propagate.
_ = c.Stop(ctx)
c.MarkStopped("Bot deleted on portal")
case EventMessageUpdate, EventMessageDelete:
// Phase 03 scope: ignore edits/deletes. Phase 05 may surface them to
// the agent for context pruning.
slog.Debug("bitrix24: ignoring message edit/delete event",
"event", evt.Type, "bot_id", evt.Params.BotID, "portal", c.cfg.Portal)
default:
slog.Info("bitrix24: unhandled event type",
"event", evt.Type, "bot_id", evt.Params.BotID, "portal", c.cfg.Portal)
}
}
// handleMessage turns an ONIMBOTMESSAGEADD into a bus.InboundMessage.
//
// Control flow (each step returns early on deny/drop):
// 1. Classify peer kind (DM vs group) from MESSAGE_TYPE.
// 2. Group-only: require-mention gate + strip the mention from the text.
// 3. Skip empty payloads (no text + no media).
// 4. Policy: DM vs Group via BaseChannel.CheckDMPolicy / CheckGroupPolicy.
// Pairing policies send a pairing reply (code + approve instructions) via
// sendPairingReply; deny drops silently.
// 5. Build metadata map with bitrix_* keys so the agent can echo / reply
// to the right dialog + message ID later.
// 6. Forward to BaseChannel.HandleMessage → publishes bus.InboundMessage.
func (c *Channel) handleMessage(ctx context.Context, evt *Event) {
// Inject tenant scope so store queries filter by the correct tenant_id.
ctx = store.WithTenantID(ctx, c.TenantID())
if evt.Params.FromUserID == "" {
return // malformed event; router already logged if this matters
}
// System messages (e.g. "user X joined the chat") should not trigger
// agent replies. Bitrix flags these with SYSTEM=Y.
if evt.Params.SystemMessage {
return
}
// Open Channel (Bitrix24 Lines) carries MESSAGE_TYPE="L" and
// CHAT_ENTITY_TYPE="LINES". The session mixes real customers coming in
// through a connector (Zalo/FB/...) with internal staff who join to
// supervise. Customers can't @-mention the bot from outside; treating the
// session as a generic group chat would either spam customers (drop when
// require_mention is true, reply to everything when false) or silently
// merge into the "direct" path. Recognise it up-front so the gate below
// can apply the right policy.
isOpenChannel := isOpenChannelParams(&evt.Params)
// Force group routing for Open Channel so the existing mention-strip /
// readable-mention pipeline below runs, and so the session key includes
// the chat id instead of dumping every participant into the "direct"
// bucket.
isGroup := isGroupMessageType(evt.Params.MessageType) || isOpenChannel
text := evt.Params.Message
slog.Info("bitrix24 message: handle entry",
"from_user_id", evt.Params.FromUserID,
"dialog_id", evt.Params.DialogID,
"message_type", evt.Params.MessageType,
"is_group", isGroup,
"is_open_channel", isOpenChannel,
"from_connector", evt.Params.FromIsConnector,
"require_mention", c.RequireMention(),
"message_id", evt.Params.MessageID,
"mentioned_list_n", len(evt.Params.MentionedList),
)
// Open Channel gate (must run BEFORE the generic group block):
// Mention is the only criterion — both internal staff and external
// customers (IS_CONNECTOR=Y) can trigger the bot when they @-mention
// it. The connector side relies on upstream populating MENTIONED_LIST
// with the bot id when the customer addresses the bot from Zalo/FB;
// without an explicit mention, traffic is dropped so the bot doesn't
// spam the customer or interfere with operator handling.
if isOpenChannel {
// Mention gate is now scoped per-connector: see shouldRequireMentionForOpenline
// for the policy (internal staff always gate; external customer gates only
// for group-style connectors like Zalo personal; 1-to-1 connectors reply
// to every customer message). Connector whitelist is overridable via
// BITRIX24_REQUIRE_MENTION_CONNECTORS env so adding e.g. a future Zalo group
// connector doesn't need a code change.
if shouldRequireMentionForOpenline(evt.Params.FromIsConnector, evt.Params.ChatEntityID) {
if !c.isMentionedParams(&evt.Params) {
slog.Info("bitrix24 message: dropped OL message without mention",
"from_user_id", evt.Params.FromUserID,
"from_connector", evt.Params.FromIsConnector,
"chat_entity_id", evt.Params.ChatEntityID,
"dialog_id", evt.Params.DialogID,
"message_id", evt.Params.MessageID)
return
}
}
}
if isGroup {
// Mention DROP CHECK is scoped to native group chats (CRM deal/task,
// plain groups) only. Open Channel sessions own their mention policy
// via shouldRequireMentionForOpenline above — re-applying the channel-
// level RequireMention flag here would silently drop, e.g., every
// Facebook Messenger customer turn (whose connector isn't in the
// require-mention whitelist) even though the upstream gate let it
// through. Text processing below (mention strip + readable rewrite)
// stays unconditional so Openline staff messages with bot BBCode tags
// still get cleaned up.
//
// Authority-ordered fallback: structured MENTIONED_LIST → raw
// MESSAGE_ORIGINAL → stripped MESSAGE. In group chats Bitrix24 strips
// the @mention from MESSAGE before sending the webhook, so checking
// MESSAGE alone misses every group mention. See
// plans/bitrix24-mcp-refactor/reports/retrospective.md §2 for context.
mentioned := c.isMentionedParams(&evt.Params)
if !isOpenChannel && c.RequireMention() && !mentioned {
slog.Info("bitrix24 message: dropped missing mention",
"from_user_id", evt.Params.FromUserID,
"dialog_id", evt.Params.DialogID,
"message_type", evt.Params.MessageType,
"message_id", evt.Params.MessageID,
)
return
}
// Prefer MESSAGE_ORIGINAL (raw BBCode) over MESSAGE for groups: Bitrix24
// strips ALL `[USER=<id>]…[/USER]` mentions — including mentions of OTHER
// users — from MESSAGE before sending the webhook. Without this, a
// message like "[USER=982]Alice[/USER] [USER=62]Bob[/USER] help us"
// reaches the agent as just "help us", losing the addressed-user context.
//
// Pipeline: stripMention removes THIS bot's own tag → convert remaining
// user/bot mentions to "@Name (ID:<id>)" so the LLM sees who else was
// addressed without parsing BBCode. Falls back to MESSAGE on legacy
// portals that don't ship MESSAGE_ORIGINAL.
if evt.Params.MessageOriginal != "" {
text = evt.Params.MessageOriginal
}
text = c.stripMention(text)
text = bxConvertUserMentionsToReadable(text)
}
text = strings.TrimSpace(text)
// Openline relays an external connector user with a sender tag at the start
// of the text. We parse it to (a) echo the connector msgId back so the reply
// routes to the right external message, and (b) — for the newer 3-token
// "[Name] #uid #msgId" layout — derive a stable per-participant identity from
// the external person's uid so each customer gets their own USER.md / memory
// instead of collapsing into the shared connector proxy.
//
// Identity is gated on FromIsConnector: only genuine connector relays
// (IS_CONNECTOR=Y) may mint a participant identity. An operator who types a
// look-alike "[Name] #a #b" tag (observed live) must never be mistaken for a
// customer, so for non-connector messages we only strip the tag from the body
// the agent sees and derive nothing. Plain group chats / DMs → no-op.
var senderTag OpenlineSenderTag
var participantSenderID string
if isOpenChannel {
if evt.Params.FromIsConnector {
senderTag = parseOpenlineSenderTag(text)
if senderTag.Format == TagFormatThreeToken && senderTag.UID != "" {
// Unique per (channel instance, OL chat, person) → one contact and
// one USER.md per external customer in the chat. c.Name() is
// config-controlled, the uid is digits-only from the regex, and the
// DialogID is a validated "chatNN" token — no freeform injection.
participantSenderID = fmt.Sprintf("openlines:%s:%s:%s",
c.Name(), evt.Params.DialogID, senderTag.UID)
}
if senderTag.Format != TagFormatNone {
text = strings.TrimSpace(senderTag.Rest)
}
} else if _, rest := extractOpenlineSenderPrefix(text, true); rest != text {
// Operator/staff message: strip a look-alike tag from the body for the
// LLM, but derive no identity and echo no prefix.
text = strings.TrimSpace(rest)
}
}
if text == "" && len(evt.Params.Files) == 0 {
return
}
// senderID defaults to the connector proxy id (e.g. "960", shared by every
// customer in the chat). When a per-participant identity was derived above we
// use it instead so contact + memory scope to the individual person.
senderID := evt.Params.FromUserID
if participantSenderID != "" {
senderID = participantSenderID
}
chatID := evt.Params.DialogID
peerKind := "direct"
if isGroup {
peerKind = "group"
}
// Policy gating. BaseChannel reports PolicyNeedsPairing for unpaired senders
// under the "pairing" policy. Answer with a real pairing reply (code + how to
// approve) via the v2 chat API, matching the other channels (Discord/Zalo/
// WhatsApp/Slack/Feishu). Group pairings are keyed by "group:<chatID>" so the
// generated code matches what CheckGroupPolicy.IsPaired later verifies.
if peerKind == "direct" {
switch c.CheckDMPolicy(ctx, senderID, c.cfg.DMPolicy) {
case channels.PolicyDeny:
return
case channels.PolicyNeedsPairing:
c.sendPairingReply(ctx, senderID, chatID, peerKind)
return
}
} else {
switch c.CheckGroupPolicy(ctx, senderID, chatID, c.cfg.GroupPolicy) {
case channels.PolicyDeny:
return
case channels.PolicyNeedsPairing:
c.sendPairingReply(ctx, "group:"+chatID, chatID, peerKind)
return
}
}
// Visibility: whisper (internal-only) vs public (forwarded to external
// connector). Send() uses this to route through imbot.message.add with
// SKIP_CONNECTOR=Y (whisper) or imbot.v2.Chat.Message.send (public).
// Default to public so legacy events without the marker still publish
// to the connector — that matches pre-refactor behavior.
visibility := VisibilityPublic
if evt.Params.IsHiddenMessage {
visibility = VisibilityWhisper
}
meta := map[string]string{
"bitrix_dialog_id": evt.Params.DialogID,
"bitrix_portal": c.portalDomainSafe(),
"bitrix_bot_id": strconv.Itoa(c.BotID()),
"bitrix_bot_code": c.cfg.BotCode,
// Bitrix MESSAGE_ID drives the v2 fields.replyId reply-link. It is a
// Bitrix-internal id, distinct from the connector msgId embedded in the
// sender tag (echoed via MetaKeySenderPrefix instead), so it must stay the
// genuine webhook MESSAGE_ID — a 13-digit connector msgId is not a valid
// Bitrix replyId.
MetaKeyMessageID: evt.Params.MessageID,
MetaKeyVisibility: visibility,
// Generic run-tracking key the gateway consumer reads to thread
// streaming / status-reaction events back to this message (all other
// channels set it). Distinct from bitrix_message_id above (same value
// here, but that key is Bitrix-specific reply-link routing).
"message_id": evt.Params.MessageID,
}
// Echo the connector sender tag back on the reply so the Open Channel
// connector routes the answer to the right external message:
// - 3-token → "#msgId" only (name + uid dropped; connector needs just the id),
// - legacy → canonical "[name] #msgId" (unchanged from prior behavior),
// - name-only → "[name]".
// senderTag is the zero value (TagFormatNone) for operator / non-connector
// messages, so this is a no-op there.
switch senderTag.Format {
case TagFormatThreeToken:
meta[MetaKeySenderPrefix] = "#" + senderTag.MsgID
case TagFormatLegacy:
meta[MetaKeySenderPrefix] = "[" + senderTag.Name + "] #" + senderTag.MsgID
case TagFormatNameOnly:
meta[MetaKeySenderPrefix] = "[" + senderTag.Name + "]"
}
// Per-participant identity signal for the consumer (per-person USER.md scope).
if participantSenderID != "" {
meta[MetaKeyParticipantUserID] = participantSenderID
}
if evt.Params.ChatID != "" {
meta["bitrix_chat_id"] = evt.Params.ChatID
}
// Entity binding lets MCP tools resolve "this deal" / "this task" without
// parsing CHAT_TITLE strings. Examples:
// bitrix_chat_entity_type=CRM bitrix_chat_entity_id=DEAL|2064
// bitrix_chat_entity_type=TASKS_TASK bitrix_chat_entity_id=2704
// Plain user-created chats omit both fields.
if evt.Params.ChatEntityType != "" {
meta["bitrix_chat_entity_type"] = evt.Params.ChatEntityType
}
if evt.Params.ChatEntityID != "" {
meta["bitrix_chat_entity_id"] = evt.Params.ChatEntityID
}
// Decoded entity context — pull out the per-connector fields from the
// raw ENTITY_ID / ENTITY_DATA_* payloads (Openline session state, CRM
// linkage, single-token task / workgroup / mail ids) plus CHAT_TITLE
// and CHAT_TYPE. Only non-empty keys are emitted so DMs / plain groups
// pay no cost. See entity_context.go for parser semantics.
if ec, ok := ParseEntityContext(&evt.Params); ok {
for k, v := range ec.ToMeta(&evt.Params) {
meta[k] = v
}
}
// Collect contact for processed messages (matches Telegram pattern at
// channels/telegram/handlers.go:617-630). Runs AFTER policy gating so
// blocked senders aren't recorded, and BEFORE HandleMessage so the
// contact row exists by the time the agent (on the other side of the
// bus) resolves userID → MCP credentials via MCPServerStore.
//
// Bitrix24 webhooks don't ship display_name / username, so we enrich
// via user.get on first sight (cached per-channel; see
// contact_enrich.go). Best-effort — if the RPC fails or scope is
// missing we still create the contact row with empty fields, which
// matches the pre-enrichment behavior and causes no regression.
if cc := c.ContactCollector(); cc != nil {
var contactName, contactUsername string
if participantSenderID != "" && senderTag.Name != "" {
// Use the connector-parsed display name directly. resolveContactName
// would call user.get(senderID), but senderID is now the synthetic
// per-participant id and the numeric proxy (e.g. 960) resolves to the
// connector account — not the customer. The parsed name is the only
// real signal we have for the external person.
contactName = senderTag.Name
} else {
contactName, contactUsername = c.resolveContactName(ctx, senderID)
}
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, contactName, contactUsername, peerKind, "user", "", "")
if isGroup && chatID != "" {
cc.EnsureContact(ctx, c.Type(), c.Name(), chatID, "", "", "", "group", "group", "", "")
}
}
// MCP lazy provisioning (Phase C). Best-effort: any failure is logged
// and swallowed — agent loop downstream will just see no creds and skip
// the MCP server's tools, which is strictly better UX than the channel
// denying the message. The typed errors let tests assert behavior
// without string matching.
if err := c.provisionIfMissing(ctx, senderID, evt.Params.FromIsConnector, evt.Auth, chatID); err != nil {
// ErrUserAuthRequired carries a URL (not a sentinel value), so it
// needs errors.As rather than errors.Is. Checked first and returns
// early — the user has no MCP access yet, so there's nothing useful
// for the agent to answer with; don't publish to the bus.
var authErr *ErrUserAuthRequired
if errors.As(err, &authErr) {
c.sendOAuthInvite(ctx, senderID, chatID, isGroup, authErr.URL)
return
}
switch {
case errors.Is(err, ErrProvisionDisabled),
errors.Is(err, ErrProvisionSkippedOpenChannel),
errors.Is(err, ErrProvisionDebounced):
// Expected no-ops — don't spam logs. Debug level is enough
// for troubleshooting "why didn't this user get MCP tools?"
slog.Debug("bitrix24 mcp: provisioning skipped",
"channel", c.Name(), "user", senderID, "reason", err)
default:
// Unexpected error (HTTP failure, persist failure, auth
// validation). Warn so operators see it, but DO NOT return —
// message still flows through to the agent.
slog.Warn("bitrix24 mcp: provisioning failed",
"channel", c.Name(), "user", senderID, "err", err)
// Best-effort degradation notice so the user knows to contact
// admin instead of silently getting tool-less replies. Debounced
// 5min per-user inside the helper so a retry storm / sustained
// outage won't spam the DM. See notifyUserOfMCPIssueOnce
// docstring for the design rationale.
c.notifyUserOfMCPIssueOnce(ctx, senderID, chatID)
}
}
// Reply/quote context: when the user replied to an earlier message, Bitrix
// ships the quoted text inline (REPLY_MESSAGE); a media-only original carries
// only an id, which we resolve via im.dialog.messages.get. resolveReplyContext
// returns a short note to prepend so the agent knows what's being answered,
// plus any quoted attachment re-injected through the SAME download pipeline as
// a direct upload (bounded by media_max_mb, best-effort). No-op when not a reply.
replyPrefix, replyMedia := c.resolveReplyContext(ctx, evt)
// Download any attachments via imbot.v2.File.download and forward them to
// the agent with their MIME type preserved. Best-effort: failures are logged
// inside downloadEventFiles and never block the text from reaching the agent.
mediaFiles := c.downloadEventFiles(ctx, c.BotID(), evt.Params.Files)
mediaFiles = append(mediaFiles, replyMedia...)
// Prepend <media:*> tags so the LLM sees the attachment alongside the body
// text. The agent loop's enrichInputMedia REPLACES tags it finds (it does
// NOT insert new ones), so without this step a Bitrix-borne PDF / audio
// arrives as bare text — the LLM never realizes an attachment exists and
// degrades to "no file attached" or hallucinates a CRM document. Match
// the Telegram / Slack / Discord pattern (shared media.BuildMediaTags) so
// the tag shape stays uniform across channels.
if len(mediaFiles) > 0 {
if tags := media.BuildMediaTags(mediaFilesToInfos(mediaFiles)); tags != "" {
if text == "" {
text = tags
} else {
text = tags + "\n" + text
}
}
}
// Reply note goes at the very front so the agent reads "who/what is being
// answered" before the (possibly quoted) media tags and the user's own text.
if replyPrefix != "" {
text = replyPrefix + text
}
slog.Info("bitrix24 message: publish to bus",
"sender_id", senderID,
"chat_id", chatID,
"peer_kind", peerKind,
"message_id", evt.Params.MessageID,
"media_count", len(mediaFiles),
)
c.HandleAuthorizedMessageMedia(senderID, chatID, text, mediaFiles, meta, peerKind)
}
// handleJoin sends a short welcome the first time the bot is added to a
// chat. Failure is non-fatal — the agent will still respond to the user's
// first real message.
func (c *Channel) handleJoin(ctx context.Context, evt *Event) {
// Open Channel sessions are customer-facing. Keep join events silent so
// adding the bot does not push an unsolicited greeting to the customer.
if isOpenChannelParams(&evt.Params) {
slog.Debug("bitrix24: welcome message skipped for open channel",
"dialog_id", evt.Params.DialogID)
return
}
client := c.Client()
botID := c.BotID()
if client == nil || botID <= 0 {
return
}
if strings.TrimSpace(evt.Params.DialogID) == "" {
return
}
welcome := fmt.Sprintf("Xin chào! Tôi là %s. Hãy hỏi tôi bất cứ điều gì.", c.cfg.BotName)
if _, err := client.Call(ctx, "imbot.v2.Chat.Message.send", map[string]any{
"botId": botID,
"dialogId": evt.Params.DialogID,
"fields": map[string]any{"message": welcome},
}); err != nil {
slog.Warn("bitrix24: welcome message send failed",
"dialog_id", evt.Params.DialogID, "err", err)
}
}
func isOpenChannelParams(p *EventParams) bool {
return p != nil && (strings.EqualFold(p.MessageType, "L") ||
strings.EqualFold(p.ChatEntityType, "LINES"))
}
// isMentionedParams checks all three sources Bitrix24 may use to convey a
// bot mention, in authority order:
//
// 1. data[PARAMS][MENTIONED_LIST][<bot_id>] — structured map populated by
// Bitrix on group messages. Highest authority (no regex, no Unicode
// edge cases). Absent on DMs.
// 2. data[PARAMS][MESSAGE_ORIGINAL] — raw BBCode (`[USER=<bot_id>]…[/USER]`).
// Group-only. Reliable when MENTIONED_LIST is absent (older portals).
// 3. data[PARAMS][MESSAGE] — stripped plain text. Bitrix removes the
// @mention from this in group chats, so it only matches in DMs.
//
// Without this fallback chain group @mentions silently drop because
// MESSAGE has the mention stripped before the webhook is sent.
func (c *Channel) isMentionedParams(p *EventParams) bool {
if p == nil {
return false
}
botID := c.BotID()
if botID <= 0 {
return false
}
id := strconv.Itoa(botID)
if _, ok := p.MentionedList[id]; ok {
return true
}
if p.MessageOriginal != "" && c.isMentioned(p.MessageOriginal) {
return true
}
return c.isMentioned(p.Message)
}
// isMentioned returns true when the message contains a [USER=<bot_id>] or
// [BOT=<bot_id>] tag matching this channel's bot. The fast path is a plain
// substring check; the regex is only built lazily for stripMention.
func (c *Channel) isMentioned(msg string) bool {
m := c.mention()
if m == nil {
return false
}
for _, tag := range m.tags {
if strings.Contains(msg, tag) {
return true
}
}
return false
}
// stripMention removes all [USER=<bot_id>]…[/USER] / [BOT=<bot_id>]…[/BOT]
// fragments belonging to this bot, leaving other mentions intact. The result
// may have leading/trailing whitespace — trimming is the caller's job
// (handleMessage already TrimSpaces both DM and group paths uniformly).
func (c *Channel) stripMention(msg string) string {
m := c.mention()
if m == nil || m.stripRe == nil {
return msg
}
return m.stripRe.ReplaceAllString(msg, "")
}
// mention returns the cached matcher for this bot's id, building it lazily
// on first use and rebuilding it when bot_id changes.
//
// Returns nil when the bot id isn't resolved yet (pre-Start) or when regex
// compilation fails (logged once — next call will retry).
//
// Uses its own mutex (mentionMu) rather than piggybacking on startMu because
// the hot read path runs on every group message and we don't want it to
// contend with Start/Stop's long-held lock.
func (c *Channel) mention() *mentionMatcher {
botID := c.BotID()
if botID <= 0 {
return nil
}
c.mentionMu.Lock()
defer c.mentionMu.Unlock()
if c.mentionRe != nil && c.mentionRe.botID == botID {
return c.mentionRe
}
id := strconv.Itoa(botID)
// Tolerant regex: closing tag may be [/USER] or [/BOT] regardless of
// the opener variant (some Bitrix clients mismatch).
//
// Body uses non-greedy `.*?` (with `(?s)` so `.` matches \n) so a mention
// whose display text contains nested BBCode — e.g. `[USER=101][b]Boss[/b][/USER]`
// — still matches. The earlier `[^\[]*` form stopped at the first `[` of
// the nested tag and never reached `[/USER]`, leaving raw BBCode in the
// prompt. Non-greedy is safe even across multiple mentions of this bot
// in one message because each iteration starts at the next `[USER=ID]`.
pattern := fmt.Sprintf(`(?s)\[(USER|BOT)=%s\].*?\[/(?:USER|BOT)\]`, id)
re, err := regexp.Compile(pattern)
if err != nil {
slog.Warn("bitrix24: failed to compile mention regex",
"bot_id", id, "err", err)
return nil
}
c.mentionRe = &mentionMatcher{
botID: botID,
stripRe: re,
tags: []string{"[USER=" + id + "]", "[BOT=" + id + "]"},
}
return c.mentionRe
}
// sendPairingReply requests a pairing code for an unpaired sender and sends it
// back into the chat via the v2 API (imbot.v2.Chat.Message.send) so an admin can
// approve access. Mirrors the pairing reply the other channels already send
// (Discord/Zalo/WhatsApp/Slack/Feishu) using the shared pairing.account_required
// system message. Debounced per-sender so a spammy sender can't flood the chat.
//
// pairingSenderID is the id the pairing is keyed by — the raw senderID for DMs,
// "group:<chatID>" for groups — so the generated code matches what CheckDMPolicy
// / CheckGroupPolicy later verify via IsPaired.
func (c *Channel) sendPairingReply(ctx context.Context, pairingSenderID, chatID, peerKind string) {
ps := c.PairingService()
if ps == nil {
return
}
if !c.CanSendPairingNotif(pairingSenderID, pairingDebounce) {
return
}
code, err := ps.RequestPairing(ctx, pairingSenderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("bitrix24 pairing: request failed",
"sender_id", pairingSenderID, "chat_id", chatID, "err", err)
return
}
replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{
"platform": "Bitrix24",
"sender_id": pairingSenderID,
"code": code,
})
if err := c.sendChunkV2Public(ctx, chatID, replyText, 0); err != nil {
slog.Warn("bitrix24 pairing: reply send failed",
"sender_id", pairingSenderID, "chat_id", chatID, "err", err)
return
}
c.MarkPairingNotifSent(pairingSenderID)
slog.Info("bitrix24 pairing: reply sent",
"sender_id", pairingSenderID, "chat_id", chatID, "peer_kind", peerKind,
"portal", c.cfg.Portal)
}
// portalDomainSafe reads the portal domain under the start lock. Returns
// empty string if the channel hasn't finished starting yet — callers treat
// that as "unknown portal" and the session router will still work off
// tenant + bot_code.
func (c *Channel) portalDomainSafe() string {
p := c.Portal()
if p == nil {
return ""
}
return p.Domain()
}
// pairingDebounce throttles the logPairingNeeded warnings so a spammy
// sender can't flood the log. 60s matches the Telegram channel convention.
const pairingDebounce = 60 * time.Second
// isGroupMessageType normalises Bitrix24 MESSAGE_TYPE to group-or-not.
// Webhook events use short codes:
//
// - "P" / "private" — direct message between two users
// - "C" / "chat" — generic multi-user group chat (also CRM Deal chats)
// - "O" / "open" — Open Channel session (customer-service widget)
// - "X" — entity-bound group chat (Tasks, Workgroups, etc.).
// Observed empirically with CHAT_ENTITY_TYPE=TASKS_TASK; treated as
// group because CHAT_USER_COUNT>1 and the @mention semantics match
// plain "C" chats. Without this branch task chats fall through to
// direct-message handling, which bypasses the require-mention gate
// and routes traffic to a `direct:chatNN` session key instead of
// `group:chatNN`, mixing per-task context into per-user history.
// - "B" — Bitrix24 workgroup / Collab (SONET_GROUP) chat,
// observed with CHAT_TYPE=B and CHAT_ENTITY_TYPE=SONET_GROUP. Same
// group semantics as "C" / "X" — multi-user by design, @mention
// gating applies. Without this branch the @mention prefix never
// renders on the bot's reply (it'd address the wrong member by
// name) and per-user history bleeds into one direct session.
//
// Anything else (including the empty string) is treated as a direct
// message so stricter DM policies apply.
func isGroupMessageType(mt string) bool {
switch strings.ToUpper(strings.TrimSpace(mt)) {
case "B", "C", "CHAT", "O", "OPEN", "X":
return true
default:
return false
}
}