Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

162 lines
6.6 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
// ResourceRef is a downloadable resource reference extracted from a message
// during formatting. Type is the download API resource type ("image" or
// "file"); MessageID is the message id used as the download API path parameter.
// For a standalone message that is the message's own id; for a resource carried
// inside a merge_forward it is the TOP-LEVEL container's id, because the
// download API addresses forwarded resources by the container, not the sub-item
// (see extractMergeForwardResourceRefs). The extract stage fills these three
// fields only — local_path and size_bytes are filled later by the download
// enrichment stage.
type ResourceRef struct {
MessageID string
Key string
Type string
}
// ExtractResourceRefs returns the downloadable resource refs carried by a
// single message's raw content. It is a pure function (no IO/runtime).
//
// Type mapping (design GAP-002):
// - image, post img (image_key) -> type "image"
// - file, audio, video, media, post media -> type "file"
// - sticker -> never extracted (unsupported)
//
// For merge_forward the sub-items are not standalone message nodes, so this
// folds them in from mergeSub (the pre-fetched flat sub-item list keyed by the
// merge_forward message_id); each sub-item ref carries the TOP-LEVEL container's
// message_id, since the download API rejects sub-item ids (234003 File not in
// msg) and can only fetch a forwarded resource through the container.
// Refs without a usable key are skipped.
func ExtractResourceRefs(msgType, rawContent, messageID string, mergeSub map[string][]map[string]interface{}) []ResourceRef {
return extractResourceRefs(msgType, rawContent, messageID, mergeSub, nil)
}
// extractResourceRefs is ExtractResourceRefs with a visited set threaded through
// merge_forward recursion. The set guards against self-referential or cyclic
// prefetch maps — a real merge_forward's flat sub-item list can include the
// container itself (or a nested merge_forward that points back), which would
// otherwise recurse until the stack overflows.
func extractResourceRefs(msgType, rawContent, messageID string, mergeSub map[string][]map[string]interface{}, visited map[string]bool) []ResourceRef {
switch msgType {
case "image":
if key := jsonStringField(rawContent, "image_key"); key != "" {
return []ResourceRef{{MessageID: messageID, Key: key, Type: "image"}}
}
case "file", "audio", "video", "media":
if key := jsonStringField(rawContent, "file_key"); key != "" {
return []ResourceRef{{MessageID: messageID, Key: key, Type: "file"}}
}
case "post":
return extractPostResourceRefs(rawContent, messageID)
case "merge_forward":
return extractMergeForwardResourceRefs(messageID, mergeSub, visited)
}
return nil
}
// extractPostResourceRefs walks a post body's elements and collects img/media
// resource refs.
func extractPostResourceRefs(rawContent, messageID string) []ResourceRef {
parsed, err := ParseJSONObject(rawContent)
if err != nil || parsed == nil {
return nil
}
body := unwrapPostLocale(parsed)
if body == nil {
return nil
}
blocks, _ := body["content"].([]interface{})
var refs []ResourceRef
for _, para := range blocks {
elems, _ := para.([]interface{})
for _, el := range elems {
elem, _ := el.(map[string]interface{})
if elem == nil {
continue
}
switch tag, _ := elem["tag"].(string); tag {
case "img":
if key, _ := elem["image_key"].(string); key != "" {
refs = append(refs, ResourceRef{MessageID: messageID, Key: key, Type: "image"})
}
case "media":
if key, _ := elem["file_key"].(string); key != "" {
refs = append(refs, ResourceRef{MessageID: messageID, Key: key, Type: "file"})
}
}
}
}
return refs
}
// extractMergeForwardResourceRefs folds resources from a merge_forward's
// pre-fetched sub-items. Every collected ref carries the TOP-LEVEL container's
// message_id (messageID here), NOT the sub-item's own id: the resources endpoint
// (GET /open-apis/im/v1/messages/:message_id/resources/:file_key) rejects a
// sub-item id with "234003 File not in msg" and can only fetch a forwarded
// resource through the container that was actually retrieved from the chat.
//
// A sub-item that is itself a merge_forward recurses through the same prefetch
// map (absent keys yield nothing, fail-silent) while keeping the same top-level
// owner — nested merge_forward ids are virtual sub-items too, so they cannot
// own a download either. The visited set breaks cycles: a real API sub-item
// list can contain the container's own id or a back-pointing merge_forward, so
// we expand each merge_forward id at most once.
func extractMergeForwardResourceRefs(messageID string, mergeSub map[string][]map[string]interface{}, visited map[string]bool) []ResourceRef {
return collectMergeForwardResourceRefs(messageID, messageID, mergeSub, visited)
}
// collectMergeForwardResourceRefs expands the merge_forward identified by
// lookupID and returns its leaf resource refs, each addressed for download by
// ownerID — the top-level merge_forward container the caller fetched. ownerID
// stays fixed across nested merge_forwards while lookupID descends into them.
func collectMergeForwardResourceRefs(ownerID, lookupID string, mergeSub map[string][]map[string]interface{}, visited map[string]bool) []ResourceRef {
if visited == nil {
visited = make(map[string]bool)
}
if visited[lookupID] {
return nil
}
visited[lookupID] = true
subItems := mergeSub[lookupID]
if len(subItems) == 0 {
return nil
}
var refs []ResourceRef
for _, sub := range subItems {
subType, _ := sub["msg_type"].(string)
subID, _ := sub["message_id"].(string)
subRaw := ""
if body, ok := sub["body"].(map[string]interface{}); ok {
subRaw, _ = body["content"].(string)
}
if subType == "merge_forward" {
// Nested merge_forward: descend by its own id, but keep downloading
// every leaf through the same top-level container (ownerID).
refs = append(refs, collectMergeForwardResourceRefs(ownerID, subID, mergeSub, visited)...)
continue
}
// Leaf sub-item: its resources download through the container (ownerID),
// not subID.
refs = append(refs, extractResourceRefs(subType, subRaw, ownerID, mergeSub, visited)...)
}
return refs
}
// jsonStringField parses raw as a JSON object and returns the named string
// field, or "" if parsing fails or the field is missing/non-string.
func jsonStringField(raw, field string) string {
parsed, err := ParseJSONObject(raw)
if err != nil {
return ""
}
s, _ := parsed[field].(string)
return s
}