442 lines
13 KiB
Go
442 lines
13 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
// Package minutesdata owns response validation and pagination facts shared by
|
|
// Minutes shortcuts. A missing or malformed collection is never treated as an
|
|
// empty successful result.
|
|
package minutesdata
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Page is one validated Minutes collection page.
|
|
type Page struct {
|
|
Items []map[string]any
|
|
HasMore bool
|
|
HasMoreKnown bool
|
|
NextToken string
|
|
}
|
|
|
|
var listKeys = []string{"itemList", "minutesList", "items", "list", "records", "dataList"}
|
|
|
|
// ParseListPage validates list_by_keyword_and_time_range and returns its
|
|
// explicit collection. An explicit [] is success; an absent/null/wrongly typed
|
|
// collection is a contract error.
|
|
func ParseListPage(data map[string]any) (Page, error) {
|
|
if err := validateEnvelope(data); err != nil {
|
|
return Page{}, err
|
|
}
|
|
containers := collectionContainers(data)
|
|
for _, container := range containers {
|
|
for _, key := range listKeys {
|
|
value, exists := container[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
items, err := mapItems(value, key)
|
|
if err != nil {
|
|
return Page{}, err
|
|
}
|
|
hasMore, known := boolField(container, "hasMore", "hasNext")
|
|
if !known {
|
|
hasMore, known = boolField(data, "hasMore", "hasNext")
|
|
}
|
|
next := stringField(container, "nextToken", "nextCursor", "pageToken")
|
|
if next == "" {
|
|
next = stringField(data, "nextToken", "nextCursor", "pageToken")
|
|
}
|
|
if hasMore && next == "" {
|
|
return Page{}, fmt.Errorf("minutes list declares another page but omits nextToken")
|
|
}
|
|
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
|
|
}
|
|
}
|
|
return Page{}, fmt.Errorf("minutes list response has no recognized collection (want result.itemList)")
|
|
}
|
|
|
|
// ParseTranscriptPage validates get_minutes_transcription. The real response
|
|
// collection is result.paragraphList and pagination uses hasNext/nextToken.
|
|
func ParseTranscriptPage(data map[string]any) (Page, error) {
|
|
if err := validateEnvelope(data); err != nil {
|
|
return Page{}, err
|
|
}
|
|
containers := collectionContainers(data)
|
|
for _, container := range containers {
|
|
value, exists := container["paragraphList"]
|
|
if !exists {
|
|
continue
|
|
}
|
|
items, err := mapItems(value, "paragraphList")
|
|
if err != nil {
|
|
return Page{}, err
|
|
}
|
|
hasMore, known := boolField(container, "hasNext", "hasMore")
|
|
if !known {
|
|
hasMore, known = boolField(data, "hasNext", "hasMore")
|
|
}
|
|
next := stringField(container, "nextToken", "nextCursor")
|
|
if next == "" {
|
|
next = stringField(data, "nextToken", "nextCursor")
|
|
}
|
|
if hasMore && next == "" {
|
|
return Page{}, fmt.Errorf("minutes transcript declares another page but omits nextToken")
|
|
}
|
|
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
|
|
}
|
|
return Page{}, fmt.Errorf("minutes transcript response has no result.paragraphList collection")
|
|
}
|
|
|
|
// ValidateArtifact rejects successful-looking but structurally empty artifact
|
|
// responses. This is intentionally artifact-specific: a generic {} cannot
|
|
// prove that the requested Minutes business data was returned.
|
|
func ValidateArtifact(name, taskUUID string, data map[string]any) error {
|
|
if err := validateEnvelope(data); err != nil {
|
|
return err
|
|
}
|
|
result, ok := data["result"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("minutes %s response has no result object", name)
|
|
}
|
|
switch name {
|
|
case "basic":
|
|
actual := TaskUUID(result)
|
|
if actual == "" {
|
|
return fmt.Errorf("minutes basic response has no task UUID")
|
|
}
|
|
if taskUUID != "" && actual != taskUUID {
|
|
return fmt.Errorf("minutes basic response task UUID mismatch")
|
|
}
|
|
case "summary":
|
|
if _, exists := result["fullSummary"]; !exists {
|
|
return fmt.Errorf("minutes summary response has no fullSummary field")
|
|
}
|
|
case "keywords":
|
|
if _, err := mapSliceField(result, "keywords"); err != nil {
|
|
return fmt.Errorf("minutes keywords response: %w", err)
|
|
}
|
|
case "todos":
|
|
if _, actionsErr := mapSliceField(result, "actions"); actionsErr != nil {
|
|
if _, todosErr := mapSliceField(result, "dingtalkTodoList"); todosErr != nil {
|
|
return fmt.Errorf("minutes todos response has neither actions nor dingtalkTodoList array")
|
|
}
|
|
}
|
|
default:
|
|
if len(result) == 0 {
|
|
return fmt.Errorf("minutes %s response result is empty", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateEnvelope exposes the common backend failure check for composite
|
|
// write/read steps that apply their own operation-specific shape validation.
|
|
func ValidateEnvelope(data map[string]any) error {
|
|
return validateEnvelope(data)
|
|
}
|
|
|
|
// Basic returns the validated basic-info result.
|
|
func Basic(taskUUID string, data map[string]any) (map[string]any, error) {
|
|
if err := ValidateArtifact("basic", taskUUID, data); err != nil {
|
|
return nil, err
|
|
}
|
|
return data["result"].(map[string]any), nil
|
|
}
|
|
|
|
// SummaryText returns the full summary, including an explicitly empty string.
|
|
func SummaryText(data map[string]any) (string, error) {
|
|
if err := ValidateArtifact("summary", "", data); err != nil {
|
|
return "", err
|
|
}
|
|
value, ok := data["result"].(map[string]any)["fullSummary"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("minutes summary fullSummary has type %T, want string", data["result"].(map[string]any)["fullSummary"])
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// MediaURL returns the actual audio/video download URL. An empty result is not
|
|
// accepted as "no media" because it cannot distinguish processing, expiry and
|
|
// permission failures.
|
|
func MediaURL(data map[string]any) (string, error) {
|
|
if err := validateEnvelope(data); err != nil {
|
|
return "", err
|
|
}
|
|
result, ok := data["result"].(map[string]any)
|
|
if !ok {
|
|
return "", fmt.Errorf("minutes media response has no result object")
|
|
}
|
|
value := stringField(result, "videoUrl", "audioUrl", "downloadUrl", "url")
|
|
if value == "" {
|
|
return "", fmt.Errorf("minutes media response has no audio/video URL")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// UploadSession extracts the two facts required to continue a local upload.
|
|
func UploadSession(data map[string]any) (sessionID, presignedURL string, err error) {
|
|
if err := validateEnvelope(data); err != nil {
|
|
return "", "", err
|
|
}
|
|
result, ok := data["result"].(map[string]any)
|
|
if !ok {
|
|
return "", "", fmt.Errorf("minutes upload create response has no result object")
|
|
}
|
|
sessionID = stringField(result, "sessionId", "uploadId")
|
|
presignedURL = stringField(result, "presignedUrl", "uploadUrl")
|
|
if sessionID == "" || presignedURL == "" {
|
|
return "", "", fmt.Errorf("minutes upload create response is missing sessionId or presignedUrl")
|
|
}
|
|
return sessionID, presignedURL, nil
|
|
}
|
|
|
|
// CompletedTaskUUID extracts the created Minutes task UUID from upload complete.
|
|
func CompletedTaskUUID(data map[string]any) (string, error) {
|
|
if err := validateEnvelope(data); err != nil {
|
|
return "", err
|
|
}
|
|
containers := collectionContainers(data)
|
|
for _, container := range containers {
|
|
if uuid := TaskUUID(container); uuid != "" {
|
|
return uuid, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("minutes upload complete response has no task UUID")
|
|
}
|
|
|
|
// ProjectList converts validated list items into the stable Shortcut view.
|
|
// Every row must carry a real task UUID; malformed rows fail the entire page
|
|
// instead of disappearing and masquerading as an empty result.
|
|
func ProjectList(page Page) ([]map[string]any, error) {
|
|
out := make([]map[string]any, 0, len(page.Items))
|
|
for index, item := range page.Items {
|
|
uuid := TaskUUID(item)
|
|
if uuid == "" {
|
|
return nil, fmt.Errorf("minutes list item %d has no task UUID", index)
|
|
}
|
|
row := map[string]any{"taskUuid": uuid}
|
|
copyFirst(row, "title", item, "title", "name")
|
|
copyFirst(row, "creator", item, "creator", "creatorName", "createUserName", "creatorNick")
|
|
copyFirst(row, "startTime", item, "startTime", "gmtStart", "beginTime", "createTime")
|
|
copyFirst(row, "endTime", item, "endTime", "gmtEnd", "deadline")
|
|
copyFirst(row, "url", item, "url", "shareUrl", "link")
|
|
copyFirst(row, "status", item, "status", "taskStatus", "state")
|
|
out = append(out, row)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// TaskUUID returns only spellings known to identify a Minutes task. A generic
|
|
// id/minutesId is deliberately not accepted because record-control consumes
|
|
// this value directly as the task UUID.
|
|
func TaskUUID(item map[string]any) string {
|
|
return stringField(item, "taskUuid", "taskUUID", "task_uuid", "uuid")
|
|
}
|
|
|
|
// LatestTaskUUID selects the newest item by an explicit comparable timestamp.
|
|
// It never silently assumes that an undocumented response order is newest-first.
|
|
func LatestTaskUUID(page Page) (string, error) {
|
|
if len(page.Items) == 0 {
|
|
return "", nil
|
|
}
|
|
bestUUID := ""
|
|
var bestTime float64
|
|
haveTime := false
|
|
for index, item := range page.Items {
|
|
uuid := TaskUUID(item)
|
|
if uuid == "" {
|
|
return "", fmt.Errorf("minutes list item %d has no task UUID", index)
|
|
}
|
|
value, ok := timestamp(item, "createTime", "gmtCreate", "startTime", "createTimeStart")
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !haveTime || value > bestTime {
|
|
haveTime = true
|
|
bestTime = value
|
|
bestUUID = uuid
|
|
}
|
|
}
|
|
if !haveTime {
|
|
return "", fmt.Errorf("minutes list is non-empty but no item has a comparable creation/start time")
|
|
}
|
|
return bestUUID, nil
|
|
}
|
|
|
|
// StableItemKey returns a deterministic de-duplication key for a transcript
|
|
// paragraph. Stable IDs win; otherwise the canonical JSON object is used.
|
|
func StableItemKey(item map[string]any) (string, error) {
|
|
if id := stringField(item, "paragraphId", "sentenceId", "segmentId", "id"); id != "" {
|
|
return "id:" + id, nil
|
|
}
|
|
raw, err := json.Marshal(item)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode transcript paragraph key: %w", err)
|
|
}
|
|
return "json:" + string(raw), nil
|
|
}
|
|
|
|
func validateEnvelope(data map[string]any) error {
|
|
if len(data) == 0 {
|
|
return fmt.Errorf("minutes response is empty")
|
|
}
|
|
if success, ok := data["success"].(bool); ok && !success {
|
|
return fmt.Errorf("minutes backend reported failure: %s", envelopeMessage(data))
|
|
}
|
|
for _, key := range []string{"errorCode", "dingOpenErrcode"} {
|
|
value, exists := data[key]
|
|
if !exists || isZeroCode(value) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("minutes backend reported %s=%v: %s", key, value, envelopeMessage(data))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectionContainers(data map[string]any) []map[string]any {
|
|
containers := []map[string]any{data}
|
|
for _, key := range []string{"result", "data"} {
|
|
if inner, ok := data[key].(map[string]any); ok {
|
|
containers = append(containers, inner)
|
|
for _, nestedKey := range []string{"result", "data"} {
|
|
if nested, ok := inner[nestedKey].(map[string]any); ok {
|
|
containers = append(containers, nested)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return containers
|
|
}
|
|
|
|
func mapItems(value any, field string) ([]map[string]any, error) {
|
|
raw, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("minutes response field %s has type %T, want array", field, value)
|
|
}
|
|
items := make([]map[string]any, 0, len(raw))
|
|
for index, value := range raw {
|
|
item, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("minutes response field %s item %d has type %T, want object", field, index, value)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func mapSliceField(data map[string]any, field string) ([]any, error) {
|
|
value, exists := data[field]
|
|
if !exists {
|
|
return nil, fmt.Errorf("missing %s", field)
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("field %s has type %T, want array", field, value)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func copyFirst(dst map[string]any, dstKey string, src map[string]any, keys ...string) {
|
|
for _, key := range keys {
|
|
if value, ok := src[key]; ok && value != nil {
|
|
dst[dstKey] = value
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func boolField(data map[string]any, keys ...string) (bool, bool) {
|
|
for _, key := range keys {
|
|
if value, ok := data[key].(bool); ok {
|
|
return value, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func stringField(data map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
value, exists := data[key]
|
|
if !exists || value == nil {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if strings.TrimSpace(typed) != "" {
|
|
return strings.TrimSpace(typed)
|
|
}
|
|
case json.Number:
|
|
return typed.String()
|
|
case float64:
|
|
return strconv.FormatFloat(typed, 'f', -1, 64)
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case int64:
|
|
return strconv.FormatInt(typed, 10)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func timestamp(data map[string]any, keys ...string) (float64, bool) {
|
|
for _, key := range keys {
|
|
value, exists := data[key]
|
|
if !exists || value == nil {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return typed, true
|
|
case int:
|
|
return float64(typed), true
|
|
case int64:
|
|
return float64(typed), true
|
|
case json.Number:
|
|
if parsed, err := typed.Float64(); err == nil {
|
|
return parsed, true
|
|
}
|
|
case string:
|
|
if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil {
|
|
return parsed, true
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(typed)); err == nil {
|
|
return float64(parsed.UnixNano()), true
|
|
}
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func isZeroCode(value any) bool {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
trimmed := strings.TrimSpace(typed)
|
|
return trimmed == "" || trimmed == "0"
|
|
case float64:
|
|
return typed == 0
|
|
case int:
|
|
return typed == 0
|
|
case int64:
|
|
return typed == 0
|
|
case json.Number:
|
|
return typed.String() == "0"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func envelopeMessage(data map[string]any) string {
|
|
for _, key := range []string{"errorMsg", "message", "msg"} {
|
|
if value := stringField(data, key); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return "no backend error message"
|
|
}
|