Files
larksuite-cli/shortcuts/im/helpers.go
91-enjoy 0aa9e96d18 feat: resolve markdown blank-line formatting inconsistency in post messages (#1216)
Simplifies the markdown-to-post rendering pipeline in the IM shortcut. The previous
implementation split markdown at blank-line boundaries into multiple post paragraphs,
using zero-width space (\u200B) sentinel characters to preserve visual spacing.
While well-intentioned, this approach introduced fragility around edge cases such as
blank lines inside fenced code blocks, messages with only blank lines, and interactions
with the heading-normalization pass. This change consolidates rendering back into a
single {"tag":"md"} segment, making the output more predictable, the code significantly
easier to follow, and the test surface easier to maintain.
Change-Id: Ic2870ecbcb31ae7d36121f120102f2ff964f5169
2026-06-02 17:49:45 +08:00

1408 lines
45 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
)
// normalizeAtMentions fixes common AI mistakes in @mention tags.
var mentionFixRe = regexp.MustCompile(`<at\s+(id|open_id|user_id)=("?)([^"\s/>]+)"?\s*/?>`)
var threadIDRe = regexp.MustCompile(`^omt_`)
var messageIDRe = regexp.MustCompile(`^om_`)
func flagMessageID(rt *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(rt.Str("message-id"))
if id == "" {
return "", output.ErrValidation("--message-id is required")
}
if strings.HasPrefix(id, "omt_") {
return "", output.ErrValidation(
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
}
return validateMessageID(id)
}
func normalizeAtMentions(content string) string {
return mentionFixRe.ReplaceAllString(content, `<at user_id="$3">`)
}
// buildMGetURL constructs the mget query URL for batch-fetching messages.
// Uses repeated params (?message_ids=x&message_ids=y) — RFC 6570 standard array
// encoding, shorter and more broadly compatible than indexed params ([0]=x).
func buildMGetURL(ids []string) string {
parts := make([]string, 0, len(ids)+1)
parts = append(parts, "card_msg_content_type=raw_card_content")
for _, id := range ids {
parts = append(parts, "message_ids="+url.QueryEscape(id))
}
return "/open-apis/im/v1/messages/mget?" + strings.Join(parts, "&")
}
func validateMessageID(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", output.ErrValidation("message ID cannot be empty")
}
if !strings.HasPrefix(input, "om_") {
return "", output.ErrValidation("invalid message ID %q: must start with om_", input)
}
return input, nil
}
// buildMediaContentFromKey builds (msgType, contentJSON) for DryRun purposes from flag values.
// Local paths and URLs are represented with placeholder keys because DryRun does not upload media.
func buildMediaContentFromKey(text, imageKey, fileKey, videoKey, videoCoverKey, audioKey string) (msgType, content, desc string) {
if text != "" {
jsonBytes, _ := json.Marshal(map[string]string{"text": text})
return "text", string(jsonBytes), ""
}
if videoKey != "" {
coverKey := videoCoverKey
if !isMediaKey(coverKey) {
coverKey = "img_dryrun_upload"
}
fk := videoKey
var d string
if !isMediaKey(videoKey) {
fk = "file_dryrun_upload"
d = dryRunMediaUploadDesc("--video", videoKey)
}
if videoCoverKey != "" && !isMediaKey(videoCoverKey) {
if d != "" {
d += "; "
}
d += dryRunMediaUploadDesc("--video-cover", videoCoverKey)
}
jsonBytes, _ := json.Marshal(map[string]string{"file_key": fk, "image_key": coverKey})
return "media", string(jsonBytes), d
}
if imageKey != "" {
if !isMediaKey(imageKey) {
jsonBytes, _ := json.Marshal(map[string]string{"image_key": "img_dryrun_upload"})
return "image", string(jsonBytes), dryRunMediaUploadDesc("--image", imageKey)
}
jsonBytes, _ := json.Marshal(map[string]string{"image_key": imageKey})
return "image", string(jsonBytes), ""
}
if fileKey != "" {
if !isMediaKey(fileKey) {
jsonBytes, _ := json.Marshal(map[string]string{"file_key": "file_dryrun_upload"})
return "file", string(jsonBytes), dryRunMediaUploadDesc("--file", fileKey)
}
jsonBytes, _ := json.Marshal(map[string]string{"file_key": fileKey})
return "file", string(jsonBytes), ""
}
if audioKey != "" {
if !isMediaKey(audioKey) {
jsonBytes, _ := json.Marshal(map[string]string{"file_key": "file_dryrun_upload"})
return "audio", string(jsonBytes), dryRunMediaUploadDesc("--audio", audioKey)
}
jsonBytes, _ := json.Marshal(map[string]string{"file_key": audioKey})
return "audio", string(jsonBytes), ""
}
return "", "", ""
}
// isURL returns true if the value looks like an http/https URL.
func isURL(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
func dryRunMediaUploadDesc(flagName, value string) string {
source := "local file"
if isURL(value) {
source = "URL"
}
return fmt.Sprintf("dry-run uses placeholder media keys for %s %s input; execution uploads it before sending", flagName, source)
}
// fileNameFromURL extracts a filename from a URL path, falling back to "download".
func fileNameFromURL(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil {
if u.Scheme != "http" && u.Scheme != "https" {
return "download"
}
base := path.Base(u.Path)
if base != "" && base != "." && base != "/" {
return base
}
}
return "download"
}
func sanitizeURLForDisplay(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u == nil {
return "[redacted-url]"
}
host := strings.TrimSpace(u.Hostname())
if host == "" {
return "[redacted-url]"
}
base := path.Base(u.Path)
if base == "" || base == "." || base == "/" {
base = "download"
}
return host + "/" + base
}
// startURLDownload performs URL validation, creates an HTTP client, and sends a
// GET request. It returns the response (with Body still open) and the file
// extension inferred from the URL. The caller must close resp.Body.
func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL string) (*http.Response, string, error) {
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
return nil, "", fmt.Errorf("blocked URL: %w", err)
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", fmt.Errorf("http client: %w", err)
}
httpClient = validate.NewDownloadHTTPClient(httpClient, validate.DownloadHTTPClientOptions{
AllowHTTP: true,
})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", fmt.Errorf("invalid URL: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
ext := filepath.Ext(fileNameFromURL(rawURL))
return resp, ext, nil
}
// downloadURLToReader returns a size-limited io.ReadCloser for the URL content
// and the file extension inferred from the URL. The caller must close the
// returned ReadCloser. No temp file is created and the content is not buffered.
func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (io.ReadCloser, string, error) {
resp, ext, err := startURLDownload(ctx, runtime, rawURL) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
if err != nil {
return nil, "", err
}
lr := &limitedReadCloser{
r: io.LimitReader(resp.Body, maxSize+1),
closer: resp.Body,
max: maxSize,
}
return lr, ext, nil
}
// limitedReadCloser wraps a LimitReader and checks for size overflow on Close.
type limitedReadCloser struct {
r io.Reader
closer io.Closer
max int64
n int64
}
func (l *limitedReadCloser) Read(p []byte) (int, error) {
n, err := l.r.Read(p)
l.n += int64(n)
if l.n > l.max {
return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max))
}
return n, err
}
func (l *limitedReadCloser) Close() error {
return l.closer.Close()
}
// mediaKind distinguishes image uploads (image_key) from file uploads (file_key).
type mediaKind int
const (
mediaKindImage mediaKind = iota // upload via image API, returns image_key
mediaKindFile // upload via file API, returns file_key
)
// mediaSpec describes how to resolve and upload a single media input.
type mediaSpec struct {
value string // raw input value (path, URL, or media key)
flagName string // CLI flag name for log messages, e.g. "--image"
mediaType string // human label for errors, e.g. "image"
msgType string // IM message type, e.g. "image", "file", "audio"
kind mediaKind // image vs file upload
maxSize int64 // download size limit
withDuration bool // whether to parse audio/video duration
resultKey string // JSON key for the upload result, e.g. "image_key"
}
// resolveMediaContent resolves text/media flags to (msgType, contentJSON) for Execute.
// For URL inputs, download failures fall back to sending the URL as a text link.
func resolveMediaContent(ctx context.Context, runtime *common.RuntimeContext, text, imageVal, fileVal, videoVal, videoCoverVal, audioVal string) (msgType, content string, err error) {
if text != "" {
jsonBytes, _ := json.Marshal(map[string]string{"text": text})
return "text", string(jsonBytes), nil
}
// Video is special: it produces two keys (file_key + image_key for cover).
if videoVal != "" {
return resolveVideoContent(ctx, runtime, videoVal, videoCoverVal)
}
// All other media types follow a uniform pattern: single input → single key.
specs := []mediaSpec{
{value: imageVal, flagName: "--image", mediaType: "image", msgType: "image", kind: mediaKindImage, maxSize: maxImageUploadSize, resultKey: "image_key"},
{value: fileVal, flagName: "--file", mediaType: "file", msgType: "file", kind: mediaKindFile, maxSize: maxFileUploadSize, resultKey: "file_key"},
{value: audioVal, flagName: "--audio", mediaType: "audio", msgType: "audio", kind: mediaKindFile, maxSize: maxFileUploadSize, withDuration: true, resultKey: "file_key"},
}
for _, s := range specs {
if s.value == "" {
continue
}
key, resolveErr := resolveOneMedia(ctx, runtime, s)
if resolveErr != nil {
return mediaFallbackOrError(s.value, s.mediaType, resolveErr)
}
jsonBytes, _ := json.Marshal(map[string]string{s.resultKey: key})
return s.msgType, string(jsonBytes), nil
}
return "", "", nil
}
// resolveOneMedia uploads a single media input (image, file, or audio) and
// returns the resulting key. It handles media keys, URLs, and local paths.
func resolveOneMedia(ctx context.Context, runtime *common.RuntimeContext, s mediaSpec) (string, error) {
if isMediaKey(s.value) {
return s.value, nil
}
if isURL(s.value) {
return resolveURLMedia(ctx, runtime, s)
}
return resolveLocalMedia(ctx, runtime, s)
}
// resolveURLMedia downloads a URL and uploads it.
func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s mediaSpec) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "downloading %s: %s\n", s.flagName, sanitizeURLForDisplay(s.value))
if s.kind == mediaKindImage {
rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize)
if err != nil {
return "", err
}
defer rc.Close()
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s\n", s.mediaType)
return uploadImageFromReader(ctx, runtime, rc, "message")
}
// File-kind: buffer in memory for possible duration parsing.
mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, mb.FileName())
dur := ""
if s.withDuration {
dur = mb.Duration()
}
return uploadFileFromReader(ctx, runtime, mb.Reader(), mb.FileName(), mb.FileType(), dur)
}
// resolveLocalMedia uploads a local file.
func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s mediaSpec) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value))
if s.kind == mediaKindImage {
return uploadImageToIM(ctx, runtime, s.value, "message")
}
ft := detectIMFileType(s.value)
dur := ""
if s.withDuration {
dur = parseMediaDuration(runtime, s.value, ft)
}
return uploadFileToIM(ctx, runtime, s.value, ft, dur)
}
// resolveVideoContent handles the video case which needs both a file_key and
// a cover image_key.
func resolveVideoContent(ctx context.Context, runtime *common.RuntimeContext, videoVal, videoCoverVal string) (string, string, error) {
videoSpec := mediaSpec{
value: videoVal, flagName: "--video", mediaType: "video",
kind: mediaKindFile, maxSize: maxFileUploadSize, withDuration: true, resultKey: "file_key",
}
fKey, err := resolveOneMedia(ctx, runtime, videoSpec)
if err != nil {
return mediaFallbackOrError(videoVal, "video", err)
}
coverSpec := mediaSpec{
value: videoCoverVal, flagName: "--video-cover", mediaType: "cover image",
kind: mediaKindImage, maxSize: maxImageUploadSize, resultKey: "image_key",
}
coverKey, err := resolveOneMedia(ctx, runtime, coverSpec)
if err != nil {
return "", "", fmt.Errorf("cover image upload failed: %w", err)
}
jsonBytes, _ := json.Marshal(map[string]string{"file_key": fKey, "image_key": coverKey})
return "media", string(jsonBytes), nil
}
// mediaFallbackOrError returns a text fallback for URL inputs when upload fails,
// or a hard error for local file inputs.
func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (string, string, error) {
if isURL(originalValue) {
// Fallback: send URL as text link instead of failing.
fallbackText := fmt.Sprintf("[%s upload failed, sending link] %s", mediaType, originalValue)
jsonBytes, _ := json.Marshal(map[string]string{"text": fallbackText})
return "text", string(jsonBytes), nil
}
return "", "", fmt.Errorf("%s upload failed: %w", mediaType, uploadErr)
}
// resolveP2PChatID resolves user open_id to P2P chat_id.
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
if runtime.IsBot() {
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/chat_p2p/batch_query",
QueryParams: larkcore.QueryParams{
"chatter_id_type": []string{"open_id"},
},
Body: map[string]interface{}{"chatter_ids": []string{openID}},
})
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("failed to parse chat_p2p response: %w", err)
}
data, _ := result["data"].(map[string]interface{})
chats, _ := data["p2p_chats"].([]interface{})
for _, item := range chats {
chat, _ := item.(map[string]interface{})
chatID, _ := chat["chat_id"].(string)
if chatID != "" {
return chatID, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
}
// resolveThreadID normalizes a message ID to its thread ID when possible.
func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error) {
if threadIDRe.MatchString(id) {
return id, nil
}
if !messageIDRe.MatchString(id) {
return "", output.Errorf(output.ExitValidation, "validation", "invalid thread ID format: must start with om_ or omt_")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/im/v1/messages/" + validate.EncodePathSegment(id),
})
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("failed to parse message response: %w", err)
}
data, _ := result["data"].(map[string]interface{})
items, _ := data["items"].([]interface{})
for _, item := range items {
msg, _ := item.(map[string]interface{})
threadID, _ := msg["thread_id"].(string)
if threadID != "" {
return threadID, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "thread ID not found for this message")
}
// parseOggOpusDuration parses the duration in milliseconds from an OGG/Opus
// buffer. Scans backward for the last OggS page header, reads the granule
// position, and divides by 48 000 (Opus standard sample rate).
// Returns 0 on any parse failure.
func parseOggOpusDuration(data []byte) int64 {
offset := -1
for i := len(data) - 4; i >= 0; i-- {
if data[i] == 'O' && data[i+1] == 'g' && data[i+2] == 'g' && data[i+3] == 'S' {
offset = i
break
}
}
if offset < 0 {
return 0
}
granuleOffset := offset + 6
if granuleOffset+8 > len(data) {
return 0
}
lo := binary.LittleEndian.Uint32(data[granuleOffset:])
hi := binary.LittleEndian.Uint32(data[granuleOffset+4:])
granule := uint64(hi)<<32 | uint64(lo)
if granule == 0 {
return 0
}
return int64(math.Ceil(float64(granule)/48000.0)) * 1000
}
// parseMp4Duration parses the duration in milliseconds from an MP4 buffer.
// Locates the moov→mvhd box and reads timescale + duration fields.
// Returns 0 on any parse failure.
func parseMp4Duration(data []byte) int64 {
moovStart, moovEnd := findMP4Box(data, 0, len(data), "moov")
if moovStart < 0 {
return 0
}
mvhdStart, mvhdEnd := findMP4Box(data, moovStart, moovEnd, "mvhd")
if mvhdStart < 0 {
return 0
}
return parseMvhdPayload(data[mvhdStart:mvhdEnd])
}
// parseMvhdPayload extracts duration in milliseconds from the raw mvhd box
// payload. Supports version 0 (32-bit fields) and version 1 (64-bit fields).
func parseMvhdPayload(data []byte) int64 {
if len(data) < 1 {
return 0
}
version := data[0]
var timescale, duration uint64
if version == 0 {
if len(data) < 20 {
return 0
}
timescale = uint64(binary.BigEndian.Uint32(data[12:]))
duration = uint64(binary.BigEndian.Uint32(data[16:]))
} else {
if len(data) < 32 {
return 0
}
timescale = uint64(binary.BigEndian.Uint32(data[20:]))
duration = binary.BigEndian.Uint64(data[24:])
}
if timescale == 0 || duration == 0 {
return 0
}
return int64(math.Round(float64(duration) / float64(timescale) * 1000))
}
// findMP4Box locates a box by its 4-char type within [start, end) of data.
// Returns (dataStart, dataEnd) after the box header, or (-1, -1) if not found.
func findMP4Box(data []byte, start, end int, boxType string) (int, int) {
offset := start
for offset+8 <= end {
size := int(binary.BigEndian.Uint32(data[offset:]))
typ := string(data[offset+4 : offset+8])
var boxEnd, dataStart int
switch {
case size == 0:
boxEnd = end
dataStart = offset + 8
case size == 1:
if offset+16 > end {
return -1, -1
}
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring the
// offset+size used for 32-bit boxes below). Reject sizes that do not
// fit the search window; this also rejects values that would
// overflow int and drive boxEnd negative (CWE-190), which would
// otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8:])
if largesize < 16 || largesize > uint64(end-offset) {
return -1, -1
}
boxEnd = offset + int(largesize)
dataStart = offset + 16
default:
if size < 8 {
return -1, -1
}
boxEnd = offset + size
dataStart = offset + 8
}
if typ == boxType {
if boxEnd > end {
boxEnd = end
}
return dataStart, boxEnd
}
offset = boxEnd
}
return -1, -1
}
// parseMediaDuration opens a file and returns the duration string (in ms)
// for audio/video uploads. Only reads the minimal portion of the file needed
// for parsing (tail for OGG, box headers + moov for MP4).
// Returns "" if parsing fails or the file type is not audio/video.
func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType string) string {
if fileType != "opus" && fileType != "mp4" {
return ""
}
info, err := runtime.FileIO().Stat(filePath)
if err != nil || info.Size() == 0 {
return ""
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return ""
}
var ms int64
if fileType == "opus" {
ms = readOggDuration(f, info.Size())
} else {
ms = readMp4Duration(f, info.Size())
}
if ms <= 0 {
return ""
}
return strconv.FormatInt(ms, 10)
}
// mediaBuffer holds downloaded media content in memory, providing both random
// access (for duration parsing) and an io.Reader (for upload). It replaces temp
// files for URL-sourced media that needs seek-like access before upload.
type mediaBuffer struct {
data []byte
ext string // file extension including leading dot, e.g. ".mp4"
name string // original file name extracted from the source URL
}
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (*mediaBuffer, error) {
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize)
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
return newMediaBufferFromBytes(data, ext, rawURL), nil
}
// newMediaBufferFromBytes builds a mediaBuffer from already-downloaded bytes.
// Split out from newMediaBuffer so the URL-to-filename wiring is testable
// without going through the hardened download transport.
func newMediaBufferFromBytes(data []byte, ext, rawURL string) *mediaBuffer {
return &mediaBuffer{data: data, ext: ext, name: fileNameFromURL(rawURL)}
}
// Reader returns a new io.Reader over the buffered data. Each call returns a
// fresh reader starting from the beginning, so the buffer can be read multiple
// times (once for duration parsing, once for upload).
func (b *mediaBuffer) Reader() io.Reader {
return bytes.NewReader(b.data)
}
// FileName returns the original file name extracted from the source URL.
func (b *mediaBuffer) FileName() string {
return b.name
}
// FileType returns the IM file type detected from the extension.
func (b *mediaBuffer) FileType() string {
return detectIMFileType("file" + b.ext)
}
// Duration parses audio/video duration from the buffered data.
func (b *mediaBuffer) Duration() string {
ft := b.FileType()
if ft != "opus" && ft != "mp4" {
return ""
}
if len(b.data) == 0 {
return ""
}
var ms int64
if ft == "opus" {
ms = readOggDurationBytes(b.data)
} else {
ms = readMp4DurationBytes(b.data)
}
if ms <= 0 {
return ""
}
return strconv.FormatInt(ms, 10)
}
// readOggDurationBytes parses OGG duration from the tail of in-memory data.
func readOggDurationBytes(data []byte) int64 {
const maxTail = 65536
buf := data
if len(buf) > maxTail {
buf = buf[len(buf)-maxTail:]
}
return parseOggOpusDuration(buf)
}
// readMp4DurationBytes walks top-level MP4 boxes in memory to find moov/mvhd duration.
func readMp4DurationBytes(data []byte) int64 {
fileSize := int64(len(data))
var offset int64
for offset+8 <= fileSize {
size := int64(binary.BigEndian.Uint32(data[offset : offset+4]))
typ := string(data[offset+4 : offset+8])
var boxEnd, dataStart int64
switch {
case size == 0:
boxEnd = fileSize
dataStart = offset + 8
case size == 1:
if offset+16 > fileSize {
return 0
}
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190), which would otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8 : offset+16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0
default:
boxEnd = offset + size
dataStart = offset + 8
}
if typ == "moov" {
moovLen := boxEnd - dataStart
if moovLen <= 0 || moovLen > 10<<20 || dataStart+moovLen > fileSize {
return 0
}
moov := data[dataStart : dataStart+moovLen]
mvhdStart, mvhdEnd := findMP4Box(moov, 0, len(moov), "mvhd")
if mvhdStart < 0 {
return 0
}
return parseMvhdPayload(moov[mvhdStart:mvhdEnd])
}
offset = boxEnd
}
return 0
}
// readOggDuration reads the tail of an OGG file (up to 64 KB) and parses duration.
func readOggDuration(f fileio.File, fileSize int64) int64 {
const maxTail = 65536
readSize := fileSize
if readSize > maxTail {
readSize = maxTail
}
buf := make([]byte, readSize)
if _, err := f.ReadAt(buf, fileSize-readSize); err != nil {
return 0
}
return parseOggOpusDuration(buf)
}
// readMp4Duration walks top-level MP4 boxes via file seeks to find moov,
// then reads only the moov content to locate mvhd and extract the duration.
func readMp4Duration(f fileio.File, fileSize int64) int64 {
hdr := make([]byte, 16)
var offset int64
for offset+8 <= fileSize {
if _, err := f.ReadAt(hdr[:8], offset); err != nil {
return 0
}
size := int64(binary.BigEndian.Uint32(hdr[0:4]))
typ := string(hdr[4:8])
var boxEnd, dataStart int64
switch {
case size == 0:
boxEnd = fileSize
dataStart = offset + 8
case size == 1:
if _, err := f.ReadAt(hdr[8:16], offset+8); err != nil {
return 0
}
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190).
largesize := binary.BigEndian.Uint64(hdr[8:16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0
default:
boxEnd = offset + size
dataStart = offset + 8
}
if typ == "moov" {
moovLen := boxEnd - dataStart
if moovLen <= 0 || moovLen > 10<<20 {
return 0
}
moov := make([]byte, moovLen)
if _, err := f.ReadAt(moov, dataStart); err != nil {
return 0
}
mvhdStart, mvhdEnd := findMP4Box(moov, 0, len(moov), "mvhd")
if mvhdStart < 0 {
return 0
}
return parseMvhdPayload(moov[mvhdStart:mvhdEnd])
}
offset = boxEnd
}
return 0
}
// optimizeMarkdownStyle optimizes markdown text for Feishu post rendering.
// Ported from an internal markdown-style implementation.
//
// Steps:
// 1. Extract code blocks with placeholders to protect them
// 2. Downgrade headings: H1 → H4, H2~H6 → H5 (only when H1~H3 present)
// 3. Normalize spacing between consecutive headings and tables with blank lines
// 4. Restore code blocks
// 5. Compress excess blank lines
// 6. Strip invalid image references (keep only img_xxx keys)
var (
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
)
func optimizeMarkdownStyle(text string) string {
const mark = "___CB_"
var codeBlocks []string
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
idx := len(codeBlocks)
codeBlocks = append(codeBlocks, m)
return fmt.Sprintf("%s%d___", mark, idx)
})
// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
if reHasH1toH3.MatchString(text) {
r = reH2toH6.ReplaceAllString(r, "##### $1")
r = reH1.ReplaceAllString(r, "#### $1")
}
r = reConsecH.ReplaceAllString(r, "$1\n\n$2")
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
r = reTableAfter.ReplaceAllString(r, "$1\n")
for i, block := range codeBlocks {
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
}
r = reExcessNL.ReplaceAllString(r, "\n\n")
if strings.Contains(r, "![") {
r = reInvalidImg.ReplaceAllStringFunc(r, func(m string) string {
// Extract the URL from ![alt](URL) — it starts after "(" and ends before ")"
start := strings.LastIndex(m, "(")
end := strings.LastIndex(m, ")")
if start >= 0 && end > start && strings.HasPrefix(m[start+1:end], "img_") {
return m
}
return ""
})
}
return r
}
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
func wrapMarkdownAsPost(markdown string) string {
optimized := optimizeMarkdownStyle(markdown)
inner, _ := json.Marshal(optimized)
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
}
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
// wrapMarkdownAsPostForDryRun rewrites remote markdown images to placeholder img_ keys
// so the preview matches the shape of the real request body.
func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
imageIndex := 0
rewritten := reMarkdownImage.ReplaceAllStringFunc(markdown, func(m string) string {
imageIndex++
sub := reMarkdownImage.FindStringSubmatch(m)
altStart := strings.Index(m, "[")
altEnd := strings.Index(m, "]")
alt := ""
if altStart >= 0 && altEnd > altStart {
alt = m[altStart+1 : altEnd]
}
if len(sub) < 2 {
return fmt.Sprintf("![%s](img_dryrun_%d)", alt, imageIndex)
}
return fmt.Sprintf("![%s](img_dryrun_%d)", alt, imageIndex)
})
desc = ""
if imageIndex > 0 {
desc = "dry-run uses placeholder image keys for markdown image URLs; execution downloads and uploads them before sending"
}
return wrapMarkdownAsPost(rewritten), desc
}
// resolveMarkdownAsPost resolves image URLs in markdown, applies style optimization,
// and wraps as post format JSON. Used by Execute (makes network calls).
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
optimized := optimizeMarkdownStyle(resolved)
inner, _ := json.Marshal(optimized)
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
}
// resolveMarkdownImageURLs finds ![alt](https://...) in markdown, downloads each URL,
// uploads as image, and replaces with ![alt](img_xxx). Failed uploads are stripped.
func resolveMarkdownImageURLs(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
if !strings.Contains(markdown, "![") {
return markdown
}
return reMarkdownImage.ReplaceAllStringFunc(markdown, func(m string) string {
sub := reMarkdownImage.FindStringSubmatch(m)
if len(sub) < 2 {
return m
}
imgURL := sub[1]
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
return ""
}
defer rc.Close()
fmt.Fprintf(runtime.IO().ErrOut, "uploading image from URL: %s\n", sanitizeURLForDisplay(imgURL))
imgKey, err := uploadImageFromReader(ctx, runtime, rc, "message")
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to upload image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
return ""
}
// Reconstruct ![alt](img_xxx)
altStart := strings.Index(m, "[")
altEnd := strings.Index(m, "]")
alt := ""
if altStart >= 0 && altEnd > altStart {
alt = m[altStart+1 : altEnd]
}
return fmt.Sprintf("![%s](%s)", alt, imgKey)
})
}
// validateContentFlags checks mutual exclusion between content flags (text/markdown/content)
// and media flags (image/file/video/audio). Returns an error string or "".
func validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey string) string {
mediaCount := 0
if imageKey != "" {
mediaCount++
}
if fileKey != "" {
mediaCount++
}
if videoKey != "" {
mediaCount++
}
if audioKey != "" {
mediaCount++
}
if mediaCount > 1 {
return "--image, --file, --video, --audio are mutually exclusive"
}
if videoCoverKey != "" && videoKey == "" {
return "--video-cover can only be used with --video"
}
if videoKey != "" && videoCoverKey == "" {
return "--video-cover is required when using --video (serves as the video cover)"
}
contentFlags := 0
if text != "" {
contentFlags++
}
if markdown != "" {
contentFlags++
}
if content != "" {
contentFlags++
}
if contentFlags > 1 {
return "--text, --markdown, and --content cannot be specified together"
}
if mediaCount > 0 && contentFlags > 0 {
return "--image/--file/--video/--audio cannot be used with --text, --markdown, or --content"
}
if contentFlags == 0 && mediaCount == 0 {
return "specify --content <json>, --text <plain text>, --markdown <markdown text>, or a media flag (--image/--file/--video/--audio)"
}
return ""
}
func validateExplicitMsgType(cmd *cobra.Command, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey string) string {
if cmd == nil || !cmd.Flags().Changed("msg-type") {
return ""
}
var inferred string
switch {
case text != "":
inferred = "text"
case markdown != "":
inferred = "post"
case imageKey != "":
inferred = "image"
case fileKey != "":
inferred = "file"
case videoKey != "":
inferred = "media"
case audioKey != "":
inferred = "audio"
}
if inferred == "" || msgType == inferred {
return ""
}
return fmt.Sprintf("--msg-type %q conflicts with the inferred message type %q from the selected content flag", msgType, inferred)
}
func detectIMFileType(filePath string) string {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".opus", ".ogg":
return "opus"
case ".mp4", ".mov", ".avi", ".mkv", ".webm":
return "mp4"
case ".pdf":
return "pdf"
case ".doc", ".docx":
return "doc"
case ".xls", ".xlsx", ".csv":
return "xls"
case ".ppt", ".pptx":
return "ppt"
default:
return "stream"
}
}
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) {
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize {
return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size()))
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("image_type", imageType)
fd.AddFile("image", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
}
data, _ := result["data"].(map[string]interface{})
imageKey, _ := data["image_key"].(string)
if imageKey == "" {
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
}
return imageKey, nil
}
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) {
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize {
return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size()))
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_type", fileType)
fd.AddField("file_name", filepath.Base(filePath))
if duration != "" {
fd.AddField("duration", duration)
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
}
data, _ := result["data"].(map[string]interface{})
fileKey, _ := data["file_key"].(string)
if fileKey == "" {
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
}
return fileKey, nil
}
// uploadImageFromReader uploads an image from an io.Reader (no local file needed).
func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext, r io.Reader, imageType string) (string, error) {
fd := larkcore.NewFormdata()
fd.AddField("image_type", imageType)
fd.AddFile("image", r)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
}
data, _ := result["data"].(map[string]interface{})
imageKey, _ := data["image_key"].(string)
if imageKey == "" {
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
}
return imageKey, nil
}
// uploadFileFromReader uploads a file from an io.Reader (no local file needed).
func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r io.Reader, fileName, fileType, duration string) (string, error) {
fd := larkcore.NewFormdata()
fd.AddField("file_type", fileType)
fd.AddField("file_name", fileName)
if duration != "" {
fd.AddField("duration", duration)
}
fd.AddFile("file", r)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
}
data, _ := result["data"].(map[string]interface{})
fileKey, _ := data["file_key"].(string)
if fileKey == "" {
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
}
return fileKey, nil
}
// FlagType enumerates the kind of bookmark.
// Aligned with server-side constants: Unknown=0, Feed=1, Message=2.
type FlagType int
const (
FlagTypeUnknown FlagType = 0
FlagTypeFeed FlagType = 1
FlagTypeMessage FlagType = 2
)
// ItemType enumerates the kind of thing being bookmarked.
// Server-side constants (only the types used by IM flags):
//
// default=0, thread=4, msg_thread=11.
//
// Note on the two thread-shaped item types:
// - ItemTypeThread (4) — thread inside a topic-style chat
// - ItemTypeMsgThread (11) — thread inside a regular chat
type ItemType int
const (
ItemTypeDefault ItemType = 0
ItemTypeThread ItemType = 4 // thread in a topic-style chat
ItemTypeMsgThread ItemType = 11 // thread in a regular chat
)
const (
flagWriteScope = "im:feed.flag:write"
flagReadScope = "im:feed.flag:read"
)
var (
flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...)
flagMessageReadScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
}
flagLookupScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
"im:chat:read",
}
)
func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, required []string) error {
if len(required) == 0 {
return nil
}
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
if err != nil {
return output.ErrWithHint(output.ExitAuth, "auth",
fmt.Sprintf("cannot verify required scope(s): %v", err),
flagScopeLoginHint(required))
}
if result == nil || result.Scopes == "" {
fmt.Fprintf(rt.IO().ErrOut,
"warning: cannot verify required scope(s) because token scope metadata is unavailable; API may fail if missing: %s\n",
strings.Join(required, " "))
return nil
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
flagScopeLoginHint(missing))
}
return nil
}
func flagScopeLoginHint(scopes []string) string {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(scopes, " "))
}
// flagItem is one entry in the flags API body. The server expects numeric
// enums serialized as strings.
type flagItem struct {
ItemID string `json:"item_id"`
ItemType string `json:"item_type"`
FlagType string `json:"flag_type"`
}
// parseItemID inspects an om_ prefix and returns a best-guess
// (itemType, flagType) pair. Used when the user omits the explicit enums.
// - om_xxx → (default, message)
func parseItemID(id string) (ItemType, FlagType, error) {
id = strings.TrimSpace(id)
switch {
case strings.HasPrefix(id, "om_"):
return ItemTypeDefault, FlagTypeMessage, nil
case id == "":
return 0, 0, output.ErrValidation("--message-id cannot be empty")
default:
return 0, 0, output.ErrValidation(
"cannot infer item type from id %q: expected om_ (message) prefix; "+
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
}
}
// parseItemType converts a user-facing string to the server enum.
func parseItemType(s string) (ItemType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "default":
return ItemTypeDefault, nil
case "thread":
return ItemTypeThread, nil
case "msg_thread":
return ItemTypeMsgThread, nil
}
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
}
// parseFlagType converts a user-facing string to the server enum.
func parseFlagType(s string) (FlagType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "message":
return FlagTypeMessage, nil
case "feed":
return FlagTypeFeed, nil
}
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
}
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
// Note: (ItemType, FlagType) is shorthand for (item_type, flag_type) — the two
// enum fields that determine which layer the flag operates on.
//
// Valid combinations are:
// - (default, message) — regular chat message (message-layer flag)
// - (thread, feed) — thread as feed-layer flag (topic-style chat)
// - (msg_thread, feed) — message-thread as feed-layer flag (regular chat)
func isValidCombo(it ItemType, ft FlagType) bool {
return (it == ItemTypeDefault && ft == FlagTypeMessage) ||
(it == ItemTypeThread && ft == FlagTypeFeed) ||
(it == ItemTypeMsgThread && ft == FlagTypeFeed)
}
// parseItemTypeFromRaw parses a stringified numeric item_type back to ItemType.
// Used when re-parsing the serialized enum for combo-validity checks.
// Note: Unknown values return ItemTypeDefault (0). This is safe because:
// 1. This function only parses values we serialized ourselves via newFlagItem
// 2. Unknown server values would fail combo validation or be rejected by the server
func parseItemTypeFromRaw(s string) ItemType {
switch s {
case "0":
return ItemTypeDefault
case "4":
return ItemTypeThread
case "11":
return ItemTypeMsgThread
}
return ItemTypeDefault
}
// parseFlagTypeFromRaw parses a stringified numeric flag_type back to FlagType.
// Used when re-parsing the serialized enum for combo-validity checks.
func parseFlagTypeFromRaw(s string) FlagType {
switch s {
case "1":
return FlagTypeFeed
case "2":
return FlagTypeMessage
}
return FlagTypeUnknown
}
// newFlagItem builds a payload entry with numeric-stringified enums.
func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
return flagItem{
ItemID: itemID,
ItemType: fmt.Sprintf("%d", int(it)),
FlagType: fmt.Sprintf("%d", int(ft)),
}
}
// getMessageChatID queries the message API to get the chat_id.
// Used by flag-create to determine the chat type for feed-layer flags.
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
if err != nil {
return "", err
}
items, ok := data["items"].([]any)
if !ok || len(items) == 0 {
return "", output.ErrValidation("message not found or unexpected API response format")
}
msg, ok := items[0].(map[string]any)
if !ok {
return "", output.ErrValidation("unexpected message format in API response")
}
chatID, ok := msg["chat_id"].(string)
if !ok {
return "", output.ErrValidation("message response missing chat_id field")
}
return chatID, nil
}
// resolveThreadFeedItemType determines the correct feed-layer ItemType for a thread
// by querying the chat API for chat_mode.
// - topic-style chat → ItemTypeThread
// - regular chat → ItemTypeMsgThread
//
// Returns an error if the chat query fails, since guessing the wrong item_type
// can cause silent failures in flag operations.
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
if err != nil {
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
}
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
chatMode, _ := data["chat_mode"].(string)
if chatMode == "topic" {
return ItemTypeThread, nil
}
return ItemTypeMsgThread, nil
}