Files
DangTinh311 af43c952ed feat(bitrix24): agent activity indicator via InputAction.notify [B24:2794]
Show an ephemeral "agent is working" indicator (thinking/searching/
generating/analyzing…) in Bitrix24 chat while the agent processes, so
users on this non-streaming channel aren't left staring at silence
until the final reply. Send behavior is unchanged; no LLM call, no DB.

Generic layer (internal/channels):
- New optional ActivityIndicatorChannel interface.
- HandleAgentEvent routes run.started->THINKING, tool.call->mapped
  status, tool.result->ANALYZING; static tool->status mapping.
- Conditional heartbeat ticker fills LLM-inference gaps (re-sends only
  when idle), 5s per-run throttle caps call rate; ticker stopped on
  terminal events AND in UnregisterRun (safety net for missed terminals).

Bitrix24 (internal/channels/bitrix24):
- OnActivityEvent calls imbot.v2.Chat.InputAction.notify best-effort,
  drop-on-limit (raw Client.Call, no retry) so cosmetic notifies never
  steal leaky-bucket capacity from real message sends.
- Per-channel activity_indicator toggle (default on).

Web UI + docs: dashboard toggle, i18n en/vi/zh, channels doc section.
Tests: 26 unit tests incl. UnregisterRun-stops-ticker regression.
2026-07-14 15:28:14 +07:00

314 lines
14 KiB
Go

