Files
goclaw/internal/channels/bitrix24/provisioner.go
T
bd5adc61c8 feat(bitrix24): imbot.v2 migration, 2-way media, openline sender-tag echo, and hardening (#1236)
* refactor(bitrix24): rename "Path B" framing to maintainer-specified naming [B24:2794]

Per maintainer hard rule #10 (no generic "Path A/B" framing) from PR #1061
review. The Bitrix24 MCP auto-onboard flow is Bitrix-specific glue
("Bitrix24 OAuth -> existing mcp_user_credentials bridge"), NOT a generic
MCP architecture pattern.

Naming convention applied consistently:
- First mention per file: full "Bitrix24 OAuth -> existing
  mcp_user_credentials bridge" (matches maintainer comment verbatim).
- Subsequent mentions in same file: shortened "mcp_user_credentials bridge".
- Test/log context referencing literal endpoint /api/auto-onboard: keep
  "auto-onboard" reference (it's the actual API endpoint name).

Changes are documentation-only:
- Rename in code comments + test descriptions + plan docs.
- Clarify framing in mcp_client.go + provisioner.go doc comments to
  emphasize Bitrix-specific glue (not generic MCP infra).
- Reuse existing mcp_user_credentials table + MCPServerStore methods
  (no schema / store / abstraction change).

Files:
- cmd/gateway.go (factory registration doc)
- internal/channels/bitrix24/{channel,factory,mcp_client,provisioner}.go
- internal/channels/bitrix24/{mcp_client,provisioner}_test.go
- plan/goclaw-mcp-integration.md (21 occurrences)

Verified: go build + MCP-related tests pass (TestProvision*,
TestInitMCPProvisioner*, TestMCPClient*).

Phase 1 of Path C execution per
plans/reports/decision-log-260519-1555-bitrix24-pr-fork-decision.md.

* fix: confine outbound media paths to agent workspace [B24:2794]

Tool MEDIA:<path> output reached channel file-upload sinks (Bitrix
imbot.v2.File.upload, Telegram sendDocument, etc.) verbatim via
parseMediaResult, with no workspace-boundary check. A malicious or buggy
tool emitting MEDIA:/etc/passwd could exfiltrate arbitrary files to chat.

Extract the EvalSymlinks+Rel containment from extractMediaFromContent into
a shared confineToWorkspace helper and apply it at the parseMediaResult
sink in processToolResult. Fixing at the source/egress boundary protects
every channel at once rather than per-channel. Paths that escape the
workspace are dropped and logged (security.media_path_rejected).

Add TestConfineToWorkspace (boundary unit) and
TestParseMediaResultConfinedToWorkspace (sink regression for H2).

* feat(bitrix24): support inbound + outbound media via imbot.v2 File API [B24:2794]

Bitrix24 channel was text-only; attachments were parsed but dropped.
- Inbound: download chat files via imbot.v2.File.download (one-time URL),
  forward to the agent with MIME preserved (internal/channels/bitrix24/download.go).
- Outbound: upload agent media to the chat via imbot.v2.File.upload
  (internal/channels/bitrix24/send_media.go).
- Add BaseChannel.HandleMessageMedia to preserve MIME/filename through the bus.
- Per-channel media_max_mb cap (default 20) applies to both directions.

Tests: 92 pass (internal/channels/bitrix24 + internal/channels), go vet clean (PG + sqliteonly).

* refactor(bitrix24): migrate messaging/bot-list/unregister to imbot v2 API [B24:2794]

Move outbound REST calls to the imbot v2 family (keeps register on v1):
- imbot.message.add -> imbot.v2.Chat.Message.send (fields.message shape, live-verified)
- imbot.bot.list (+ legacy imbot.list fallback) -> imbot.v2.Bot.list; add botListRows
  to normalize the v2 {bots:[...]} envelope, legacy array, and id-keyed map forms
- imbot.unregister -> imbot.v2.Bot.unregister

Bot registration stays on v1 imbot.register: v2 imbot.v2.Bot.register changes the
event-delivery model (per-event handler URLs -> eventMode), which would require
rewriting the inbound event parser. No user-facing behavior change.

Tests: bitrix24 package green; go vet ./... clean.

* feat(bitrix24): route whisper via v1 SKIP_CONNECTOR + add v2 replyId [B24:2794]

Bot was leaking HiddenMessage (whisper) replies to the external Zalo
connector because every outbound call went through imbot.v2.Chat.Message.send,
which has no equivalent of the v1 SKIP_CONNECTOR flag. Branch the outbound
path on inbound visibility:

  whisper → imbot.message.add + SKIP_CONNECTOR=Y  (v1, send_v1.go)
  public  → imbot.v2.Chat.Message.send + fields.replyId  (v2, send_v2.go)

Pipeline:
  events.go        parse data[PARAMS][PARAMS][COMPONENT_ID]=HiddenMessage
                   into EventParams.IsHiddenMessage (form + JSON variants)
  handle.go        set bitrix_visibility on InboundMessage.Metadata
  consumer         forward visibility + message_id into OutboundMessage
  send.go          resolveSendOptions + sendChunk dispatcher +
                   shared callWithRateLimitRetry helper
  metadata_keys.go single source of truth for the keys + values

Defaults preserve pre-refactor behaviour: callers that don't populate
bitrix_visibility still go through v2 public, and replyId is omitted
unless a numeric bitrix_message_id arrives in metadata.

Tests:
  TestParseEvent_FormURLEncoded_IsHiddenMessage  (3 cases)
  TestParseEvent_JSON_IsHiddenMessage             (3 cases)
  TestResolveSendOptions                          (8 cases)
  TestSend_BranchesOnVisibility                   (4 cases)

* feat(bitrix24): openline sender-tag echo on replies [B24:2794]

Openline sender-tag echo (this change):
- Capture the connector sender tag ("[name #id]:" or "[name] #id:") from
  inbound openline group messages, strip it from the body the agent sees,
  and re-prepend the canonical "[name] #id:" form to the reply so the Open
  Channel connector routes the answer back to the right external user.
- New sender_prefix.go helper (+ test) accepts both inbound layouts and
  emits one canonical form; scoped to messages carrying the tag, so plain
  chats are unaffected.
- metadata_keys.go: MetaKeySenderPrefix; handle.go capture/strip/stash;
  gateway_consumer_normal.go forwards the key; send.go prepends it on the
  first chunk before chunking.

Bundled bitrix24 channel-core work already on this branch:
- handle.go: @mention is the sole trigger for both staff and connector
  customers; unmentioned traffic is dropped (was: drop all connector msgs).
- isGroupMessageType: treat SONET_GROUP "B" as a group.
- handle_test.go, mcp_client_test.go: cover the above.

* feat(bitrix24): accept colon-less openline sender tag, echo [name] #id [B24:2794]

The Open Channel connector dropped the trailing colon from its sender tag:
inbound now arrives as "[Name] #id <msg>" (was "[Name] #id: <msg>"). The
id-bearing patterns required the colon, so the tag fell through to the
name-only branch and the reply echoed "[Name]" — dropping the #id the
connector needs to route the answer back.

- sender_prefix.go: make the trailing ":" optional on both id layouts
  ([name #id] / [name] #id, with or without colon) and echo the canonical
  "[name] #id" (no colon) to match the connector's current format. Bare
  "[name]" (no id) still echoes "[name]" for Open Channel only.
- handle.go: gate the bare name-only layout to Open Channel (isOpenChannel)
  so ordinary group chats starting with "[x] ..." are left untouched.
- sender_prefix_test.go: cover colon/no-colon x id-inside/id-outside, the
  name-only openline case, and the non-openline no-op.

* fix: security and robustness fixes from the bitrix24 channel review [B24:2794]

- download.go: block redirect-based SSRF on inbound media. CheckRedirect
  re-validates each hop (http(s) only, reject private/loopback/link-local
  hosts, cap hops); the initial portal-domain pin is no longer bypassable
  via a 3xx to an internal service. Public-host redirects still allowed.
- handle.go: extract/echo the openline sender tag only for Open Channel
  sessions (was: any group chat), removing bogus prefixes in CRM group
  chats and narrowing the forged-tag misroute surface.
- loop_tools.go + loop_media.go: confine result.Media to the agent / team /
  tenant-allowed roots (new confineToAnyRoot) before a channel uploads it,
  so a prompt-injected out-of-workspace path (e.g. /etc/passwd) cannot
  exfiltrate, while legitimate cross-workspace media (team files, delegatee
  output) still flows.
- send_media.go: bounded outbound read via io.LimitReader replaces the
  os.Stat + os.ReadFile pair, closing the TOCTOU size-cap bypass; cap a
  single message's outbound attachments at 10 (mirrors inbound).
- register.go: paginate imbot.v2.Bot.list (limit/offset + hasNextPage,
  capped at 40 pages) so verify/lookup see bots past the first 50.
- mcp_client.go: redact access_token / refresh_token / client_secret from an
  echoed MCP error body before it is logged or returned (+ test).

* fix(security): validate resolved dial IP on Bitrix media redirects [B24:2794]

The inbound media download redirect guard only string-checked the redirect
hostname (isPrivateOrLoopback on req.URL.Hostname()), so a redirect to a public
hostname that resolves to 127.0.0.1 / 169.254.169.254 / an RFC1918 address — or a
DNS-rebinding swap between check and dial — still passed the guard and the client
would connect. Reported in PR review.

Add security.NewRedirectFollowingSafeClient: it follows redirects but validates
the RESOLVED destination IP of every hop at dial time via net.Dialer.Control,
reusing the existing blocked-CIDR list. The IP it checks is the IP actually
dialed, so both redirect-to-internal and DNS rebinding are refused, while
legitimate public CDN redirects still succeed. download.go now uses it instead of
the hostname-string guard.

Tests: deterministic dial-control table (loopback / link-local / private /
multicast / unspecified / public, v4 + v6), malformed/non-IP addr, test bypass,
loopback-dial-blocked client wiring, and redirect cap + scheme checks.

* feat(bitrix24): per-participant Zalo openline identity from 3-token sender tag [B24:2794]

Parse the connector's "[Name] #uid #msgId" sender tag so each external
customer in a shared Open Channel group gets its own contact + USER.md
instead of collapsing onto the connector proxy id. Identity minting is
gated on IS_CONNECTOR=Y to reject operator forged tags. Echo back the
msgId only ("#msgId") on replies; keep the legacy single-number and
name-only layouts unchanged. Zero DB migration.

- sender_prefix.go: parseOpenlineSenderTag() classifies 3-token / legacy / name-only
- handle.go: synthetic senderID "openlines:{instance}:{chat}:{uid}" + participant_user_id metadata, gated on FromIsConnector
- gateway_consumer_normal.go: deriveGroupUserID() routes participant -> per-person scope, group fallback otherwise
- send.go: buildAddressMention numeric-id guard so synthetic ids don't emit invalid [USER=...] BBCode
- MetaKeyMessageID kept as Bitrix MESSAGE_ID (drives v2 fields.replyId); connector msgId surfaced only via echo prefix

---------

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
Co-authored-by: Chinh Dang <chinhdang@192.168.68.104>
2026-06-22 14:23:34 +07:00

390 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package bitrix24
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mcpProvisionDebounceTTL is how long we suppress repeat auto-onboard calls
// for the same (serverID, userID) pair after a successful OR failed attempt.
// 60s is long enough to swallow Bitrix24 webhook retries (which can spam 3–5
// events in a burst on transient 5xx) but short enough that a recovered MCP
// server is usable again within one minute.
const mcpProvisionDebounceTTL = 60 * time.Second
// mcpCredsRefreshWindow is the lead time at which we proactively refresh user
// credentials. If the cached BITRIX_EXPIRES_AT is within this window of now,
// the next webhook event triggers an auto-onboard refresh — preventing the
// upcoming tool call from racing a stale token.
//
// Sized 5x typical Bitrix REST round-trip latency. The Bitrix-issued
// access_token TTL is 1h (3600s), so 5 min = 8% of lifetime — refresh load
// stays manageable on busy portals.
const mcpCredsRefreshWindow = 5 * time.Minute
// mcpDebounceKey keys the in-memory rate-limit map. ServerID + UserID is
// sufficient — different Bitrix portals route to different channel instances
// with different debounce maps, so cross-portal collision isn't possible.
type mcpDebounceKey struct {
serverID uuid.UUID
userID string
}
// Sentinel errors. Callers log-and-continue on any of these — none are fatal
// to message processing. Kept as package-level vars (not fmt.Errorf literals)
// so tests can errors.Is() against them without string matching.
var (
// ErrProvisionSkippedOpenChannel means the channel is a Bitrix24 Open
// Channel bot (TYPE "O"). Auto-onboard is disabled because transient
// customers don't have tenant_users rows — shared-credential support
// is deferred to Phase E.
ErrProvisionSkippedOpenChannel = errors.New("bitrix24 mcp: provisioning skipped for Open Channel bot")
// ErrProvisionDisabled means the channel was built without MCP wiring
// (nil MCPServerStore, empty mcp_server_name/mcp_base_url, server row
// not found). Not an error — the channel simply operates without MCP
// credentials for its users. Agent loop already handles "no creds →
// skip this server" gracefully.
ErrProvisionDisabled = errors.New("bitrix24 mcp: provisioning disabled")
// ErrProvisionDebounced means an auto-onboard for this (server, user)
// pair ran within the last mcpProvisionDebounceTTL. Caller should NOT
// retry; the previous attempt's outcome (success or failure) is still
// authoritative.
ErrProvisionDebounced = errors.New("bitrix24 mcp: provisioning debounced")
)
// initMCPProvisioner wires the lazy-provisioning plumbing at Start() time.
// Safe to call even when provisioning is disabled — in that case it just
// returns nil without touching mcpStore.
//
// Three things have to line up before provisioner can run:
// 1. Factory was called with a non-nil MCPServerStore.
// 2. Instance config has both mcp_server_name and mcp_base_url set.
// 3. The mcp_servers row exists (looked up by name).
//
// Bitrix24 OAuth → existing mcp_user_credentials bridge (Bitrix-specific
// glue — see mcp_client.go doc): the MCP server authenticates each
// /api/auto-onboard call via the caller-supplied Bitrix access_token by
// calling Bitrix `profile` and matching the token-owner ID against
// bitrix_user_id — no shared admin secret is required, so multi-tenant
// isolation holds naturally (each portal's users authenticate with their
// own per-portal OAuth tokens).
//
// Any single missing piece leaves the channel usable but with
// provisioning off — that's the operator's "staged rollout" path: install
// the channel first, layer MCP on later.
//
// Called under startMu (held by Channel.Start()).
func (c *Channel) initMCPProvisioner(ctx context.Context) error {
// Fast exits for the explicitly-disabled configurations. We don't log
// at Info level here because operators who never want MCP shouldn't
// see recurring startup noise; Debug level surfaces it for troubleshooting.
if c.mcpStore == nil {
slog.Debug("bitrix24 mcp: provisioning disabled (no MCPServerStore wired at factory)",
"channel", c.Name())
return nil
}
if strings.TrimSpace(c.cfg.MCPServerName) == "" || strings.TrimSpace(c.cfg.MCPBaseURL) == "" {
slog.Debug("bitrix24 mcp: provisioning disabled (mcp_server_name or mcp_base_url empty)",
"channel", c.Name())
return nil
}
// Resolve server name → UUID once at startup. If the server name is
// wrong or the row doesn't exist yet, log and disable provisioning —
// don't block channel startup. Admin can create the server + reload
// the channel later.
//
// PGMCPServerStore.GetServerByName scopes the lookup by tenant_id from
// context (multi-tenant isolation). Channel.Start receives ctx from the
// instance loader without that scope set — wrap it explicitly with the
// channel's own tenant id so the lookup matches the row a tenant admin
// created via `bitrix-portal create` / dashboard.
lookupCtx := ctx
if tid := c.TenantID(); tid != uuid.Nil {
lookupCtx = store.WithTenantID(ctx, tid)
}
server, err := c.mcpStore.GetServerByName(lookupCtx, c.cfg.MCPServerName)
if err != nil || server == nil {
slog.Warn("bitrix24 mcp: provisioning disabled — server not found",
"channel", c.Name(), "mcp_server_name", c.cfg.MCPServerName, "err", err)
return nil
}
c.mcpServerID = server.ID
c.mcpClient = newMCPClient(c.cfg.MCPBaseURL, 10*time.Second)
c.mcpDebounce = make(map[mcpDebounceKey]time.Time)
slog.Info("bitrix24 mcp: provisioning enabled",
"channel", c.Name(),
"mcp_server", c.cfg.MCPServerName,
"mcp_server_id", server.ID)
return nil
}
// provisionIfMissing mints per-user MCP credentials on first sight of a user
// IF all prerequisites hold (provisioning enabled + bot is internal + no
// existing creds + not debounced). Best-effort: every failure mode returns
// a typed error but NEVER blocks the caller — handleMessage proceeds to
// HandleMessage regardless, so user messages always get processed.
//
// Called from handleMessage after EnsureContact, before HandleMessage.
func (c *Channel) provisionIfMissing(ctx context.Context, userID string, auth EventAuth) error {
// Skip #1: Open Channel bot. No per-user credentials for transient
// customers — see type docstring.
if c.IsOpenChannelBot() {
slog.Debug("bitrix24 mcp: provision skip open channel", "channel", c.Name(), "user_id", userID)
return ErrProvisionSkippedOpenChannel
}
// Skip #2: provisioning disabled at startup. Channel operates without
// MCP — downstream agent loop sees no creds and skips MCP tools.
if c.mcpStore == nil || c.mcpClient == nil || c.mcpServerID == uuid.Nil {
slog.Debug("bitrix24 mcp: provision skip disabled", "channel", c.Name(), "user_id", userID)
return ErrProvisionDisabled
}
// Skip #3: already have creds AND token is far from expiry. Provisioner
// is primarily a LAZY-MINT path, but it also refreshes opportunistically:
//
// - Token expired → must refresh (loop-side 401 purge would otherwise
// leave the user stranded until next event)
// - Token expiring within mcpCredsRefreshWindow → refresh proactively
// so the upcoming tool call doesn't hit a freshly stale token.
// - Token warm (> refresh window remaining) → skip; reuse cached creds
// to avoid hammering mcp-bx-syn.
//
// The refresh window must be > the longest expected tool-call latency so
// proactive refresh lands before the call. 5 min is conservative given
// typical Bitrix REST round-trips (sub-second to a few seconds).
existing, err := c.mcpStore.GetUserCredentials(ctx, c.mcpServerID, userID)
if err == nil && existing != nil && existing.APIKey != "" {
expiresAtRaw := strings.TrimSpace(existing.Env["BITRIX_EXPIRES_AT"])
if expiresAtRaw == "" {
// Legacy creds without expiry metadata are STALE-unknown. mcp-bx-syn
// will reject when its stored access_token expires (1h TTL) → loop-side
// 401 purge fires, breaking the in-flight conversation. Refresh once
// to write BITRIX_EXPIRES_AT so subsequent events follow the warm-skip
// path. The "1 HTTP per first-event-after-deploy" cost self-heals
// after one refresh writes the meta column.
slog.Info("bitrix24 mcp: refreshing legacy credentials (no expiry meta)",
"channel", c.Name(), "user_id", userID)
// fall through to debounce + refresh below
} else {
if expiresAt, parseErr := time.Parse(time.RFC3339, expiresAtRaw); parseErr == nil {
now := time.Now().UTC()
timeLeft := expiresAt.Sub(now)
if timeLeft > mcpCredsRefreshWindow {
slog.Debug("bitrix24 mcp: provision skip warm credentials",
"channel", c.Name(), "user_id", userID, "expires_at", expiresAtRaw,
"time_left", timeLeft.String())
return nil
}
if timeLeft > 0 {
slog.Info("bitrix24 mcp: refreshing near-expiry user credentials",
"channel", c.Name(),
"user_id", userID,
"expires_at", expiresAtRaw,
"time_left", timeLeft.String())
} else {
slog.Info("bitrix24 mcp: refreshing expired user credentials",
"channel", c.Name(),
"user_id", userID,
"expired_at", expiresAtRaw)
}
}
}
}
// Skip #4: debounce. Bitrix24 retries webhooks aggressively on 5xx,
// so a failed auto-onboard can trigger 3–5 attempts per second
// without this guard. TTL = 60s covers the retry burst window and
// the typical "MCP server blip" recovery time.
if c.isMCPProvisionDebounced(c.mcpServerID, userID) {
slog.Warn("bitrix24 mcp: provision debounced", "channel", c.Name(), "user_id", userID)
return ErrProvisionDebounced
}
c.markMCPProvisionDebounced(c.mcpServerID, userID)
// OAuth tokens are plumbed through the webhook event's auth block —
// MCP server uses them to call Bitrix REST on behalf of this user.
// Missing tokens will be caught by mcpClient.autoOnboard validation,
// but surface them here with a clearer error so operators don't have
// to trace to mcp_client.go.
if auth.Domain == "" || auth.AccessToken == "" || auth.RefreshToken == "" {
return fmt.Errorf("bitrix24 mcp: incomplete auth block (domain/access_token/refresh_token required)")
}
resp, err := c.mcpClient.autoOnboard(ctx, autoOnboardRequest{
Domain: auth.Domain,
BitrixUserID: userID,
AccessToken: auth.AccessToken,
RefreshToken: auth.RefreshToken,
ExpiresIn: auth.ExpiresIn,
// DisplayName left empty — Bitrix webhook doesn't carry it; MCP
// server should enrich via user.get if it needs a label.
})
if err != nil {
slog.Warn("bitrix24 mcp: auto-onboard failed", "channel", c.Name(), "user_id", userID, "err", err)
return fmt.Errorf("bitrix24 mcp: auto-onboard failed: %w", err)
}
// Persist OAuth tokens alongside the minted API key so MCP server can
// re-authenticate on subsequent tool calls without a fresh onboard.
// Env map keys are plain strings (partner's MCPServerStore encrypts
// them transparently via encKey on write).
expiresAt := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
creds := store.MCPUserCredentials{
APIKey: resp.APIKey,
Env: map[string]string{
"BITRIX_DOMAIN": auth.Domain,
"BITRIX_ACCESS_TOKEN": auth.AccessToken,
"BITRIX_REFRESH_TOKEN": auth.RefreshToken,
"BITRIX_EXPIRES_AT": expiresAt,
},
}
if err := c.mcpStore.SetUserCredentials(ctx, c.mcpServerID, userID, creds); err != nil {
return fmt.Errorf("bitrix24 mcp: persist credentials: %w", err)
}
slog.Info("bitrix24 mcp: provisioned user credentials",
"channel", c.Name(), "user_id", userID, "mcp_server_id", c.mcpServerID,
"created", resp.Created)
return nil
}
// isMCPProvisionDebounced reports whether a provisioning attempt for
// (serverID, userID) ran within the last mcpProvisionDebounceTTL. Also
// opportunistically prunes expired entries so the map doesn't grow
// unbounded across long-lived channels.
func (c *Channel) isMCPProvisionDebounced(serverID uuid.UUID, userID string) bool {
c.mcpProvMu.Lock()
defer c.mcpProvMu.Unlock()
key := mcpDebounceKey{serverID: serverID, userID: userID}
if ts, ok := c.mcpDebounce[key]; ok {
if time.Since(ts) < mcpProvisionDebounceTTL {
return true
}
// Expired — delete so the map stays lean. Cheap to do here since
// we're already holding the lock for the check.
delete(c.mcpDebounce, key)
}
return false
}
func (c *Channel) markMCPProvisionDebounced(serverID uuid.UUID, userID string) {
c.mcpProvMu.Lock()
defer c.mcpProvMu.Unlock()
if c.mcpDebounce == nil {
// Defensive: initMCPProvisioner allocates this, but if some code
// path bypassed init (e.g. test that constructs Channel directly
// and then calls provisionIfMissing with provisioning enabled),
// a nil map write would panic. Allocate on demand instead.
c.mcpDebounce = make(map[mcpDebounceKey]time.Time)
}
c.mcpDebounce[mcpDebounceKey{serverID: serverID, userID: userID}] = time.Now()
}
// mcpUserNotifyDebounceTTL controls how often a single user can receive
// the "MCP is having issues" degradation notice. 5 minutes is a
// deliberate compromise:
// - long enough that a webhook retry burst (Bitrix fires 3–5 events in
// seconds on transient 5xx) doesn't spam the user with duplicates;
// - short enough that if the user sends a brand-new message 10 minutes
// later and MCP is still broken, they get re-informed rather than
// silently wondering why tools don't work.
//
// Not configurable intentionally — giving operators a knob here would
// invite "set it to 0 to test" → real user spam. If a specific deployment
// needs a different cadence, propose the change in a PR with rationale.
const mcpUserNotifyDebounceTTL = 5 * time.Minute
// mcpUserNotifyMessage is the user-facing text sent when provisionIfMissing
// hits an unexpected failure. Keep it short and non-alarming — users don't
// benefit from HTTP status codes or "MCP" jargon. The "contact admin" hint
// is concrete enough that an operator seeing the companion slog.Warn can
// match up user report ↔ log entry.
//
// Vietnamese-first because the current deployment is a Vietnamese team;
// i18n through internal/i18n.T(locale, ...) can replace this literal once
// the channel threads a locale through (right now it does not — HandleMessage
// accepts locale on inbound but the channel itself has no way to reply in
// the user's preferred language yet).
const mcpUserNotifyMessage = "⚠️ Hệ thống đang gặp vấn đề với MCP tools nội bộ. " +
"Một số chức năng có thể không hoạt động như mong đợi. " +
"Vui lòng liên hệ admin kỹ thuật để xem lại. " +
"Tôi vẫn có thể trả lời các câu hỏi cơ bản khác."
// notifyUserOfMCPIssueOnce sends a one-shot degradation notice to the
// Bitrix24 user via imbot.message.add when provisioning fails in an
// unexpected way. Debounced per-user with mcpUserNotifyDebounceTTL so
// webhook retry storms or sustained MCP outages don't flood the DM.
//
// Goals (explicit):
// - User knows "something is wrong, contact admin" rather than silently
// getting degraded tool-less replies.
// - Channel health stays Green (per operator preference for silent
// degradation). This function writes to the user, NOT to health.
// - Channel logs the detail via slog.Warn at the call site; THIS
// function only writes to the debug log on Send failure (which is
// itself best-effort — notification isn't message delivery).
//
// Non-goals:
// - Retry on Send failure. If the Bitrix24 portal is unreachable, the
// whole channel is broken; a notification retry loop is pointless.
// - Differentiate failure kinds in the user message. User doesn't care
// whether it's HTTP 500 from MCP vs a missing refresh token — they
// just need to know "talk to admin".
func (c *Channel) notifyUserOfMCPIssueOnce(ctx context.Context, userID, chatID string) {
// A missing chatID means we lost the reply target — skipping is the
// right call (no one to notify). Empty userID shouldn't happen at
// this call site (handle.go gates on evt.Params.FromUserID) but cheap
// to defend against.
if strings.TrimSpace(chatID) == "" || strings.TrimSpace(userID) == "" {
return
}
c.notifyMu.Lock()
if c.notifyDebounce == nil {
c.notifyDebounce = make(map[string]time.Time)
}
if ts, ok := c.notifyDebounce[userID]; ok && time.Since(ts) < mcpUserNotifyDebounceTTL {
c.notifyMu.Unlock()
return
}
c.notifyDebounce[userID] = time.Now()
c.notifyMu.Unlock()
// Reuse sendChunk directly instead of building a bus.OutboundMessage +
// going through Send. Two reasons:
// 1. Send() has a running-state check that'd bounce if the channel is
// mid-stop; degradation notice is best-effort so skipping in that
// state is fine, but building an OutboundMessage just to get
// rejected is wasteful. sendChunk re-checks Client()/BotID() for
// us anyway.
// 2. The notice is plain text — no BBCode conversion, no chunking
// (well under the 4000-rune limit), no media. Send's pipeline is
// overkill.
// Provisioner notice is a one-off staff alert sent into the user's
// chat with the bot — always public (default v2 path), no replyId
// because there's no inbound message to link back to.
if err := c.sendChunk(ctx, chatID, mcpUserNotifyMessage, sendOptions{visibility: VisibilityPublic}); err != nil {
slog.Debug("bitrix24 mcp: failed to send user degradation notice",
"channel", c.Name(), "user", userID, "chat_id", chatID, "err", err)
}
}
// compile-time assertion: sync.Mutex is always zero-initializable; this
// nudge just documents that mcpProvMu doesn't need an explicit constructor.
var _ sync.Mutex = sync.Mutex{}