Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
561 lines
20 KiB
Go
561 lines
20 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"telesrv/internal/admin"
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// Third-party bot verification over the admin API
|
|
// (core.telegram.org/api/bots/verification).
|
|
//
|
|
// These are the mirror routes of the panel's own /api/botverification endpoints:
|
|
// the panel reads straight from PostgreSQL for speed, while an integration holding
|
|
// a scoped token reads it here. Every mutation only ever travels this way, so the
|
|
// command journal, the status machine and the optimistic lock are enforced in one
|
|
// place.
|
|
//
|
|
// This is NOT the official platform verification surface in verification.go. The
|
|
// two mechanisms own separate tables, separate permissions (botverification.* vs
|
|
// verification.*) and separate routes, and neither reads the other's state: a
|
|
// third-party verifier must never be able to mint a platform checkmark.
|
|
//
|
|
// Every int64 crosses the JSON boundary as a decimal string. Bot ids, peer ids,
|
|
// custom emoji document ids and the optimistic-locking version all exceed the
|
|
// range a JSON number holds exactly, and a rounded version would decide the wrong
|
|
// revision of a row.
|
|
|
|
// handleBotVerifiers lists verifier bots.
|
|
func (s *Server) handleBotVerifiers(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
limit, ok := optionalQueryInt(w, query, "limit")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := s.svc.BotVerifiers(r.Context(), queryBool(query.Get("enabled_only")), limit)
|
|
if err != nil {
|
|
writeBotVerificationError(w, err)
|
|
return
|
|
}
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
rows = append(rows, botVerifierResponse(item))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
|
}
|
|
|
|
// handleVerificationIcons lists the icon catalogue.
|
|
func (s *Server) handleVerificationIcons(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
limit, ok := optionalQueryInt(w, query, "limit")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := s.svc.VerificationIcons(r.Context(), queryBool(query.Get("active_only")), limit)
|
|
if err != nil {
|
|
writeBotVerificationError(w, err)
|
|
return
|
|
}
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
rows = append(rows, verificationIconResponse(item))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
|
}
|
|
|
|
// handleCustomVerifications lists granted marks with keyset paging.
|
|
func (s *Server) handleCustomVerifications(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
|
if !ok {
|
|
return
|
|
}
|
|
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
limit, ok := optionalQueryInt(w, query, "limit")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := s.svc.CustomVerifications(r.Context(), domain.CustomVerificationFilter{
|
|
VerifierBotID: verifierBotID,
|
|
PeerType: peerType,
|
|
Query: query.Get("q"),
|
|
BeforeID: beforeID,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
writeBotVerificationError(w, err)
|
|
return
|
|
}
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
rows = append(rows, customVerificationResponse(item))
|
|
}
|
|
// The page bound is the use-case layer's, so has_more is derived from what came
|
|
// back rather than from the limit the caller asked for.
|
|
hasMore := limit > 0 && len(items) >= limit
|
|
nextBeforeID := ""
|
|
if hasMore && len(items) > 0 {
|
|
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"rows": rows,
|
|
"has_more": hasMore,
|
|
"next_before_id": nextBeforeID,
|
|
})
|
|
}
|
|
|
|
// handleCustomVerificationRequests is the third-party review queue.
|
|
func (s *Server) handleCustomVerificationRequests(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
|
if !ok {
|
|
return
|
|
}
|
|
filter := domain.CustomVerificationRequestFilter{
|
|
PeerType: peerType,
|
|
Query: query.Get("q"),
|
|
}
|
|
// status accepts a comma-separated list, so a "pending,approved" view is one
|
|
// request rather than two.
|
|
for _, raw := range strings.Split(query.Get("status"), ",") {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
status := domain.CustomVerificationRequestStatus(raw)
|
|
if !status.Valid() {
|
|
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationStatusInvalid, "invalid status "+raw)
|
|
return
|
|
}
|
|
filter.Statuses = append(filter.Statuses, status)
|
|
}
|
|
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
filter.VerifierBotID = verifierBotID
|
|
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
filter.BeforeID = beforeID
|
|
limit, ok := optionalQueryInt(w, query, "limit")
|
|
if !ok {
|
|
return
|
|
}
|
|
filter.Limit = limit
|
|
items, err := s.svc.CustomVerificationRequests(r.Context(), filter)
|
|
if err != nil {
|
|
writeBotVerificationError(w, err)
|
|
return
|
|
}
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
rows = append(rows, customVerificationRequestResponse(item))
|
|
}
|
|
hasMore := limit > 0 && len(items) >= limit
|
|
nextBeforeID := ""
|
|
if hasMore && len(items) > 0 {
|
|
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"rows": rows,
|
|
"has_more": hasMore,
|
|
"next_before_id": nextBeforeID,
|
|
})
|
|
}
|
|
|
|
// handleCustomVerificationRequest is one application with the verifier behind it
|
|
// and whether the mark is on the peer right now.
|
|
func (s *Server) handleCustomVerificationRequest(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := moderationPathID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
req, err := s.svc.CustomVerificationRequest(r.Context(), id)
|
|
if err != nil {
|
|
writeBotVerificationError(w, err)
|
|
return
|
|
}
|
|
body := map[string]any{"request": customVerificationRequestResponse(req)}
|
|
// The verifier row is advisory: it may have been revoked since the application
|
|
// was filed, and that must not turn the audit record into a 500. An absent row
|
|
// is reported as a verifier with only its id, so the reviewer can see which bot
|
|
// it was.
|
|
if settings, err := s.svc.BotVerifier(r.Context(), req.VerifierBotID); err == nil {
|
|
body["verifier"] = botVerifierResponse(settings)
|
|
} else if errors.Is(err, domain.ErrVerifierNotFound) {
|
|
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
|
} else {
|
|
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
|
body["verifier_error"] = err.Error()
|
|
}
|
|
// mark_active tells "approved" apart from "approved and since stripped by the
|
|
// operator", which is the one thing the status alone cannot say.
|
|
if active, err := s.svc.CustomVerificationMarkActive(r.Context(), req.VerifierBotID, req.Peer); err == nil {
|
|
body["mark_active"] = active
|
|
} else {
|
|
body["mark_active"] = false
|
|
body["mark_error"] = err.Error()
|
|
}
|
|
writeJSON(w, http.StatusOK, body)
|
|
}
|
|
|
|
// handleCustomVerificationCounts is the queue summary.
|
|
func (s *Server) handleCustomVerificationCounts(w http.ResponseWriter, r *http.Request) {
|
|
counts, err := s.svc.CustomVerificationRequestCounts(r.Context())
|
|
if err != nil {
|
|
writeBotVerificationError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"counts": customVerificationCountsResponse(counts)})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Server) handleGrantBotVerifier(w http.ResponseWriter, r *http.Request) {
|
|
var req admin.GrantBotVerifierRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.GrantBotVerifier(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleSetBotVerifierEnabled(w http.ResponseWriter, r *http.Request) {
|
|
var req admin.SetBotVerifierEnabledRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.SetBotVerifierEnabled(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleRevokeBotVerifier(w http.ResponseWriter, r *http.Request) {
|
|
var req admin.RevokeBotVerifierRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.RevokeBotVerifier(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleUpsertVerificationIcon(w http.ResponseWriter, r *http.Request) {
|
|
var req admin.UpsertVerificationIconRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.UpsertVerificationIcon(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleSetVerificationIconActive(w http.ResponseWriter, r *http.Request) {
|
|
var req admin.SetVerificationIconActiveRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.SetVerificationIconActive(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleRevokeCustomVerification(w http.ResponseWriter, r *http.Request) {
|
|
var req admin.RevokeCustomVerificationRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.RevokeCustomVerification(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleApproveBotVerification(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := moderationPathID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req admin.ApproveBotVerificationRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
// The path is the authority on which application is decided: a body naming a
|
|
// different one would make the URL lie to the audit trail.
|
|
req.RequestID = id
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.ApproveBotVerification(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleRejectBotVerification(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := moderationPathID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req admin.RejectBotVerificationRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.RequestID = id
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.RejectBotVerification(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
func (s *Server) handleRevokeBotVerification(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := moderationPathID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req admin.RevokeBotVerificationRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.RequestID = id
|
|
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
|
result, err := s.svc.RevokeBotVerification(r.Context(), req)
|
|
writeBotVerificationCommandResult(w, result, err)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// botVerifierResponse renders one verifier bot. The keys are the panel's row
|
|
// field names, so the same shape reaches the browser whether it came from here or
|
|
// from the panel's direct read.
|
|
func botVerifierResponse(settings domain.BotVerifierSettings) map[string]any {
|
|
out := map[string]any{
|
|
"BotID": strconv.FormatInt(settings.BotID, 10),
|
|
"IconDocumentID": strconv.FormatInt(settings.IconDocumentID, 10),
|
|
"CompanyName": settings.CompanyName,
|
|
"DefaultDescription": settings.DefaultDescription,
|
|
"CanModifyCustomDescription": settings.CanModifyCustomDescription,
|
|
"Enabled": settings.Enabled,
|
|
"GrantedBy": settings.GrantedBy,
|
|
"GrantReason": settings.GrantReason,
|
|
"Version": strconv.FormatInt(settings.Version, 10),
|
|
}
|
|
if !settings.CreatedAt.IsZero() {
|
|
out["CreatedAt"] = settings.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !settings.UpdatedAt.IsZero() {
|
|
out["UpdatedAt"] = settings.UpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func verificationIconResponse(icon domain.VerificationIcon) map[string]any {
|
|
out := map[string]any{
|
|
"ID": strconv.FormatInt(icon.ID, 10),
|
|
"DocumentID": strconv.FormatInt(icon.DocumentID, 10),
|
|
"OwnerBotID": strconv.FormatInt(icon.OwnerBotID, 10),
|
|
"Name": icon.Name,
|
|
"Active": icon.Active,
|
|
}
|
|
if !icon.CreatedAt.IsZero() {
|
|
out["CreatedAt"] = icon.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !icon.UpdatedAt.IsZero() {
|
|
out["UpdatedAt"] = icon.UpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func customVerificationResponse(mark domain.CustomVerification) map[string]any {
|
|
out := map[string]any{
|
|
"ID": strconv.FormatInt(mark.ID, 10),
|
|
"VerifierBotID": strconv.FormatInt(mark.VerifierBotID, 10),
|
|
"PeerType": string(mark.Peer.Type),
|
|
"PeerID": strconv.FormatInt(mark.Peer.ID, 10),
|
|
"IconDocumentID": strconv.FormatInt(mark.IconDocumentID, 10),
|
|
"Description": mark.Description,
|
|
"Version": strconv.FormatInt(mark.Version, 10),
|
|
}
|
|
if !mark.CreatedAt.IsZero() {
|
|
out["CreatedAt"] = mark.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !mark.UpdatedAt.IsZero() {
|
|
out["UpdatedAt"] = mark.UpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func customVerificationRequestResponse(req domain.CustomVerificationRequest) map[string]any {
|
|
out := map[string]any{
|
|
"ID": strconv.FormatInt(req.ID, 10),
|
|
"VerifierBotID": strconv.FormatInt(req.VerifierBotID, 10),
|
|
"ApplicantUserID": strconv.FormatInt(req.ApplicantUserID, 10),
|
|
"PeerType": string(req.Peer.Type),
|
|
"PeerID": strconv.FormatInt(req.Peer.ID, 10),
|
|
"PeerTitle": req.PeerTitle,
|
|
"PeerUsername": req.PeerUsername,
|
|
"Reason": req.Reason,
|
|
"RequestedDescription": req.RequestedDescription,
|
|
"Status": string(req.Status),
|
|
"DecidedBy": req.DecidedBy,
|
|
"DecisionReason": req.DecisionReason,
|
|
// InternalNote is operator-only. It is exposed here because every caller of
|
|
// this route already holds botverification.review, and it is the reviewer's
|
|
// own handover note; it is never part of the applicant-facing projection.
|
|
"InternalNote": req.InternalNote,
|
|
"CorrelationID": req.CorrelationID,
|
|
"Version": strconv.FormatInt(req.Version, 10),
|
|
}
|
|
if !req.CreatedAt.IsZero() {
|
|
out["CreatedAt"] = req.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !req.UpdatedAt.IsZero() {
|
|
out["UpdatedAt"] = req.UpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !req.ApprovedAt.IsZero() {
|
|
out["ApprovedAt"] = req.ApprovedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !req.RejectedAt.IsZero() {
|
|
out["RejectedAt"] = req.RejectedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// customVerificationCountsResponse renders the queue summary with every modelled
|
|
// status present, so the panel never has to distinguish "zero" from "absent". The
|
|
// values are decimal strings for the same exactness reason as the ids.
|
|
func customVerificationCountsResponse(counts map[domain.CustomVerificationRequestStatus]int64) map[string]string {
|
|
out := make(map[string]string, len(customVerificationStatusOrder))
|
|
for _, status := range customVerificationStatusOrder {
|
|
out[string(status)] = strconv.FormatInt(counts[status], 10)
|
|
}
|
|
for status, count := range counts {
|
|
if _, ok := out[string(status)]; !ok {
|
|
out[string(status)] = strconv.FormatInt(count, 10)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// customVerificationStatusOrder is the closed status set, in lifecycle order.
|
|
var customVerificationStatusOrder = []domain.CustomVerificationRequestStatus{
|
|
domain.CustomVerificationPending,
|
|
domain.CustomVerificationApproved,
|
|
domain.CustomVerificationRejected,
|
|
domain.CustomVerificationRevoked,
|
|
}
|
|
|
|
// botVerificationPeerType validates the peer filter against the peer kinds a
|
|
// third-party mark can sit on. An unmodelled value is a 400 rather than an empty
|
|
// result, so a typo is reported instead of silently returning nothing.
|
|
func botVerificationPeerType(w http.ResponseWriter, raw string) (domain.PeerType, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", true
|
|
}
|
|
peerType := domain.PeerType(raw)
|
|
if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
|
|
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationTargetInvalid, "invalid peer_type")
|
|
return "", false
|
|
}
|
|
return peerType, true
|
|
}
|
|
|
|
// queryBool reads a boolean flag the way the panel writes it: an absent or empty
|
|
// value is false, and "1"/"true"/"yes" are true.
|
|
func queryBool(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// botVerificationErrorStatus maps a third-party verification failure onto its HTTP
|
|
// status.
|
|
//
|
|
// The version conflict is 409, not 400, because nothing about the request was
|
|
// wrong -- another operator simply decided first, and the panel has to answer that
|
|
// by reloading rather than by correcting input. The per-verifier bound is 409 for
|
|
// the same reason: the request was well formed and the state refused it.
|
|
func botVerificationErrorStatus(code string) int {
|
|
switch code {
|
|
case admin.CodeBotVerifierNotFound,
|
|
admin.CodeBotVerifierBotNotFound,
|
|
admin.CodeVerificationIconNotFound,
|
|
admin.CodeCustomVerificationNotFound,
|
|
admin.CodeCustomVerificationRequestNotFound:
|
|
return http.StatusNotFound
|
|
case admin.CodeCustomVerificationConflict,
|
|
admin.CodeCustomVerificationLimit,
|
|
admin.CodeCustomVerificationRequestExists:
|
|
return http.StatusConflict
|
|
case admin.CodeCustomVerificationRateLimited:
|
|
return http.StatusTooManyRequests
|
|
case admin.CodeBotVerifierForbidden,
|
|
admin.CodeBotVerifierDescriptionForbidden,
|
|
admin.CodeBotVerifierInvalid,
|
|
admin.CodeVerificationIconInactive,
|
|
admin.CodeVerificationIconInvalid,
|
|
admin.CodeCustomVerificationStatusInvalid,
|
|
admin.CodeCustomVerificationReasonRequired,
|
|
admin.CodeCustomVerificationTargetInvalid,
|
|
admin.CodeCustomVerificationTargetSystem,
|
|
admin.CodeCustomVerificationInvalid:
|
|
// BOTVERIFIER_FORBIDDEN is 400 rather than 403 on purpose: the caller is
|
|
// authorised (403 is reserved for the permission gate), it is the *subject*
|
|
// that may not verify, which the operator fixes by enabling the verifier.
|
|
return http.StatusBadRequest
|
|
default:
|
|
return http.StatusInternalServerError
|
|
}
|
|
}
|
|
|
|
func writeBotVerificationError(w http.ResponseWriter, err error) {
|
|
code := admin.BotVerificationErrorCode(err)
|
|
writeCodedError(w, botVerificationErrorStatus(code), code, err.Error())
|
|
}
|
|
|
|
// writeBotVerificationCommandResult answers a command.
|
|
//
|
|
// The body stays a CommandResult so the panel parses one shape for every operator
|
|
// action, but the status is derived from the failure: a lost optimistic-locking
|
|
// race must reach the browser as 409, because that is the one failure the panel
|
|
// resolves by reloading the row instead of by asking the operator to fix the form.
|
|
func writeBotVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
|
|
if err == nil {
|
|
writeJSON(w, http.StatusOK, result)
|
|
return
|
|
}
|
|
code := admin.BotVerificationErrorCode(err)
|
|
status := botVerificationErrorStatus(code)
|
|
if status == http.StatusInternalServerError {
|
|
// An unmapped command failure is a bad request, as everywhere else in this
|
|
// API, rather than a server fault.
|
|
status = http.StatusBadRequest
|
|
}
|
|
if result.CommandID == "" {
|
|
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
|
|
}
|
|
if result.Error == "" {
|
|
result.Error = err.Error()
|
|
}
|
|
if code == admin.CodeCustomVerificationConflict {
|
|
result.Message = "another operator changed this row first; reload it and try again"
|
|
}
|
|
writeJSON(w, status, result)
|
|
}
|