package bitrix24
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// bitrixCreds maps the credentials JSON from channel_instances.credentials.
//
// Bitrix24 keeps portal-level OAuth (client_id / client_secret / tokens) on the
// `bitrix_portals` row — not here. A channel_instance is a thin pointer into
// that portal plus per-bot config, so creds is currently empty. Kept as an
// explicit struct to reserve the shape for future bot-local secrets (e.g. a
// per-bot HMAC) without breaking stored rows.
type bitrixCreds struct{}
// bitrixInstanceConfig maps the non-secret config JSONB from channel_instances.config.
//
// Portal + BotCode + BotName are required. Everything else is optional with
// sensible defaults applied in the factory. Fields are grouped to match the
// Phase 03 plan — resource link first, then policies, rendering, stream,
// misc, and a per-instance PublicURL override (used for the webhook URLs
// sent to imbot.register).
type bitrixInstanceConfig struct {
// Resource link (required)
Portal string `json:"portal"` // bitrix_portals.name scoped by tenant_id
BotCode string `json:"bot_code"` // stable key passed to imbot.register / LookupRegisteredBot
BotName string `json:"bot_name"` // display name
BotAvatar string `json:"bot_avatar,omitempty"` // optional URL; factory resolves and base64-encodes at Start()
// BotType — forwarded verbatim to imbot.register TYPE param.
//
// "B" = standard chatbot (default; matches Bitrix24 docs default).
// Nhân viên nội bộ; sees DMs always, sees group messages only
// when @mentioned. Pairs with tenant_users via ContactCollector
// and receives per-user MCP credentials (Phase C provisioner).
// Recommended: dm_policy=pairing, group_policy=open.
//
// "O" = Open Channel bot. Khách hàng từ widget external chat (imol|…).
// Admin phải gắn bot vào Open Channel queue qua UI Bitrix sau
// register.
// Recommended: dm_policy=open, group_policy=open (khách không
// pair được). MCP credentials bị skip cho bot này — nếu cần
// MCP tools, admin phải setup shared credential (Phase E tương
// lai). Factory does NOT auto-relax dm_policy — admin phải
// explicit set open, hoặc bot sẽ im lặng với khách (logs chỉ
// rõ "pairing needed").
//
// Anything else rejected at factory load.
BotType string `json:"bot_type,omitempty"`
// Policies
AllowFrom []string `json:"allow_from,omitempty"`
GroupAllowFrom []string `json:"group_allow_from,omitempty"`
DMPolicy string `json:"dm_policy,omitempty"`
GroupPolicy string `json:"group_policy,omitempty"`
DeptAllowFrom []int `json:"dept_allow_from,omitempty"` // Phase 04
RequireMention *bool `json:"require_mention,omitempty"`
// Rendering
TextChunkLimit int `json:"text_chunk_limit,omitempty"` // default 4000
MediaMaxMB int `json:"media_max_mb,omitempty"` // default 20
// Stream / reactions (Phase 05, 07)
Streaming *bool `json:"streaming,omitempty"`
ReactionLevel string `json:"reaction_level,omitempty"` // off|minimal|full
// ActivityIndicator toggles the ephemeral "agent is working" indicator
// (imbot.v2.Chat.InputAction.notify) shown while the agent thinks / runs
// tools. nil = enabled (default on). Best-effort, dropped on rate limit.
ActivityIndicator *bool `json:"activity_indicator,omitempty"`
// Misc
HistoryLimit int `json:"history_limit,omitempty"`
BlockReply *bool `json:"block_reply,omitempty"`
ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"`
// Webhook endpoint override. Bitrix24 imbot.register requires absolute
// URLs for EVENT_MESSAGE_ADD etc. GoClaw has no global GOCLAW_PUBLIC_URL
// setting — we let operators configure it per-instance so multiple
// gateways fronting different ingresses can co-exist.
//
// When empty Start() warns and still registers with /bitrix24/events as
// a relative path; the admin has to fix the config before webhooks flow.
PublicURL string `json:"public_url,omitempty"`
// Optional MCP lazy-provisioning binding (Phase C).
//
// When MCPServerID (or the legacy MCPServerName+MCPBaseURL pair) is set
// AND the factory variant that accepts a MCPServerStore is used
// (FactoryWithPortalStoreAndMCP), the channel tries to mint per-user MCP
// credentials on first message:
//
// 1. Channel receives message from user U with OAuth tokens in event.
// 2. Channel looks up MCPUserCredentials(serverID, senderID). Present
// → skip. Absent → POST /api/auto-onboard on the resolved base URL
// forwarding U's OAuth tokens. MCP server authenticates the call
// via Bitrix `profile` against the supplied access_token — no
// shared admin secret required — and responds with a per-user
// api_key, which channel stores via SetUserCredentials (the
// "Bitrix24 OAuth → existing mcp_user_credentials bridge" —
// Bitrix-specific glue, not a generic MCP architecture pattern).
// 3. Agent pipeline downstream reads those creds naturally.
//
// Best-effort: if any step fails, channel logs a warning and forwards
// the message anyway — agent loop will just see no creds and skip
// that MCP server's tools. User gets a response, albeit without MCP.
//
// Skipped entirely for Open Channel bots (bot_type=O) — transient
// customers don't map to tenant_users.
//
// MCPServerID is the preferred wiring since v3.15: admin picks an MCP
// server from the dashboard dropdown (filtered to
// require_user_credentials=true rows) and the channel resolves both
// name and URL from the mcp_servers row.
MCPServerID string `json:"mcp_server_id,omitempty"` // mcp_servers.id (UUID)
// Deprecated: MCPServerName + MCPBaseURL are kept for backward-compat
// with configs written before the MCPServerID rollout. If MCPServerID
// is empty and both legacy fields are set the channel falls back to
// GetServerByName and uses MCPBaseURL as the base URL. New configs
// should only set MCPServerID; Phase 5 of the refactor migrates every
// existing row and drops these fields.
MCPServerName string `json:"mcp_server_name,omitempty"` // legacy — mcp_servers.name
MCPBaseURL string `json:"mcp_base_url,omitempty"` // legacy — HTTPS root
}
// Factory is the base channels.ChannelFactory signature. Bitrix24 requires a
// BitrixPortalStore, so this returns an explanatory error — the gateway must
// register FactoryWithPortalStore() instead. Kept to satisfy anyone who
// looks for Factory by convention across channel packages.
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
return nil, errors.New("bitrix24: use FactoryWithPortalStore in gateway wiring — portal store is required")
}
// FactoryWithPortalStore returns a ChannelFactory closed over the portal
// store + AES encryption key. Gateway wiring calls this once at startup and
// hands the returned closure to the InstanceLoader.
//
// This variant leaves MCP lazy-provisioning disabled. Use
// FactoryWithPortalStoreAndMCP when the gateway is configured with an
// MCPServerStore and you want channels to auto-onboard per-user MCP
// credentials on first message.
//
// Responsibilities of the closure:
// - Unmarshal and validate cfg (required fields + defaults).
// - Resolve the shared Router singleton (creates it on first invocation).
// - Build the Channel with pairing + allow lists + mention policy wired up.
//
// The closure does NOT resolve/load the Portal or talk to Bitrix24 — that
// work is deferred to Channel.Start() so a bad row doesn't crash boot.
func FactoryWithPortalStore(portalStore store.BitrixPortalStore, encKey string) channels.ChannelFactory {
return FactoryWithPortalStoreAndMCP(portalStore, nil, encKey)
}
// FactoryWithPortalStoreAndMCP is the MCP-aware variant of
// FactoryWithPortalStore. When mcpStore is non-nil AND the instance config
// has both mcp_server_name + mcp_base_url set, the channel enables lazy
// provisioning: on first message from each user, it POSTs to
// {mcp_base_url}/api/auto-onboard to mint per-user MCP credentials,
// which downstream agent pipeline reads naturally. The MCP server
// authenticates each call via the caller-supplied Bitrix access_token —
// no shared admin secret is required (Bitrix24 OAuth → existing
// mcp_user_credentials bridge; Bitrix-specific glue, not a generic
// MCP architecture pattern).
//
// Pass nil mcpStore to disable provisioning even if config has the fields.
// Half-config (only one of mcp_server_name / mcp_base_url set) fails fast.
func FactoryWithPortalStoreAndMCP(portalStore store.BitrixPortalStore, mcpStore store.MCPServerStore, encKey string) channels.ChannelFactory {
return func(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
if portalStore == nil {
return nil, errors.New("bitrix24 factory: nil BitrixPortalStore (gateway wiring bug)")
}
// creds is optional for Bitrix24 — the bot has no private secrets of
// its own. Decode anyway so a malformed blob surfaces as a boot error.
if len(creds) > 0 {
var c bitrixCreds
if err := json.Unmarshal(creds, &c); err != nil {
return nil, fmt.Errorf("decode bitrix24 credentials: %w", err)
}
}
var ic bitrixInstanceConfig
if len(cfg) > 0 {
if err := json.Unmarshal(cfg, &ic); err != nil {
return nil, fmt.Errorf("decode bitrix24 config: %w", err)
}
}
if ic.Portal == "" || ic.BotCode == "" || ic.BotName == "" {
return nil, errors.New("bitrix24 channel requires portal, bot_code, and bot_name")
}
applyConfigDefaults(&ic)
// Validate bot_type AFTER defaults so empty → "B" passes the check.
// Keep the set small and explicit — other values (e.g. "H" hidden
// helper) may appear in Bitrix docs but we haven't verified the
// event semantics, so refusing unknown types avoids shipping a bot
// that silently receives no events.
switch ic.BotType {
case "B", "O":
// ok
default:
return nil, fmt.Errorf("bitrix24: invalid bot_type %q (must be \"B\" or \"O\")", ic.BotType)
}
// MCP provisioning config uses mcp_server_id (preferred) or the
// legacy mcp_server_name + mcp_base_url pair. Catching half-config
// here prevents a silent "provisioning disabled but you meant to
// enable it" surprise when someone only fills one of the legacy
// fields. The new mcp_server_id path is self-contained (server
// name + URL both derived from the mcp_servers row at Start()).
hasServerID := strings.TrimSpace(ic.MCPServerID) != ""
hasServerName := strings.TrimSpace(ic.MCPServerName) != ""
hasBaseURL := strings.TrimSpace(ic.MCPBaseURL) != ""
if !hasServerID && hasServerName != hasBaseURL {
return nil, errors.New("bitrix24: mcp_server_name and mcp_base_url must both be set, or both empty (or use mcp_server_id)")
}
// Shared process-wide router. InitWebhookRouter uses sync.Once so the
// first caller wins; later callers get the same pointer. Any nil-store
// mistake would have panicked on the first call anyway — returning
// the error keeps boot diagnostics clean.
router, err := InitWebhookRouter(portalStore, encKey, RouterConfig{})
if err != nil {
return nil, fmt.Errorf("bitrix24 router init: %w", err)
}
ch := &Channel{
BaseChannel: channels.NewBaseChannel(name, msgBus, mergeAllowLists(ic.AllowFrom, ic.GroupAllowFrom)),
cfg: ic,
portalStore: portalStore,
encKey: encKey,
router: router,
stopCh: make(chan struct{}),
mcpStore: mcpStore, // may be nil; provisionIfMissing treats nil as disabled
}
ch.SetType(channels.TypeBitrix24)
ch.SetName(name)
ch.SetPairingService(pairingSvc)
// applyConfigDefaults guarantees RequireMention is non-nil, but guard
// the deref anyway so a future refactor of the defaults can't turn this
// into a boot-time nil-pointer panic.
requireMention := true
if ic.RequireMention != nil {
requireMention = *ic.RequireMention
}
ch.SetRequireMention(requireMention)
ch.SetHistoryLimit(ic.HistoryLimit)
ch.ValidatePolicy(ic.DMPolicy, ic.GroupPolicy)
return ch, nil
}
}
// applyConfigDefaults fills in the per-instance knobs a well-behaved portal
// would have been given at onboard time. Pulled into its own function so
// tests can exercise the default surface directly.
func applyConfigDefaults(ic *bitrixInstanceConfig) {
// bot_type default matches Bitrix24 imbot.register TYPE default.
// Keep this BEFORE the policy defaults so future logic can branch on
// bot_type if needed — currently it does not (see type docstring).
if ic.BotType == "" {
ic.BotType = "B"
}
if ic.DMPolicy == "" {
ic.DMPolicy = string(channels.DMPolicyPairing)
}
if ic.GroupPolicy == "" {
ic.GroupPolicy = string(channels.GroupPolicyOpen)
}
if ic.TextChunkLimit <= 0 {
ic.TextChunkLimit = 4000
}
if ic.MediaMaxMB <= 0 {
ic.MediaMaxMB = 20
}
if ic.ReactionLevel == "" {
ic.ReactionLevel = "minimal"
}
if ic.RequireMention == nil {
t := true
ic.RequireMention = &t
}
if ic.Streaming == nil {
t := true
ic.Streaming = &t
}
}
// mergeAllowLists concatenates DM and group allow-lists into a single slice
// that BaseChannel.IsAllowed can check against. Order preserved; empty input
// slices skipped so the resulting slice stays nil when nothing is configured
// (BaseChannel treats a nil allow-list as "open").
func mergeAllowLists(dm, group []string) []string {
if len(dm) == 0 && len(group) == 0 {
return nil
}
out := make([]string, 0, len(dm)+len(group))
out = append(out, dm...)
out = append(out, group...)
return out
}