From e188d8b8426f453d61265be4ff9db898e4c63257 Mon Sep 17 00:00:00 2001 From: "zhangjun.1" Date: Fri, 24 Jul 2026 11:52:23 +0800 Subject: [PATCH] feat: event description support rich text Co-authored-by: Cursor --- shortcuts/calendar/calendar_agenda.go | 2 + shortcuts/calendar/calendar_create.go | 10 +- shortcuts/calendar/calendar_get.go | 3 +- shortcuts/calendar/calendar_test.go | 130 +++++++- shortcuts/calendar/calendar_update.go | 42 ++- shortcuts/calendar/description_rich_images.go | 172 +++++++++++ .../calendar/description_rich_images_test.go | 279 ++++++++++++++++++ shortcuts/calendar/helpers.go | 17 ++ skills/lark-calendar/SKILL.md | 2 + .../references/lark-calendar-create.md | 3 +- .../references/lark-calendar-recurring.md | 4 +- .../references/lark-calendar-update.md | 10 +- .../calendar_description_rich_dryrun_test.go | 119 ++++++++ 13 files changed, 774 insertions(+), 19 deletions(-) create mode 100644 shortcuts/calendar/description_rich_images.go create mode 100644 shortcuts/calendar/description_rich_images_test.go create mode 100644 tests/cli_e2e/calendar/calendar_description_rich_dryrun_test.go diff --git a/shortcuts/calendar/calendar_agenda.go b/shortcuts/calendar/calendar_agenda.go index 225727d023..b8fc794dc3 100644 --- a/shortcuts/calendar/calendar_agenda.go +++ b/shortcuts/calendar/calendar_agenda.go @@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{ } } + backfillDescriptionRich(e) + filtered = append(filtered, e) } } diff --git a/shortcuts/calendar/calendar_create.go b/shortcuts/calendar/calendar_create.go index 2599ebd2eb..18017b81cf 100644 --- a/shortcuts/calendar/calendar_create.go +++ b/shortcuts/calendar/calendar_create.go @@ -20,7 +20,6 @@ import ( func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} { eventData := map[string]interface{}{ "summary": runtime.Str("summary"), - "description": runtime.Str("description"), "start_time": map[string]string{"timestamp": startTs}, "end_time": map[string]string{"timestamp": endTs}, "attendee_ability": "can_modify_event", @@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s if rrule := runtime.Str("rrule"); rrule != "" { eventData["recurrence"] = rrule } + if descriptionRich := descriptionRichToSend(runtime); descriptionRich != "" { + eventData["description_rich"] = descriptionRich + } return eventData } @@ -118,7 +120,8 @@ var CalendarCreate = common.Shortcut{ {Name: "summary", Desc: "event title"}, {Name: "start", Desc: "start time (ISO 8601)", Required: true}, {Name: "end", Desc: "end time (ISO 8601)", Required: true}, - {Name: "description", Desc: "event description"}, + {Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true}, + {Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `
`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a
2. b`, `- x
- y`, `![p](url)
**bold**`).", Input: []string{common.File, common.Stdin}}, {Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"}, {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, {Name: "rrule", Desc: "recurrence rule (rfc5545)"}, @@ -231,6 +234,9 @@ var CalendarCreate = common.Shortcut{ if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") } + if err := resolveDescriptionRichImages(runtime, calendarId); err != nil { + return err + } eventData := buildEventData(runtime, startTs, endTs) diff --git a/shortcuts/calendar/calendar_get.go b/shortcuts/calendar/calendar_get.go index a49fb1fbb9..f9e50099e3 100644 --- a/shortcuts/calendar/calendar_get.go +++ b/shortcuts/calendar/calendar_get.go @@ -81,6 +81,7 @@ type calendarEvent struct { OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` + DescriptionRich string `json:"description_rich,omitempty"` StartTime *calendarEventTime `json:"start_time,omitempty"` EndTime *calendarEventTime `json:"end_time,omitempty"` VChat *calendarEventVChat `json:"vchat,omitempty"` @@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err if status, _ := out["status"].(string); status != "cancelled" { delete(out, "status") } - + backfillDescriptionRich(out) return out, nil } diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index b6dec52f47..121bd76569 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -988,9 +988,14 @@ func TestUpdate_PatchEventOnly(t *testing.T) { if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { t.Fatalf("unmarshal captured patch body: %v", err) } - if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" { + // The deprecated, hidden --description folds into description_rich; the CLI + // never sends the plain description field (mutually exclusive downstream). + if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" { t.Fatalf("unexpected patch body: %#v", body) } + if _, ok := body["description"]; ok { + t.Fatalf("plain description must not be sent, got: %#v", body) + } if body["need_notification"] != false { t.Fatalf("need_notification = %#v, want false", body["need_notification"]) } @@ -1364,6 +1369,63 @@ func TestAgenda_Success(t *testing.T) { } } +func TestAgenda_UnifiesDescriptionRich(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "event_id": "evt_rich", + "summary": "Rich", + "status": "confirmed", + "description": "[测试]\n友情提醒", + "description_rich": "友情提醒", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + map[string]interface{}{ + "event_id": "evt_plain", + "summary": "Plain", + "status": "confirmed", + "description": "just text", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + // Read keeps both fields: description (plain) and description_rich (rich). + if !strings.Contains(out, "\"description\": \"[测试]\\n友情提醒\"") { + t.Errorf("expected plain description retained, got: %s", out) + } + if !strings.Contains(out, "\"description_rich\": \"友情提醒\"") { + t.Errorf("expected rich description surfaced, got: %s", out) + } + if !strings.Contains(out, "\"description\": \"just text\"") { + t.Errorf("expected plain description retained for plain-only event, got: %s", out) + } + if !strings.Contains(out, "\"description_rich\": \"just text\"") { + t.Errorf("expected description_rich backfilled from plain, got: %s", out) + } +} + func TestAgenda_EmptyResult(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) @@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) { } } +func TestGet_UnifiesDescriptionRich(t *testing.T) { + // Read keeps both description (plain) and description_rich (rich). + t.Run("rich present", func(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_rich", + "summary": "Rich", + "description": "[表格]", + "description_rich": "| a | b |\n| --- | --- |\n| c | d |", + "start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"}, + "end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"}, + }, + }, + }, + }) + if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "\"description\": \"[表格]\"") { + t.Errorf("expected plain description retained, got: %s", out) + } + if !strings.Contains(out, "\"description_rich\":") { + t.Errorf("expected description_rich in output, got: %s", out) + } + }) + + // When only a plain description exists, description_rich is backfilled from it + // and the plain description is still returned. + t.Run("only plain backfills rich", func(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_plain", + "summary": "Plain", + "description": "just text", + "start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"}, + "end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"}, + }, + }, + }, + }) + if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "\"description\": \"just text\"") { + t.Errorf("expected plain description retained, got: %s", out) + } + if !strings.Contains(out, "\"description_rich\": \"just text\"") { + t.Errorf("expected description_rich backfilled from plain, got: %s", out) + } + }) +} + func TestGet_CancelledStatus_PreservesStatus(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) diff --git a/shortcuts/calendar/calendar_update.go b/shortcuts/calendar/calendar_update.go index 08ae66f78f..728e29aca5 100644 --- a/shortcuts/calendar/calendar_update.go +++ b/shortcuts/calendar/calendar_update.go @@ -29,7 +29,8 @@ var CalendarUpdate = common.Shortcut{ {Name: "event-id", Desc: "event ID to update", Required: true}, {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, {Name: "summary", Desc: "event title"}, - {Name: "description", Desc: "event description"}, + {Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true}, + {Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `
`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a
2. b`, `- x
- y`, `![p](url)
**bold**`).", Input: []string{common.File, common.Stdin}}, {Name: "start", Desc: "new start time (ISO 8601); requires --end"}, {Name: "end", Desc: "new end time (ISO 8601); requires --start"}, {Name: "rrule", Desc: "recurrence rule (rfc5545)"}, @@ -71,7 +72,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error { return err } if !hasCalendarUpdateOperation(runtime) { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids") } return nil } @@ -109,11 +110,16 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in body := map[string]interface{}{} hasFields := false - for _, field := range []string{"summary", "description"} { - if runtime.Cmd.Flags().Changed(field) { - body[field] = runtime.Str(field) - hasFields = true - } + if runtime.Cmd.Flags().Changed("summary") { + body["summary"] = runtime.Str("summary") + hasFields = true + } + if runtime.Cmd.Flags().Changed("description-rich") { + body["description_rich"] = runtime.Str("description-rich") + hasFields = true + } else if runtime.Cmd.Flags().Changed("description") { + body["description_rich"] = runtime.Str("description") + hasFields = true } if runtime.Cmd.Flags().Changed("rrule") { rrule := strings.TrimSpace(runtime.Str("rrule")) @@ -356,6 +362,15 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext) return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id") } + // Upload any local images referenced in --description-rich and rewrite them + // to drive URLs before the description is sent (the service cannot read + // local files). + if runtime.Cmd.Flags().Changed("description-rich") { + if err := resolveDescriptionRichImages(runtime, calendarID); err != nil { + return err + } + } + body, hasEventFields, err := buildCalendarUpdateEventData(runtime) if err != nil { return err @@ -428,9 +443,20 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou if summary, _ := event["summary"].(string); summary != "" { result["summary"] = summary } - if description, _ := event["description"].(string); description != "" { + // Surface both description fields on read: description holds plain text, + // description_rich holds the rich (Markdown) version, backfilled from plain + // when the service returned no rich value. + description, _ := event["description"].(string) + if description != "" { result["description"] = description } + descriptionRich, _ := event["description_rich"].(string) + if descriptionRich == "" { + descriptionRich = description + } + if descriptionRich != "" { + result["description_rich"] = descriptionRich + } if start := formatCalendarEventTime(event["start_time"]); start != "" { result["start"] = start } diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go new file mode 100644 index 0000000000..1254cd2205 --- /dev/null +++ b/shortcuts/calendar/description_rich_images.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "fmt" + "image" + + // Register the common image decoders so DecodeConfig can read intrinsic + // dimensions for PNG/JPEG/GIF sources. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "net/url" + "path/filepath" + "regexp" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const calendarMediaParentType = "calendar" + +var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`) + +func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID string) error { + md := runtime.Str("description-rich") + if md == "" || !strings.Contains(md, "![") { + return nil + } + rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md) + if err != nil { + return err + } + if changed { + if err := runtime.Cmd.Flags().Set("description-rich", rewritten); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description-rich after image upload: %v", err).WithCause(err) + } + } + return nil +} + +func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) { + matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1) + if len(matches) == 0 { + return md, false, nil + } + var out strings.Builder + last := 0 + changed := false + cache := map[string]string{} + for _, m := range matches { + altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5] + src := strings.TrimSpace(md[srcStart:srcEnd]) + if !isLocalImageSrc(src) { + continue + } + alt := md[altStart:altEnd] + uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache) + if err != nil { + return "", false, err + } + out.WriteString(md[last:srcStart]) + out.WriteString(uploadedURL) + last = srcEnd + changed = true + } + if !changed { + return md, false, nil + } + out.WriteString(md[last:]) + return out.String(), true, nil +} + +func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) { + localPath := localImagePath(src) + if cached, ok := cache[localPath]; ok { + return cached, nil + } + + safePath, err := validate.SafeInputPath(localPath) + if err != nil { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--description-rich image %q could not be read: %v", src, err). + WithParam("--description-rich"). + WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL"). + WithCause(err) + } + + info, err := runtime.FileIO().Stat(localPath) + if err != nil { + return "", common.WrapInputStatErrorTyped(err) + } + + fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ + FilePath: localPath, + FileName: filepath.Base(safePath), + FileSize: info.Size(), + ParentType: calendarMediaParentType, + ParentNode: &calendarID, + }) + if err != nil { + return "", err + } + + width, height := decodeImageDimensions(runtime, localPath) + uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size()) + cache[localPath] = uploadedURL + return uploadedURL, nil +} + +func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) { + f, err := runtime.FileIO().Open(path) + if err != nil { + return 0, 0 + } + defer f.Close() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, 0 + } + return cfg.Width, cfg.Height +} + +func isLocalImageSrc(src string) bool { + if src == "" { + return false + } + lower := strings.ToLower(src) + switch { + case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"): + return false + case strings.HasPrefix(lower, "file://"): + return true + } + if i := strings.Index(src, "://"); i > 0 { + return false + } + return true +} + +func localImagePath(src string) string { + s := strings.TrimSpace(src) + if strings.HasPrefix(strings.ToLower(s), "file://") { + if u, err := url.Parse(s); err == nil && u.Path != "" { + s = u.Path + } + } + if decoded, err := url.PathUnescape(s); err == nil { + return decoded + } + return s +} + +func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string { + host := "internal-api-drive-stream.larkoffice.com" + if brand == core.BrandLark { + host = "internal-api-drive-stream.larksuite.com" + } + u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken) + if width > 0 && height > 0 { + u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height) + } + if size > 0 { + u += fmt.Sprintf("&im_size=%d", size) + } + return u +} diff --git a/shortcuts/calendar/description_rich_images_test.go b/shortcuts/calendar/description_rich_images_test.go new file mode 100644 index 0000000000..03690c3e11 --- /dev/null +++ b/shortcuts/calendar/description_rich_images_test.go @@ -0,0 +1,279 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "bytes" + "encoding/json" + "errors" + "image" + "image/png" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestIsLocalImageSrc(t *testing.T) { + cases := []struct { + src string + want bool + }{ + {"./images/pic.png", true}, + {"images/pic.png", true}, + {"../assets/a.png", true}, + {"/Users/me/Desktop/a.png", true}, + {`C:\Users\me\a.png`, true}, + {"file:///Users/me/a.png", true}, + {"图片和附件/测试图片.png", true}, + {"https://example.com/a.png", false}, + {"http://example.com/a.png", false}, + {"HTTPS://EXAMPLE.com/a.png", false}, + {"data:image/png;base64,iVBOR", false}, + {"ftp://host/a.png", false}, + {"", false}, + } + for _, c := range cases { + if got := isLocalImageSrc(c.src); got != c.want { + t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want) + } + } +} + +func TestLocalImagePath(t *testing.T) { + cases := []struct{ in, want string }{ + {"images/pic.png", "images/pic.png"}, + {"images/my%20pic.png", "images/my pic.png"}, + {"file:///Users/me/a.png", "/Users/me/a.png"}, + } + for _, c := range cases { + if got := localImagePath(c.in); got != c.want { + t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service +// relies on: a Lark host (so token extraction triggers) whose final path +// segment is exactly the uploaded file token. +func TestBuildCalendarImagePreviewURL(t *testing.T) { + for _, tc := range []struct { + brand core.LarkBrand + hostFrag string + }{ + {core.BrandFeishu, "larkoffice"}, + {core.BrandLark, "larksuite"}, + } { + raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568) + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("built URL not parseable: %v", err) + } + if !strings.Contains(u.Host, tc.hostFrag) { + t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag) + } + segs := strings.Split(strings.Trim(u.Path, "/"), "/") + if last := segs[len(segs)-1]; last != "boxcnTOKEN123" { + t.Errorf("last path segment = %q, want token", last) + } + q := u.Query() + if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" { + t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size")) + } + } + + // With unknown dimensions the helper params are omitted entirely. + raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0) + if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") { + t.Errorf("expected no dimension params for unknown size, got %q", raw) + } +} + +// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images +// pass through unchanged and never trigger an upload (runtime unused → nil). +func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) { + md := "text ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)" + got, changed, err := uploadLocalDescriptionImages(nil, "cal", md) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if changed { + t.Errorf("changed = true, want false") + } + if got != md { + t.Errorf("markdown mutated: %q", got) + } +} + +// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path, +// mocks the drive upload, and asserts the create body's description_rich carries +// the uploaded token (not the local path). +func TestCreate_UploadsLocalDescriptionImage(t *testing.T) { + dir := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(orig) + if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil { + t.Fatal(err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}}, + } + reg.Register(uploadStub) + + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_001", + "summary": "Pic", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + }}, + } + reg.Register(createStub) + + runErr := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Pic", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--description-rich", "![pic](./pic.png)", + "--as", "bot", + }, f, stdout) + if runErr != nil { + t.Fatalf("unexpected error: %v", runErr) + } + + if uploadStub.CapturedBody == nil { + t.Fatalf("expected drive upload to be called") + } + if createStub.CapturedBody == nil { + t.Fatalf("expected create event to be called") + } + var body map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil { + t.Fatalf("create body unmarshal: %v", err) + } + dr, _ := body["description_rich"].(string) + if !strings.Contains(dr, "boxcnTOKEN123") { + t.Fatalf("description_rich should contain uploaded token, got %q", dr) + } + if strings.Contains(dr, "./pic.png") { + t.Fatalf("local path should be rewritten away, got %q", dr) + } +} + +// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's +// intrinsic width/height and byte size are appended to the rewritten drive URL +// (so the facade can populate originalWidth/originalHeight and the client can +// render the image inline). +func TestCreate_LocalImageCarriesDimensions(t *testing.T) { + dir := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(orig) + + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil { + t.Fatal(err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}}, + }) + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_001", + "summary": "Pic", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + }}, + } + reg.Register(createStub) + + runErr := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Pic", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--description-rich", "![pic](./pic.png)", + "--as", "bot", + }, f, stdout) + if runErr != nil { + t.Fatalf("unexpected error: %v", runErr) + } + var body map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil { + t.Fatalf("create body unmarshal: %v", err) + } + dr, _ := body["description_rich"].(string) + if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") { + t.Fatalf("description_rich should carry image dimensions, got %q", dr) + } + if !strings.Contains(dr, "im_size=") { + t.Fatalf("description_rich should carry image byte size, got %q", dr) + } +} + +// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path +// yields a typed --description-rich validation error before any API call. +func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + runErr := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Pic", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--description-rich", "![p](/etc/hosts)", + "--as", "bot", + }, f, stdout) + if runErr == nil { + t.Fatalf("expected error for absolute image path") + } + var ve *errs.ValidationError + if !errors.As(runErr, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr) + } + if ve.Param != "--description-rich" { + t.Errorf("param = %q, want --description-rich", ve.Param) + } +} diff --git a/shortcuts/calendar/helpers.go b/shortcuts/calendar/helpers.go index b9511fea42..421a58fa9c 100644 --- a/shortcuts/calendar/helpers.go +++ b/shortcuts/calendar/helpers.go @@ -30,6 +30,23 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) { return startInput, endInput } +func backfillDescriptionRich(event map[string]interface{}) { + if event == nil { + return + } + if descRich, _ := event["description_rich"].(string); descRich == "" { + if desc, _ := event["description"].(string); desc != "" { + event["description_rich"] = desc + } + } +} +func descriptionRichToSend(runtime *common.RuntimeContext) string { + if v := runtime.Str("description-rich"); v != "" { + return v + } + return runtime.Str("description") +} + func hasExplicitBotFlag(cmd *cobra.Command) bool { if cmd == nil { return false diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index 1974360181..faa9f6459c 100644 --- a/skills/lark-calendar/SKILL.md +++ b/skills/lark-calendar/SKILL.md @@ -48,6 +48,8 @@ lark-cli calendar +agenda --as user lark-cli calendar +get --calendar-id --event-id ``` +读取日程时同时返回 `description`(纯文本)和 `description_rich`(**Markdown** 富文本)两个字段:`description` 存纯文本,`description_rich` 存富文本;仅有纯文本描述时,`description_rich` 会用该纯文本兜底填充(两字段值相同)。创建/更新日程时只传 `description_rich`(Markdown)。 + ### `+search-event` — 按关键词、时间范围和参会人搜索日程 仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。 diff --git a/skills/lark-calendar/references/lark-calendar-create.md b/skills/lark-calendar/references/lark-calendar-create.md index b0e772b1a5..9223eb4bcc 100644 --- a/skills/lark-calendar/references/lark-calendar-create.md +++ b/skills/lark-calendar/references/lark-calendar-create.md @@ -32,13 +32,14 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \ | `--summary ` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 | | `--start