mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
* fix(wecom): stop blaming an admin's credentials when the fault is ours RegisterWecomBYO's default branch answered 400 with "check the Bot ID and secret from the WeCom admin console". Every failure that is not one of the three ownership conflicts lands there — a Postgres blip, a bad encryption key, a bug of ours — and all of them tell the admin their credentials are wrong. That is not a cosmetic mistake. It sends them to the WeCom console to rotate a secret that was fine, and a rotated WeCom long-connection secret cannot be recovered: the console shows it once. A thirty-second database failover costs them a working bot. Split by whose fault it is. validateInstallationParams now wraps ErrInvalidInstallationParams, so a missing field is identifiable as the caller's to fix and keeps its 400. Everything else falls to a 500 that says so plainly and states that the credentials were not changed, logged at Error with the workspace and agent rather than Warn. Each branch also carries a stable code next to the English sentence, through a new writeErrorCode helper. The settings tab reads it (errorCode, alongside the existing dispatchReasonCode) and renders its own localized string, so a zh-Hans admin stops being toasted English. The sentence remains the fallback for a server that sends no code. Rebased onto main, which brought #6594's credential probe into the same switch. The two are complementary and both are kept whole: #6594 decides whether WeCom refused the pair (ErrCredentialsRejected) or could not be reached at all (ErrCredentialsUnverifiable); this change decides what the admin is told about each. Both now carry their own stable code — wecom_credentials_rejected (400) and wecom_credentials_unverifiable (503) — rather than folding into wecom_install_rejected, which means "you left a field out" and must keep meaning only that: the three call for three different actions from the admin. The full matrix is now one named function, writeWecomInstallError, instead of a switch inline in the handler: bot already owned (same ws / archived agent / other ws) → 409 required field missing → 400 WeCom refused the credentials → 400 WeCom unreachable, credentials unverified → 503 anything else (DB, encryption key, a bug) → 500 Seven new keys in en/ja/ko/zh-Hans, key sets verified identical across all four at 45 keys each. Tests. At the handler level, a table-driven test pins HTTP status + error + code for all seven outcomes, plus a wrapped sentinel and a second test that every outcome's code is distinct — the layer where a bad merge lands and where nothing was asserting before, since the wecom-package test only proves sentinel matching and the frontend test only mocks the code. Dropping either credential arm fails the matrix test; giving two outcomes one code fails the distinctness test even though both are 400 and the status assertions still pass. In the wecom package, every missing field is identifiable as a caller error and names itself, complete params pass, and an infrastructure error does NOT match the sentinel. Four frontend tests cover the localized render, both new codes, and the no-code fallback. * test(wecom): pin each install outcome's own fallback sentence The matrix test's header said it pinned status + error + code, but the table carried no expected sentence and the assertion was only that one was non-empty. That leaves the case it was written to catch: a resolution that keeps every status and code correct and hands one outcome its neighbour's sentence still passes, and a client too old to know the codes then shows the admin the wrong next action — "check your credentials" for an outage, or "wait and retry" for a pair WeCom actually refused. The sentence is a contract with those clients as much as the status is, so the table now carries it and the test compares it exactly. Verified by crossing the rejected/unverifiable sentences in writeWecomInstallError: before, green; now, three rows fail with the status and code still right. Co-authored-by: multica-agent <[email protected]> * docs(wecom): un-orphan the validateInstallationParams comment, drop the stale 500 claim Two problems in the same block. ErrInvalidInstallationParams was declared between validateInstallationParams's doc comment and the function, so godoc read "validateInstallationParams is a lightweight pre-write check" as documentation of the error value and left the function undocumented. And the text said everything that is not one of these belongs on a 500, which stopped being true when #6594 added ErrCredentialsRejected (400) and ErrCredentialsUnverifiable (503). Left as-is it invites the next reader to collapse exactly the distinctions this change spent its effort drawing. Comment split back onto what each one documents, and the classification now names all three outcomes and points at writeWecomInstallError, which holds the matrix. No behaviour change. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[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 503.
|
|
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 {
|
|
writeError(w, http.StatusServiceUnavailable, "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() {
|
|
writeError(w, http.StatusServiceUnavailable, "wecom integration not enabled")
|
|
return
|
|
}
|
|
svc := h.wecomInstallService()
|
|
if svc == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "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() {
|
|
writeError(w, http.StatusServiceUnavailable, "wecom integration not enabled")
|
|
return
|
|
}
|
|
svc := h.wecomInstallService()
|
|
if svc == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "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 {
|
|
writeError(w, http.StatusServiceUnavailable, "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,
|
|
})
|
|
}
|