// 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(`]+)"?\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, ``) } // 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 , --text , --markdown , 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 }