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

344 lines
13 KiB
Go

package bitrix24
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Channel is the goclaw-side Bitrix24 bot handle.
//
// One Channel instance per (portal, bot_code) pair. Start() resolves the
// portal via the shared Router, calls imbot.register idempotently, and wires
// the returned bot_id into the Router so inbound events land here.
//
// Concurrency:
// - BaseChannel handles its own locking for health / running / allowlist.
// - `cfg`, `portalStore`, `encKey`, `router` are write-once at construction.
// - `portal`, `client`, `botID` are written during Start() and read
// afterwards; guarded by startMu so Stop() sees a coherent snapshot even
// if it races a failed Start().
type Channel struct {
*channels.BaseChannel
cfg bitrixInstanceConfig
portalStore store.BitrixPortalStore
encKey string
router *Router
startMu sync.Mutex
portal *Portal
client *Client
botID int
// stopOnce ensures close(stopCh) runs at most once. stopCh is wired into
// any long-running goroutine the channel spawns (currently none in Phase
// 03, reserved for Phase 05 streaming).
stopOnce sync.Once
stopCh chan struct{}
// mentionRe caches the compiled per-botID mention regex. Rebuilt on
// demand whenever the cached entry's bot id no longer matches the
// channel's current bot id (e.g. after a Reload() re-registered and
// got back a different id). mentionMu guards mentionRe specifically
// so the hot read path doesn't contend with Start/Stop on startMu.
mentionMu sync.Mutex
mentionRe *mentionMatcher
// reactions tracks the current status-reaction emoji per user message so
// OnReactionEvent can replace the previous one as the agent's status
// progresses (thinking → tool → done). Keyed by "<chatID>:<messageID>";
// values are *reactionState. See reactions.go.
reactions sync.Map
// MCP lazy provisioner (Phase C). All fields nil / zero when
// provisioning is disabled — channel then works exactly as before
// (messages flow through without trying to mint MCP credentials).
//
// mcpStore comes from the MCP-aware factory variant; mcpClient is
// built at Start() iff config has mcp_server_name + mcp_base_url and
// the named mcp_servers row exists. Bitrix24 OAuth → existing
// mcp_user_credentials bridge: the MCP server authenticates each
// onboard call via the caller-supplied Bitrix access_token — no
// shared admin secret is required. mcpServerID is
// resolved once at Start() via mcpStore.GetServerByName and then
// cached — avoids looking up the server on every inbound message.
mcpStore store.MCPServerStore
mcpClient *mcpClient
mcpServerID uuid.UUID
mcpProvMu sync.Mutex
mcpDebounce map[mcpDebounceKey]time.Time
// User-facing degradation notice state. When provisionIfMissing fails
// in an UNEXPECTED way (HTTP failure, persist failure, not one of the
// typed skip sentinels), the channel best-effort sends a one-shot
// message to the user so they're not left wondering why their agent
// responses suddenly lack MCP tools. The notify map keeps per-user
// debounce timestamps so a sustained MCP outage or a webhook retry
// burst doesn't flood the user's DM with duplicates. TTL is defined
// as mcpUserNotifyDebounceTTL in provisioner.go.
//
// Separate mutex from mcpProvMu because notify happens on the logging
// branch after provisioning has already returned — no need to
// serialize the two paths, and keeping them independent means a slow
// Bitrix Send() can't stall the next provisioning decision.
notifyMu sync.Mutex
notifyDebounce map[string]time.Time
// OAuth re-authorization DM debounce. Deliberately separate map/mutex
// from notifyDebounce above — the two notice types (generic MCP failure
// vs "please re-authorize") are independent; one must never suppress
// the other. Same TTL value (mcpUserNotifyDebounceTTL) but its own state.
oauthInviteMu sync.Mutex
oauthInviteDebounce map[string]time.Time
// Contact-name enrichment cache. Bitrix24 webhooks don't carry
// display_name / username, so the channel lazily resolves them via
// user.get on first sight of each sender. Cache is per-channel (not
// per-portal) to keep the lock narrow and let per-bot debouncing
// compose naturally; cross-bot duplicate lookups for the same user
// are fine at this scale. See contact_enrich.go for TTL policy and
// the negative-cache rationale.
nameCacheMu sync.Mutex
nameCache map[string]nameCacheEntry
}
// Type returns the platform identifier used by the router / health pages.
// Always "bitrix24" regardless of the DB-instance name.
func (c *Channel) Type() string { return channels.TypeBitrix24 }
// BotID exposes the registered bot id for the Phase 02 BotDispatcher
// contract. Returns 0 before Start() completes — Router.RegisterBot rejects
// zero, so early calls are a no-op.
func (c *Channel) BotID() int {
c.startMu.Lock()
defer c.startMu.Unlock()
return c.botID
}
// PortalName returns the portal key configured on this channel (not the
// Bitrix24 domain). Used by BotDispatcher so Router.handleAppUninstall can
// drop bots by portal without needing a back-pointer to the portal struct.
func (c *Channel) PortalName() string { return c.cfg.Portal }
// Config returns a copy of the instance config. Exported for tests.
func (c *Channel) Config() bitrixInstanceConfig { return c.cfg }
// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default).
func (c *Channel) BlockReplyEnabled() *bool { return c.cfg.BlockReply }
// ChatBehaviorConfig returns the per-channel chat_behavior override.
func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.cfg.ChatBehavior }
// IsOpenChannelBot reports whether this channel was registered as a Bitrix24
// Open Channel bot (TYPE "O"), i.e. a customer-facing bot attached to an
// Open Channel queue. Standard internal bots (TYPE "B") return false.
//
// Phase C provisioner uses this to skip per-user MCP credential minting:
// Open Channel senders are transient customers without tenant_users mapping,
// so minting credentials for each one would bloat the DB and leak internal
// permissions. Shared-credential support is deferred to Phase E.
func (c *Channel) IsOpenChannelBot() bool {
return strings.EqualFold(strings.TrimSpace(c.cfg.BotType), "O")
}
// Start brings the channel online:
// 1. Resolve the portal (load from store on cold path via the Router).
// 2. Sanity-check it's installed — uninstalled portals surface as Failed
// health with an actionable message (admin must visit /bitrix24/install).
// 3. Ensure the portal's refresh loop is running (idempotent).
// 4. imbot.register with idempotency via portal state.
// 5. Register the (bot_id → Channel) mapping with the Router.
//
// On failure the channel's own bot_id / Router bot entry are NOT populated,
// so a later Reload() re-runs cleanly. The portal entry is intentionally
// left behind — other bots on the same portal share it, and the Router's
// EnsurePortalRunning is idempotent across restarts.
//
// The mention regex cache is automatically invalidated if/when bot_id
// changes across retries (see mention() in handle.go) — no reset needed
// here. Avoiding a sync.Once reassignment also avoids a race with any
// in-flight handler goroutine still calling mention().
func (c *Channel) Start(ctx context.Context) error {
c.MarkStarting("Registering bot")
tid := c.TenantID()
if tid == uuid.Nil {
c.MarkFailed("Missing tenant", "channel instance has no tenant_id", channels.ChannelFailureKindConfig, false)
return errors.New("bitrix24: missing tenant_id (InstanceLoader must call SetTenantID)")
}
p, err := c.router.ResolveOrLoadPortal(ctx, tid, c.cfg.Portal)
if err != nil {
c.MarkFailed("Portal not found", err.Error(), channels.ChannelFailureKindConfig, false)
return err
}
if !p.Installed() {
msg := fmt.Sprintf("Portal %q not installed — visit /bitrix24/install to authorize before starting the channel.", c.cfg.Portal)
c.MarkFailed("Portal not installed", msg, channels.ChannelFailureKindAuth, false)
return fmt.Errorf("bitrix24 portal %q not installed", c.cfg.Portal)
}
c.router.RegisterPortal(p)
c.router.EnsurePortalRunning(ctx, p)
c.startMu.Lock()
c.portal = p
c.client = p.Client()
c.startMu.Unlock()
botID, err := c.registerBot(ctx)
if err != nil {
c.MarkFailed("imbot.register failed", err.Error(), classifyStartupErr(err), true)
return err
}
if err := p.RecordRegisteredBot(ctx, c.cfg.BotCode, botID); err != nil {
slog.Warn("bitrix24: failed to persist bot_id",
"tenant", tid, "portal", c.cfg.Portal, "bot_code", c.cfg.BotCode,
"bot_id", botID, "err", err)
}
c.startMu.Lock()
c.botID = botID
c.startMu.Unlock()
c.router.RegisterBot(botID, c)
// MCP lazy-provisioner wiring — safe to ignore errors; initMCPProvisioner
// already logs warnings and leaves fields zero when provisioning can't be
// enabled, which is a non-fatal "channel works, just no MCP" state.
if err := c.initMCPProvisioner(ctx); err != nil {
slog.Warn("bitrix24: MCP provisioner init returned error (non-fatal)",
"name", c.Name(), "err", err)
}
c.SetRunning(true)
c.MarkHealthy("Connected")
slog.Info("bitrix24 channel started",
"name", c.Name(), "tenant", tid, "portal", c.cfg.Portal, "bot_id", botID)
return nil
}
// Destroy releases external Bitrix24 resources (the imbot.register'd bot)
// and then calls Stop() for local cleanup. Called by the delete handler
// BEFORE the channel_instance row is removed from DB so the bot doesn't
// linger as a zombie on the Bitrix24 portal.
//
// Best-effort: Bitrix-side or persist failures are logged but do not return
// an error. Blocking the DB delete on a permanently-dead portal would leave
// the row stuck forever; the operator can audit the warn log if cleanup
// needs to be done manually.
//
// Safe when Start() never completed — botID == 0 or portal == nil short-
// circuit the network/persist work and the call falls through to Stop()
// for whatever local state was set up.
//
// Idempotent: a second Destroy after a successful one will see botID == 0
// (Stop clears it), trigger isBotNotFoundError on the unregister, and
// no-op on ForgetRegisteredBot.
func (c *Channel) Destroy(ctx context.Context) error {
c.startMu.Lock()
botID := c.botID
portal := c.portal
code := c.cfg.BotCode
c.startMu.Unlock()
// Step 1: tell Bitrix24 the bot is gone.
if botID > 0 {
if err := c.unregisterBot(ctx, botID); err != nil {
slog.Warn("bitrix24 destroy: imbot.unregister failed — proceeding with local cleanup",
"portal", c.cfg.Portal, "bot_code", code, "bot_id", botID, "err", err)
}
}
// Step 2: clear the persisted bot_code → bot_id mapping so a future
// channel with the same bot_code re-registers fresh instead of trying
// to reuse a (now-deleted) bot_id.
if portal != nil && code != "" {
if err := portal.ForgetRegisteredBot(ctx, code); err != nil {
slog.Warn("bitrix24 destroy: ForgetRegisteredBot failed",
"portal", c.cfg.Portal, "bot_code", code, "err", err)
}
}
// Step 3: local teardown. Stop() also removes the bot from the
// Router's dispatch map (router.UnregisterBot) — no extra call needed.
return c.Stop(ctx)
}
// Stop unwires the channel from the Router and closes the stop channel.
// Does NOT tear down the portal — other bots on the same portal keep it
// alive, and the Router's EnsurePortalRunning is idempotent on next Start().
// Safe to call multiple times.
func (c *Channel) Stop(ctx context.Context) error {
c.startMu.Lock()
botID := c.botID
c.botID = 0
c.startMu.Unlock()
if botID > 0 {
c.router.UnregisterBot(botID)
}
c.SetRunning(false)
c.MarkStopped("")
c.stopOnce.Do(func() { close(c.stopCh) })
return nil
}
// WebhookHandler implements channels.WebhookChannel so the gateway can mount
// the shared Router onto the main HTTP mux. Only the first Bitrix24 channel
// wins the claim — every other channel returns ("", nil) and the gateway
// skips mounting. All portals share /bitrix24/install and /bitrix24/events.
func (c *Channel) WebhookHandler() (string, http.Handler) {
return c.router.ClaimWebhookRoute()
}
// Router returns the shared Router instance. Exported for tests that want
// to assert Register / Unregister side-effects.
func (c *Channel) Router() *Router { return c.router }
// Portal returns the resolved portal (nil before Start()). Exported for
// tests that need to inspect the cached portal without reaching through
// the router.
func (c *Channel) Portal() *Portal {
c.startMu.Lock()
defer c.startMu.Unlock()
return c.portal
}
// Client returns the REST client bound to the portal (nil before Start()).
func (c *Channel) Client() *Client {
c.startMu.Lock()
defer c.startMu.Unlock()
return c.client
}
// classifyStartupErr maps registration errors into a health failure kind.
// APIError auth-family codes → Auth; transport errors → Network; everything
// else (config, quota, unknown) → Config so operators see an actionable
// "check instance config" message instead of a generic "unknown".
func classifyStartupErr(err error) channels.ChannelFailureKind {
if err == nil {
return channels.ChannelFailureKindUnknown
}
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case "expired_token", "invalid_token", "NO_AUTH_FOUND", "PORTAL_DELETED":
return channels.ChannelFailureKindAuth
}
}
return channels.ChannelFailureKindConfig
}