Files
SYNITY 641d4f8756 feat(bitrix24): ship per-user OAuth re-authorization flow (#1417)
feat(bitrix24): ship per-user OAuth re-authorization flow (#1417)
2026-07-10 10:56:45 +07:00

632 lines
28 KiB
Go
Raw Permalink 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"
"net/url"
"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")
)
// deadTokenCodes are Bitrix APIError.Code values (client.go:82-97) that mean
// a refresh_token is dead beyond repair — Bitrix rejected the refresh
// attempt outright, so no amount of retrying will succeed. Only these codes
// escalate to ErrUserAuthRequired; any other error from selfRefreshUserCreds
// (network, 5xx, DB persist failure) is treated as transient and falls
// through to the existing notifyUserOfMCPIssueOnce path instead.
var deadTokenCodes = map[string]bool{
"invalid_grant": true,
"expired_token": true,
"NO_AUTH_FOUND": true,
}
// isDeadTokenCode reports whether a Bitrix APIError.Code indicates the
// stored refresh_token is permanently dead (user must re-authorize) rather
// than a transient failure.
func isDeadTokenCode(code string) bool {
return deadTokenCodes[code]
}
// ErrUserAuthRequired means goclaw has no way to obtain a working Bitrix
// OAuth token for this user — either no mcp_user_credentials row exists yet,
// or the stored refresh_token was rejected outright by Bitrix
// (isDeadTokenCode). The caller (handle.go) must DM the user the URL so they
// can re-authorize; the message must NOT reach the agent bus (no MCP access
// to answer with yet).
type ErrUserAuthRequired struct {
URL string
}
func (e *ErrUserAuthRequired) Error() string {
return "bitrix24 mcp: user authorization required"
}
// 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
}
hasServerID := strings.TrimSpace(c.cfg.MCPServerID) != ""
hasLegacy := strings.TrimSpace(c.cfg.MCPServerName) != "" && strings.TrimSpace(c.cfg.MCPBaseURL) != ""
if !hasServerID && !hasLegacy {
slog.Debug("bitrix24 mcp: provisioning disabled (no mcp_server_id and legacy fields empty)",
"channel", c.Name())
return nil
}
// Resolve the mcp_servers row. Preferred path: mcp_server_id (UUID
// dashboards write since v3.15). Fallback: legacy name lookup for
// configs written before the refactor and not yet migrated.
//
// GetServer / GetServerByName both scope by tenant_id from context.
// 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 the dashboard
// or `bitrix-portal create`.
lookupCtx := ctx
if tid := c.TenantID(); tid != uuid.Nil {
lookupCtx = store.WithTenantID(ctx, tid)
}
var (
server *store.MCPServerData
err error
)
if hasServerID {
serverUUID, parseErr := uuid.Parse(strings.TrimSpace(c.cfg.MCPServerID))
if parseErr != nil {
slog.Warn("bitrix24 mcp: provisioning disabled — invalid mcp_server_id",
"channel", c.Name(), "mcp_server_id", c.cfg.MCPServerID, "err", parseErr)
return nil
}
server, err = c.mcpStore.GetServer(lookupCtx, serverUUID)
} else {
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_id", c.cfg.MCPServerID,
"mcp_server_name", c.cfg.MCPServerName,
"err", err)
return nil
}
// Base URL sourcing: for the id-based path we derive the ORIGIN
// (scheme://host[:port]) from the mcp_servers row. The row's URL is
// the MCP JSON-RPC endpoint (e.g. https://mcp.example.com/mcp), but
// the /api/auto-onboard REST call the provisioner will POST expects
// the origin only — appending "/api/auto-onboard" to the JSON-RPC
// path would 404. Legacy path stays on MCPBaseURL, which historically
// was already the origin (admin gave us "https://mcp.example.com"),
// so a half-migrated fleet keeps working until Phase 5 rewrites configs.
baseURL := strings.TrimSpace(c.cfg.MCPBaseURL)
if hasServerID {
derived, deriveErr := deriveAutoOnboardBaseURL(server.URL)
if deriveErr != nil {
slog.Warn("bitrix24 mcp: provisioning disabled — mcp_servers.url unparseable",
"channel", c.Name(),
"mcp_server", server.Name,
"url", server.URL,
"err", deriveErr)
return nil
}
baseURL = derived
}
if baseURL == "" {
slog.Warn("bitrix24 mcp: provisioning disabled — mcp_servers row has empty url",
"channel", c.Name(), "mcp_server", server.Name)
return nil
}
c.mcpServerID = server.ID
c.mcpClient = newMCPClient(baseURL, 10*time.Second)
c.mcpDebounce = make(map[mcpDebounceKey]time.Time)
slog.Info("bitrix24 mcp: provisioning enabled",
"channel", c.Name(),
"mcp_server", server.Name,
"mcp_server_id", server.ID,
"require_user_credentials", server.RequireUserCredentials)
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.
// dialogID is the inbound event's DialogID — needed to build the OAuth
// authorize URL's state payload (oauth_state_codec.go) when this function
// must return ErrUserAuthRequired.
func (c *Channel) provisionIfMissing(ctx context.Context, userID string, fromConnector bool, auth EventAuth, dialogID string) error {
// Skip #1: Open Channel message from an external connector customer.
// Transient customers reach the bot via a connector (Zalo/FB/etc.) and
// report IS_CONNECTOR=Y — they are not Bitrix users and have no per-user
// OAuth, so skip them. Internal staff who join an Open Channel session
// report IS_CONNECTOR=N and DO have OAuth, so they must be provisioned.
if c.IsOpenChannelBot() && fromConnector {
slog.Debug("bitrix24 mcp: provision skip open channel connector", "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.
// Atomic check-and-set: an aggressive Bitrix retry burst can deliver
// several events for the same user near-simultaneously. A separate
// check-then-mark leaves a TOCTOU gap where two of them both pass the gate
// and both run a self-refresh, double-rotating the refresh_token (the
// second then fails on the now-consumed token). Acquire under one lock.
if !c.tryAcquireMCPProvision(c.mcpServerID, userID) {
slog.Warn("bitrix24 mcp: provision debounced", "channel", c.Name(), "user_id", userID)
return ErrProvisionDebounced
}
// 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 == "" {
// Direct/group chatbot events don't carry OAuth tokens in the top-level
// auth[] block — Bitrix only ships them on Open Channel events (see
// design.md — confirmed by Bitrix24 support as expected behavior, not
// a bug: imbot subscriptions bind with USER_ID=0).
//
// Brand-new user (never onboarded) — nothing to refresh. Send the user
// an OAuth re-authorize link instead of failing silently.
if existing == nil {
url, err := c.BuildUserAuthorizeURL(userID, dialogID)
if err != nil {
return fmt.Errorf("bitrix24 mcp: build authorize url: %w", err)
}
return &ErrUserAuthRequired{URL: url}
}
// Existing user — try refreshing the USER-context token from the
// stored refresh_token instead of failing, so per-user MCP stays
// alive without depending on the event carrying tokens.
if err := c.selfRefreshUserCreds(ctx, userID, existing); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && isDeadTokenCode(apiErr.Code) {
// refresh_token is dead beyond repair — same re-auth flow as
// the brand-new-user case above. Do NOT delete the existing
// credential row: SetUserCredentials upserts on
// (server_id, user_id, tenant_id), so completing the OAuth
// flow overwrites this row's env in place (design.md §12 —
// deleting first would be unnecessary churn).
url, urlErr := c.BuildUserAuthorizeURL(userID, dialogID)
if urlErr != nil {
return fmt.Errorf("bitrix24 mcp: build authorize url: %w", urlErr)
}
return &ErrUserAuthRequired{URL: url}
}
return err // transient (network/5xx/DB) — falls through to notifyUserOfMCPIssueOnce
}
return nil
}
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
}
// selfRefreshUserCreds renews a user's per-user Bitrix MCP credentials WITHOUT
// relying on an inbound event carrying OAuth tokens. It refreshes the stored
// refresh_token via OAuth (grant_type=refresh_token, using the portal's
// client_id/secret), re-onboards the MCP server with the rotated tokens, and
// persists them. Bitrix rotates the refresh_token on every call, so persisting
// the new pair is mandatory — otherwise the next refresh would use a dead token.
//
// Returns an error (surfaced to the caller as a degradation notice) when there
// are no stored credentials to refresh, the stored refresh_token is dead
// (invalid_grant → user must re-authorize), or onboarding/persisting fails.
func (c *Channel) selfRefreshUserCreds(ctx context.Context, userID string, existing *store.MCPUserCredentials) error {
if existing == nil || existing.Env == nil {
return fmt.Errorf("bitrix24 mcp: incomplete auth block and no stored credentials to refresh")
}
refreshToken := strings.TrimSpace(existing.Env["BITRIX_REFRESH_TOKEN"])
domain := strings.TrimSpace(existing.Env["BITRIX_DOMAIN"])
if refreshToken == "" || domain == "" {
return fmt.Errorf("bitrix24 mcp: incomplete auth block and stored credentials missing domain/refresh_token")
}
if c.portal == nil {
return fmt.Errorf("bitrix24 mcp: portal unavailable for self-refresh")
}
tr, err := c.portal.RefreshUserToken(ctx, refreshToken)
if err != nil {
slog.Warn("bitrix24 mcp: self-refresh oauth failed", "channel", c.Name(), "user_id", userID, "err", err)
return fmt.Errorf("bitrix24 mcp: self-refresh oauth failed: %w", err)
}
resp, err := c.mcpClient.autoOnboard(ctx, autoOnboardRequest{
Domain: domain,
BitrixUserID: userID,
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: int(tr.ExpiresIn),
})
if err != nil {
slog.Warn("bitrix24 mcp: self-refresh re-onboard failed", "channel", c.Name(), "user_id", userID, "err", err)
return fmt.Errorf("bitrix24 mcp: self-refresh re-onboard failed: %w", err)
}
expiresAt := time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
creds := store.MCPUserCredentials{
APIKey: resp.APIKey,
Env: map[string]string{
"BITRIX_DOMAIN": domain,
"BITRIX_ACCESS_TOKEN": tr.AccessToken,
"BITRIX_REFRESH_TOKEN": tr.RefreshToken, // rotated — persist the new one
"BITRIX_EXPIRES_AT": expiresAt,
},
}
// Decouple persist from the caller's context: once Bitrix rotated the
// refresh_token, a canceled request ctx must NOT drop the new token to the
// store — the old token is already dead and would strand the user until
// re-authorization. Mirrors Portal.refreshLocked's WithoutCancel rationale.
if err := c.mcpStore.SetUserCredentials(context.WithoutCancel(ctx), c.mcpServerID, userID, creds); err != nil {
return fmt.Errorf("bitrix24 mcp: persist self-refreshed credentials: %w", err)
}
slog.Info("bitrix24 mcp: self-refreshed user credentials",
"channel", c.Name(), "user_id", userID, "mcp_server_id", c.mcpServerID, "created", resp.Created)
return nil
}
// deriveAutoOnboardBaseURL strips the path/query/fragment from an MCP server
// URL so what's left is safe to append "/api/auto-onboard" to. The
// mcp_servers.url column stores the JSON-RPC endpoint the agent loop dials
// for tool calls (which usually lives under a subpath like /mcp), while the
// per-user credential-minting REST call the Bitrix24 channel makes lives at
// the origin. This helper bridges the two conventions with a single URL
// column so operators don't have to fill in a second field.
//
// Examples:
// - "https://mcp.example.com/mcp" → "https://mcp.example.com"
// - "https://mcp.example.com/mcp/" → "https://mcp.example.com"
// - "https://mcp.example.com/" → "https://mcp.example.com"
// - "https://mcp.example.com" → "https://mcp.example.com"
// - "http://localhost:8080/some/path" → "http://localhost:8080"
//
// Returns an error when the string cannot be parsed as an absolute URL or
// carries no host — either case would produce a nonsensical base URL for
// the auto-onboard client and we prefer to disable provisioning rather than
// send credentials to a bogus origin.
func deriveAutoOnboardBaseURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("mcp_servers.url is empty")
}
u, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("parse mcp_servers.url: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("mcp_servers.url %q has no scheme or host", trimmed)
}
// Reset every component that could turn the origin back into a full URL
// so a future field addition here doesn't silently break the invariant.
u.Path = ""
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
u.Opaque = ""
return u.String(), nil
}
// tryAcquireMCPProvision atomically checks the debounce window for
// (serverID, userID) and, if clear, records a fresh attempt — all under a
// single lock so concurrent webhook-retry events can't both pass the gate
// (which would double-rotate the refresh_token in selfRefreshUserCreds).
// Returns true when the caller acquired the slot (NOT debounced), false
// otherwise. Prunes the entry on the way through to keep the map lean.
func (c *Channel) tryAcquireMCPProvision(serverID uuid.UUID, userID string) bool {
c.mcpProvMu.Lock()
defer c.mcpProvMu.Unlock()
if c.mcpDebounce == nil {
// Defensive: initMCPProvisioner allocates this, but if some code path
// bypassed init (e.g. a test that constructs Channel directly), a nil
// map write would panic. Allocate on demand instead.
c.mcpDebounce = make(map[mcpDebounceKey]time.Time)
}
key := mcpDebounceKey{serverID: serverID, userID: userID}
if ts, ok := c.mcpDebounce[key]; ok && time.Since(ts) < mcpProvisionDebounceTTL {
return false // still within the debounce window
}
c.mcpDebounce[key] = time.Now()
return true
}
// 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."
// oauthInviteMessage is the short hint sent alongside the keyboard button
// (handle.go, sendOAuthInvite) for a user who has no MCP credentials yet or
// whose refresh_token died. Contains NO url — the url lives only in the
// keyboard button's LINK field, built at the call site from
// Channel.BuildUserAuthorizeURL (oauth_state_codec.go). Vietnamese-first for
// the same reason as mcpUserNotifyMessage above (channel doesn't thread
// locale yet).
const oauthInviteMessage = "🔐 Vui lòng bấm nút bên dưới để cấp quyền truy cập CRM " +
"(link có hiệu lực 10 phút)."
// oauthInviteButtonText is the label of the single keyboard button sent with
// oauthInviteMessage.
const oauthInviteButtonText = "Cấp quyền truy cập CRM"
// oauthInviteGroupHintMessage is sent in the ORIGINAL group/dialog only when
// the oauth-invite trigger came from a group chat, so the user knows to check
// their private messages. Never contains the URL.
const oauthInviteGroupHintMessage = "Đã gửi bạn tin nhắn riêng để cấp quyền truy cập CRM."
// 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{}