mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(plugin): return 403 when API is disabled (MUL-7437) Co-authored-by: multica-agent <[email protected]> * fix(api): classify disabled capabilities as non-retryable (MUL-7437) Co-authored-by: multica-agent <[email protected]> * fix(api): address disabled capability review feedback (MUL-7437) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
402 lines
17 KiB
Go
402 lines
17 KiB
Go
package handler
|
|
|
|
// wecom_web.go — the Web-UI-facing management endpoints for the WeCom
|
|
// smart-bot ("智能机器人" / aibot) integration. Parallel of slack.go: it
|
|
// exposes list / install / revoke over JSON so the Settings page and per-
|
|
// agent Integrations tab can drive wecom.InstallationService.Upsert /
|
|
// Revoke. There is no callback surface — the smart-bot is a client-initiated
|
|
// WebSocket long connection, so a public callback URL is not required.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/wecom"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// wecomBodyLimit caps what either WeCom JSON endpoint will read. Both bodies
|
|
// are a handful of short fields, and encoding/json materializes a string value
|
|
// whole before Decode returns — so without a ceiling, one authenticated caller
|
|
// POSTing a multi-gigabyte token string costs the API server that much RSS per
|
|
// request.
|
|
const wecomBodyLimit = 16 * 1024
|
|
|
|
// WecomBindingRedeemer is the slice of wecom.BindingTokenService the redeem
|
|
// endpoint drives. *wecom.BindingTokenService is the production value; the
|
|
// interface exists so the body ceiling can be pinned in a test without a live
|
|
// channel_binding_token table.
|
|
type WecomBindingRedeemer interface {
|
|
RedeemAndBind(ctx context.Context, raw string, multicaUserID pgtype.UUID) (wecom.RedeemedBindingToken, error)
|
|
}
|
|
|
|
// WecomInstallationResponse is the wire shape for a wecom installation
|
|
// row. The secret is NEVER included — it remains sealed on the row. BotID
|
|
// is surfaced because operators need to see which bot is bound.
|
|
type WecomInstallationResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
AgentID string `json:"agent_id"`
|
|
BotID string `json:"bot_id"`
|
|
InstallerUserID string `json:"installer_user_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func wecomInstallationToResponse(inst wecom.Installation) WecomInstallationResponse {
|
|
return WecomInstallationResponse{
|
|
ID: uuidToString(inst.ID),
|
|
WorkspaceID: uuidToString(inst.WorkspaceID),
|
|
AgentID: uuidToString(inst.AgentID),
|
|
BotID: inst.BotID,
|
|
InstallerUserID: uuidToString(inst.InstallerUserID),
|
|
Status: string(inst.Status),
|
|
}
|
|
}
|
|
|
|
// wecomIntegrationConfigured reports whether the wecom integration is
|
|
// wired on this deployment. Both surfaces (list + register) short-circuit
|
|
// on it: list degrades to an empty response, register returns 403.
|
|
func (h *Handler) wecomIntegrationConfigured() bool {
|
|
return h.WecomStore != nil && h.WecomCredentials != nil && h.ChannelRouter != nil
|
|
}
|
|
|
|
// ListWecomInstallations (GET /api/workspaces/{id}/wecom/installations) is
|
|
// member-visible so the Integrations tab renders for non-admins. Same
|
|
// response envelope Slack / Lark use.
|
|
func (h *Handler) ListWecomInstallations(w http.ResponseWriter, r *http.Request) {
|
|
if !h.wecomIntegrationConfigured() {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"installations": []WecomInstallationResponse{},
|
|
"configured": false,
|
|
"install_supported": false,
|
|
})
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
svc := h.wecomInstallService()
|
|
if svc == nil {
|
|
writeFeatureDisabled(w, "wecom_not_configured", "wecom integration not enabled")
|
|
return
|
|
}
|
|
rows, err := svc.ListByWorkspace(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list wecom installations")
|
|
return
|
|
}
|
|
out := make([]WecomInstallationResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, wecomInstallationToResponse(row))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"installations": out,
|
|
"configured": true,
|
|
"install_supported": true,
|
|
})
|
|
}
|
|
|
|
// RegisterWecomBYORequest is the body an admin submits from the Web UI's
|
|
// BYO dialog. The bot's stable identifier (BotID) and its long-connection
|
|
// secret are copied from the WeCom admin console; the secret is written
|
|
// straight through the secretbox so plaintext never lives on this file's
|
|
// stack past the wecom.InstallationService.Upsert call.
|
|
type RegisterWecomBYORequest struct {
|
|
BotID string `json:"bot_id"`
|
|
Secret string `json:"secret"`
|
|
|
|
// BotName is the bot's name as it appears in a chat. Optional, and used
|
|
// for one thing: recognising the bot's own @-mention in a group, which
|
|
// WeCom delivers as literal text. Omitting it on a re-install of the same
|
|
// bot keeps whatever name the row already carries.
|
|
BotName string `json:"bot_name,omitempty"`
|
|
}
|
|
|
|
// RegisterWecomBYO (POST /api/workspaces/{id}/wecom/install/byo?agent_id=…)
|
|
// installs a user-supplied ("bring your own") wecom smart-bot for an agent.
|
|
// Admin-only at the router.
|
|
func (h *Handler) RegisterWecomBYO(w http.ResponseWriter, r *http.Request) {
|
|
if !h.wecomIntegrationConfigured() {
|
|
writeFeatureDisabled(w, "wecom_not_configured", "wecom integration not enabled")
|
|
return
|
|
}
|
|
svc := h.wecomInstallService()
|
|
if svc == nil {
|
|
writeFeatureDisabled(w, "wecom_not_configured", "wecom integration not enabled")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
|
|
if agentIDStr == "" {
|
|
writeError(w, http.StatusBadRequest, "agent_id is required")
|
|
return
|
|
}
|
|
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: agentUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "agent not found in this workspace")
|
|
return
|
|
}
|
|
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var body RegisterWecomBYORequest
|
|
r.Body = http.MaxBytesReader(w, r.Body, wecomBodyLimit)
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
inst, err := svc.Upsert(r.Context(), wecom.InstallationParams{
|
|
WorkspaceID: wsUUID,
|
|
AgentID: agentUUID,
|
|
InstallerUserID: initiatorUUID,
|
|
BotID: strings.TrimSpace(body.BotID),
|
|
Secret: strings.TrimSpace(body.Secret),
|
|
BotDisplayName: strings.TrimSpace(body.BotName),
|
|
})
|
|
if err != nil {
|
|
writeWecomInstallError(w, err, wsUUID, agentUUID)
|
|
return
|
|
}
|
|
h.publish(protocol.EventWecomInstallationCreated, uuidToString(inst.WorkspaceID), "user", userID, map[string]any{
|
|
"id": uuidToString(inst.ID),
|
|
})
|
|
writeJSON(w, http.StatusOK, wecomInstallationToResponse(inst))
|
|
}
|
|
|
|
// writeWecomInstallError is the whole response matrix for a failed install, in
|
|
// one place so it can be read and tested as a matrix rather than inferred from
|
|
// a switch buried in the handler.
|
|
//
|
|
// The distinction it draws is who has to do something about the failure:
|
|
//
|
|
// bot already owned (same ws / archived agent / other ws) → 409, go disconnect it there
|
|
// required field missing → 400, the admin's input
|
|
// WeCom refused the pair → 400, the ONE branch that may blame the credentials
|
|
// WeCom unreachable, so unverified → 503, say the credentials were not changed
|
|
// anything else (DB, encryption key, a bug) → 500, log it, do not blame the admin
|
|
//
|
|
// One bot is one live long connection, so the (wecom, bot_id) slot has exactly
|
|
// one owner. Upsert conflicts on (workspace_id, agent_id, channel_type), which
|
|
// means connecting an already-connected bot to a SECOND agent misses ON
|
|
// CONFLICT and trips idx_channel_installation_type_appid instead. Each sentinel
|
|
// gets the sentence that tells the admin where the bot actually is; returning
|
|
// err.Error() here used to toast them the raw "duplicate key value violates
|
|
// unique constraint" text — and forwarded every other database error verbatim
|
|
// to the API caller besides.
|
|
//
|
|
// Every case carries a stable code as well as the sentence, so the settings tab
|
|
// can say this in the admin's own language instead of toasting English at them.
|
|
// The sentence stays as the fallback for a client that does not know the code.
|
|
// The codes are a published contract: each one means one outcome and keeps
|
|
// meaning it, because the admin action differs per outcome.
|
|
func writeWecomInstallError(w http.ResponseWriter, err error, wsUUID, agentUUID pgtype.UUID) {
|
|
switch {
|
|
case errors.Is(err, wecom.ErrBotOwnedBySameWorkspace):
|
|
writeErrorCode(w, http.StatusConflict, "wecom_bot_owned_by_same_workspace",
|
|
"this bot is already connected to another agent in this workspace — disconnect it there first, then connect it here")
|
|
case errors.Is(err, wecom.ErrBotOwnedByArchivedAgent):
|
|
writeErrorCode(w, http.StatusConflict, "wecom_bot_owned_by_archived_agent",
|
|
"this bot is connected to an archived agent in this workspace — restore that agent, or disconnect its bot, before connecting it here")
|
|
case errors.Is(err, wecom.ErrBotOwnedByAnotherWorkspace):
|
|
writeErrorCode(w, http.StatusConflict, "wecom_bot_owned_by_another_workspace",
|
|
"this bot is already connected to a different Multica workspace — disconnect it there before connecting it here")
|
|
case errors.Is(err, wecom.ErrInvalidInstallationParams):
|
|
// Something the admin left out. Their input, their fix. This code means
|
|
// exactly that and nothing else — the two credential outcomes below get
|
|
// their own, because "you left a field out", "WeCom said no" and "we
|
|
// could not ask WeCom" are three different next actions for the admin,
|
|
// and a client keying off one code cannot tell them apart.
|
|
writeErrorCode(w, http.StatusBadRequest, "wecom_install_rejected",
|
|
"could not connect the WeCom bot — check the Bot ID and secret from the WeCom admin console, and that the bot is a smart bot with the long connection enabled")
|
|
case errors.Is(err, wecom.ErrCredentialsRejected):
|
|
// WeCom itself refused the pair. This is the one branch entitled to
|
|
// blame the credentials, and the only one the admin can act on by going
|
|
// back to the console.
|
|
writeErrorCode(w, http.StatusBadRequest, "wecom_credentials_rejected",
|
|
"WeCom rejected this Bot ID and secret — check both on the WeCom admin console, and that the bot is a smart bot with the long connection enabled")
|
|
case errors.Is(err, wecom.ErrCredentialsUnverifiable):
|
|
// The deployment could not reach WeCom. Reporting this as a bad
|
|
// credential sends the admin to rotate a secret that was fine — and a
|
|
// rotated WeCom secret cannot be recovered. 503: nothing is wrong with
|
|
// the input, the check could not be made.
|
|
slog.Warn("wecom install could not verify the bot",
|
|
"error", err, "workspace_id", uuidToString(wsUUID), "agent_id", uuidToString(agentUUID))
|
|
writeErrorCode(w, http.StatusServiceUnavailable, "wecom_credentials_unverifiable",
|
|
"could not reach WeCom to verify this bot — the credentials were not changed; try again in a moment")
|
|
default:
|
|
// Ours: the database, the encryption key, a bug. This branch used to
|
|
// answer 400 and blame the credentials, which sent the admin to the
|
|
// WeCom console to rotate a secret that was fine — and a rotated WeCom
|
|
// secret cannot be recovered, so a thirty-second Postgres failover cost
|
|
// them a working bot. Whatever went wrong here, it was not their Bot ID.
|
|
slog.Error("wecom install failed",
|
|
"error", err, "workspace_id", uuidToString(wsUUID), "agent_id", uuidToString(agentUUID))
|
|
writeErrorCode(w, http.StatusInternalServerError, "wecom_install_failed",
|
|
"could not save this bot — something went wrong on our side. Your credentials were not changed; please try again, and contact support if it keeps failing")
|
|
}
|
|
}
|
|
|
|
// RevokeWecomInstallation (DELETE /api/workspaces/{id}/wecom/installations/{installationId})
|
|
// flips status to 'revoked'. Admin-only at the router. Row-preserving so a
|
|
// re-install through Upsert flips it back to 'active' atomically.
|
|
func (h *Handler) RevokeWecomInstallation(w http.ResponseWriter, r *http.Request) {
|
|
if !h.wecomIntegrationConfigured() {
|
|
writeFeatureDisabled(w, "wecom_not_configured", "wecom integration not enabled")
|
|
return
|
|
}
|
|
svc := h.wecomInstallService()
|
|
if svc == nil {
|
|
writeFeatureDisabled(w, "wecom_not_configured", "wecom integration not enabled")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := svc.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
|
|
if errors.Is(err, wecom.ErrInstallationNotFound) {
|
|
writeError(w, http.StatusNotFound, "wecom installation not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load installation")
|
|
return
|
|
}
|
|
if err := svc.Revoke(r.Context(), instUUID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
|
|
return
|
|
}
|
|
h.publish(protocol.EventWecomInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
|
|
"id": uuidToString(instUUID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// wecomInstallService builds an InstallationService on demand from the
|
|
// pieces the handler was wired with. It is cheap (a struct wrap over
|
|
// *db.Queries + the shared secretbox.Box behind the CredentialsResolver),
|
|
// and this lazy pattern avoids adding a fourth wecom field on the Handler
|
|
// struct just for the Web-UI path.
|
|
//
|
|
// SecretboxCredentialsResolver is the only production implementation of
|
|
// wecom.CredentialsResolver, so a type-assert is safe. If a test supplies
|
|
// a fake resolver, the assert falls through and both endpoints report 503,
|
|
// which is exactly the "no encryption key wired" behaviour we want.
|
|
func (h *Handler) wecomInstallService() *wecom.InstallationService {
|
|
res, ok := h.WecomCredentials.(*wecom.SecretboxCredentialsResolver)
|
|
if !ok || res == nil || res.Box == nil {
|
|
return nil
|
|
}
|
|
// The probe is what makes the reclaim inside Upsert safe to run against a
|
|
// row this caller may not own. h.WecomCredentialProbe is a test seam;
|
|
// production leaves it nil and NewInstallationService supplies the real
|
|
// handshake. There is no wiring that leaves the check off — a nil probe is
|
|
// a construction error there, not a fail-open mode.
|
|
var opts []wecom.InstallationOption
|
|
if h.WecomCredentialProbe != nil {
|
|
opts = append(opts, wecom.WithCredentialProbe(h.WecomCredentialProbe))
|
|
}
|
|
svc, err := wecom.NewInstallationService(h.Queries, h.TxStarter, res.Box, opts...)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// RedeemWecomBindingTokenRequest carries the raw token the user clicked
|
|
// through from the bot's "link your Multica account" prompt.
|
|
type RedeemWecomBindingTokenRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// RedeemWecomBindingTokenResponse echoes the bound workspace / installation
|
|
// / wecom user so the frontend can confirm without a second fetch.
|
|
type RedeemWecomBindingTokenResponse struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
InstallationID string `json:"installation_id"`
|
|
WecomUserID string `json:"wecom_user_id"`
|
|
}
|
|
|
|
// RedeemWecomBindingToken (POST /api/wecom/binding/redeem) binds the WeCom
|
|
// aibot userid carried by the token to the logged-in Multica user. The
|
|
// redeemer's identity comes from the session, not the token, so a stolen
|
|
// token cannot bind a WeCom id to an attacker's account. Failure modes map
|
|
// to distinct status codes:
|
|
//
|
|
// - 410 Gone: token unknown / consumed / expired
|
|
// - 409 Conflict: this WeCom id is already bound to a different user
|
|
// - 403 Forbidden: redeemer is not a workspace member
|
|
func (h *Handler) RedeemWecomBindingToken(w http.ResponseWriter, r *http.Request) {
|
|
if h.WecomBindingTokens == nil {
|
|
writeFeatureDisabled(w, "wecom_not_configured", "wecom integration not configured")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req RedeemWecomBindingTokenRequest
|
|
r.Body = http.MaxBytesReader(w, r.Body, wecomBodyLimit)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Token == "" {
|
|
writeError(w, http.StatusBadRequest, "token is required")
|
|
return
|
|
}
|
|
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
redeemed, err := h.WecomBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, wecom.ErrBindingTokenInvalid):
|
|
writeError(w, http.StatusGone, "binding token invalid or expired")
|
|
case errors.Is(err, wecom.ErrBindingAlreadyAssigned):
|
|
writeError(w, http.StatusConflict, "this WeCom user is already bound to a different Multica user")
|
|
case errors.Is(err, wecom.ErrBindingNotWorkspaceMember):
|
|
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "failed to redeem token")
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, RedeemWecomBindingTokenResponse{
|
|
WorkspaceID: uuidToString(redeemed.WorkspaceID),
|
|
InstallationID: uuidToString(redeemed.InstallationID),
|
|
WecomUserID: redeemed.WecomUserID,
|
|
})
|
|
}
|