mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
203 lines
8.6 KiB
Go
203 lines
8.6 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// sendOAuthInvite delivers the "please re-authorize" message: a private DM
|
|
// (dialogId=userID, NOT chatID) with fields.message (short hint) + a single
|
|
// fields.keyboard button whose LINK is the authorize URL (design.md §6 — a
|
|
// button, not a raw link in text). If the trigger came from a group chat, a
|
|
// separate plain-text hint is left in the original chatID so the user knows
|
|
// to check their DMs — the hint never carries the URL. If the DM send itself
|
|
// fails (e.g. the bot cannot message this user), falls back to sending the
|
|
// SAME message+keyboard into chatID instead of losing the invite entirely.
|
|
//
|
|
// Debounced 5min/user via its own map (oauthInviteMu/oauthInviteDebounce on
|
|
// Channel) — deliberately independent of notifyUserOfMCPIssueOnce's debounce
|
|
// (provisioner.go) so the two notice types never suppress each other.
|
|
func (c *Channel) sendOAuthInvite(ctx context.Context, userID, chatID string, isGroup bool, url string) {
|
|
if !c.tryAcquireOAuthInviteNotify(userID) {
|
|
return
|
|
}
|
|
|
|
fields := map[string]any{
|
|
"message": oauthInviteMessage,
|
|
"keyboard": []map[string]any{
|
|
{"TEXT": oauthInviteButtonText, "LINK": url},
|
|
},
|
|
}
|
|
sendTo := func(dialogID string) error {
|
|
_, err := c.Client().Call(ctx, "imbot.v2.Chat.Message.send", map[string]any{
|
|
"botId": c.BotID(),
|
|
"dialogId": dialogID,
|
|
"fields": fields,
|
|
})
|
|
return err
|
|
}
|
|
|
|
if err := sendTo(userID); err != nil {
|
|
slog.Warn("bitrix24 mcp: oauth invite DM failed, falling back to original dialog",
|
|
"channel", c.Name(), "user", userID, "chat_id", chatID, "err", err)
|
|
if fbErr := sendTo(chatID); fbErr != nil {
|
|
slog.Warn("bitrix24 mcp: oauth invite fallback also failed",
|
|
"channel", c.Name(), "user", userID, "chat_id", chatID, "err", fbErr)
|
|
// Total delivery failure — the user got NOTHING (not the DM, not the
|
|
// fallback). Release the debounce slot we just took so the user's
|
|
// next message retries immediately instead of silently waiting out
|
|
// the full 5-minute TTL for an invite that never arrived.
|
|
c.releaseOAuthInviteNotify(userID)
|
|
}
|
|
return
|
|
}
|
|
if isGroup {
|
|
if err := c.sendChunk(ctx, chatID, oauthInviteGroupHintMessage, sendOptions{visibility: VisibilityPublic}); err != nil {
|
|
slog.Debug("bitrix24 mcp: oauth invite group hint failed",
|
|
"channel", c.Name(), "user", userID, "chat_id", chatID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tryAcquireOAuthInviteNotify mirrors tryAcquireMCPProvision's atomic
|
|
// check-and-set shape (provisioner.go) but guards a DIFFERENT debounce map —
|
|
// this one gates the "please re-authorize" DM, not the underlying auto-onboard
|
|
// attempt. Returns true when the caller acquired the slot (not debounced).
|
|
//
|
|
// Keyed by userID alone (no serverID, unlike mcpDebounceKey elsewhere in this
|
|
// package) — safe today because one Channel is exactly one bot on one portal
|
|
// wired to one mcp_servers row (c.mcpServerID is a single field, not a set).
|
|
// If a future refactor lets one Channel serve multiple MCP servers, this key
|
|
// would need serverID added too, the same way mcpDebounceKey already has it.
|
|
//
|
|
// Opportunistically sweeps expired entries on every call (cheap: bounded by
|
|
// the number of unique Bitrix user IDs that have ever triggered this flow,
|
|
// and only walks the map this function itself owns) so the map doesn't grow
|
|
// for the lifetime of the process — same unbounded-growth pattern flagged in
|
|
// mcpDebounce/notifyDebounce is deliberately not repeated here.
|
|
func (c *Channel) tryAcquireOAuthInviteNotify(userID string) bool {
|
|
c.oauthInviteMu.Lock()
|
|
defer c.oauthInviteMu.Unlock()
|
|
if c.oauthInviteDebounce == nil {
|
|
c.oauthInviteDebounce = make(map[string]time.Time)
|
|
}
|
|
now := time.Now()
|
|
for id, ts := range c.oauthInviteDebounce {
|
|
if now.Sub(ts) >= mcpUserNotifyDebounceTTL {
|
|
delete(c.oauthInviteDebounce, id)
|
|
}
|
|
}
|
|
if ts, ok := c.oauthInviteDebounce[userID]; ok && now.Sub(ts) < mcpUserNotifyDebounceTTL {
|
|
return false
|
|
}
|
|
c.oauthInviteDebounce[userID] = now
|
|
return true
|
|
}
|
|
|
|
// releaseOAuthInviteNotify clears a user's debounce entry early. Only called
|
|
// when delivery totally failed (DM AND the chatID fallback both errored) —
|
|
// see sendOAuthInvite — so the debounce doesn't block a retry for the full
|
|
// TTL when the user never actually received anything.
|
|
func (c *Channel) releaseOAuthInviteNotify(userID string) {
|
|
c.oauthInviteMu.Lock()
|
|
delete(c.oauthInviteDebounce, userID)
|
|
c.oauthInviteMu.Unlock()
|
|
}
|
|
|
|
// UserOnboardResult is what HandleUserOAuthCallback returns so the HTTP
|
|
// handler (webhook.go, handleUserOAuthCallback) can render the right outcome
|
|
// page. Outcome is one of "success", "declined", "identity_mismatch".
|
|
type UserOnboardResult struct {
|
|
Outcome string
|
|
}
|
|
|
|
// HandleUserOAuthCallback finishes a per-user OAuth re-authorization: it
|
|
// exchanges the authorization code, validates the response actually belongs
|
|
// to the Bitrix user the DM invite targeted, then mints/refreshes MCP
|
|
// credentials via the SAME autoOnboard + SetUserCredentials sequence
|
|
// provisionIfMissing already uses (provisioner.go) — no separate mint logic,
|
|
// no api_key churn (design.md §12).
|
|
//
|
|
// payload is the ALREADY-DECODED + HMAC-verified state (decoding happens in
|
|
// the HTTP handler, which needs the payload's BotID to resolve which Channel
|
|
// to call this on in the first place — see handleUserOAuthCallback).
|
|
func (c *Channel) HandleUserOAuthCallback(ctx context.Context, code string, payload *oauthStatePayload) (*UserOnboardResult, error) {
|
|
portal := c.Portal()
|
|
if portal == nil {
|
|
return nil, errors.New("bitrix24 oauth callback: portal not available")
|
|
}
|
|
|
|
tr, err := portal.ExchangeUserAuthCode(ctx, code)
|
|
if err != nil {
|
|
slog.Warn("bitrix24 oauth callback: exchange failed",
|
|
"channel", c.Name(), "user_id", payload.UserID, "err", err)
|
|
return nil, fmt.Errorf("bitrix24 oauth callback: exchange: %w", err)
|
|
}
|
|
|
|
// Identity check: the Bitrix account that just approved MUST be the same
|
|
// one the DM invite was built for. Without this, user X's invite link
|
|
// could be completed by user Y (e.g. forwarded, or X asks a colleague to
|
|
// click it), silently attaching Y's Bitrix identity to X's MCP row.
|
|
//
|
|
// Portal/domain identity was already checked one layer up, in
|
|
// handleUserOAuthCallback (webhook.go), against the redirect's own
|
|
// `domain` query param — NOT against tr.Domain here, which is the OAuth
|
|
// server's own domain for this kind of exchange, not the portal's (see
|
|
// ExchangeUserAuthCode doc comment). This check confirms "same PERSON we
|
|
// sent the link to"; that one confirmed "same portal." Both matter;
|
|
// neither substitutes for the other.
|
|
gotUserID := strconv.FormatInt(tr.UserID, 10)
|
|
if gotUserID != payload.UserID {
|
|
slog.Warn("bitrix24 oauth callback: identity mismatch — authorized as a different Bitrix user",
|
|
"channel", c.Name(), "expected_user_id", payload.UserID, "got_user_id", gotUserID)
|
|
return &UserOnboardResult{Outcome: "identity_mismatch"}, nil
|
|
}
|
|
|
|
if c.mcpStore == nil || c.mcpClient == nil || c.mcpServerID == uuid.Nil {
|
|
return nil, errors.New("bitrix24 oauth callback: mcp provisioning not configured for this channel")
|
|
}
|
|
|
|
// NOTE: tr.Domain is deliberately NOT used here — it's the OAuth server's
|
|
// own domain for this kind of exchange, not the portal's (see
|
|
// ExchangeUserAuthCode). payload.Domain (signed into the state at
|
|
// BuildUserAuthorizeURL time, sourced from the real webhook event that
|
|
// triggered the invite, and re-validated against the redirect's own
|
|
// `domain` param in handleUserOAuthCallback) is the correct value.
|
|
resp, err := c.mcpClient.autoOnboard(ctx, autoOnboardRequest{
|
|
Domain: payload.Domain,
|
|
BitrixUserID: payload.UserID,
|
|
AccessToken: tr.AccessToken,
|
|
RefreshToken: tr.RefreshToken,
|
|
ExpiresIn: int(tr.ExpiresIn),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bitrix24 oauth callback: auto-onboard: %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": payload.Domain,
|
|
"BITRIX_ACCESS_TOKEN": tr.AccessToken,
|
|
"BITRIX_REFRESH_TOKEN": tr.RefreshToken,
|
|
"BITRIX_EXPIRES_AT": expiresAt,
|
|
},
|
|
}
|
|
if err := c.mcpStore.SetUserCredentials(ctx, c.mcpServerID, payload.UserID, creds); err != nil {
|
|
return nil, fmt.Errorf("bitrix24 oauth callback: persist credentials: %w", err)
|
|
}
|
|
|
|
slog.Info("bitrix24 oauth callback: user re-authorized",
|
|
"channel", c.Name(), "user_id", payload.UserID, "created", resp.Created)
|
|
return &UserOnboardResult{Outcome: "success"}, nil
|
|
}
|