diff --git a/internal/citation/citation.go b/internal/citation/citation.go new file mode 100644 index 0000000000..31c3be0341 --- /dev/null +++ b/internal/citation/citation.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package citation + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// SourceType identifies the product resource represented by a citation. +type SourceType int + +const ( + SourceUnknown SourceType = 0 + SourceMail SourceType = 12 +) + +// Citation is the JSON shape attached to successful command envelopes. +type Citation struct { + SourceType SourceType `json:"source_type"` + URL string `json:"url"` + Title string `json:"title"` + Snippet string `json:"snippet,omitempty"` + PublishTime string `json:"publish_time,omitempty"` +} + +// Filter drops citations that cannot be opened by a client. +func Filter(items []Citation) []Citation { + out := make([]Citation, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.URL) == "" { + continue + } + out = append(out, item) + } + return out +} + +// Time normalizes OpenAPI timestamp values into RFC3339. Mail APIs commonly +// return Unix milliseconds as either numbers or decimal strings. +func Time(value interface{}) string { + switch v := value.(type) { + case nil: + return "" + case time.Time: + if v.IsZero() { + return "" + } + return v.UTC().Format(time.RFC3339) + case string: + return timeString(v) + case jsonNumber: + return timeString(v.String()) + case int: + return unixTimestamp(int64(v)) + case int64: + return unixTimestamp(v) + case float64: + return unixTimestamp(int64(v)) + case float32: + return unixTimestamp(int64(v)) + default: + return timeString(fmt.Sprint(value)) + } +} + +type jsonNumber interface { + String() string +} + +func timeString(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t.UTC().Format(time.RFC3339) + } + if n, err := strconv.ParseInt(value, 10, 64); err == nil { + return unixTimestamp(n) + } + return "" +} + +func unixTimestamp(value int64) string { + if value <= 0 { + return "" + } + if value > 1_000_000_000_000 { + return time.UnixMilli(value).UTC().Format(time.RFC3339) + } + return time.Unix(value, 0).UTC().Format(time.RFC3339) +} diff --git a/internal/output/emitter.go b/internal/output/emitter.go index fcba4e49af..9da45fddb5 100644 --- a/internal/output/emitter.go +++ b/internal/output/emitter.go @@ -10,6 +10,7 @@ import ( "io" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/citation" ) // NoticeProvider supplies the notice attached to a structured envelope. @@ -46,6 +47,7 @@ type EmitterConfig struct { type EmitOptions struct { Raw bool Meta *Meta + Citations []citation.Citation Format string JQ string DryRun bool @@ -195,12 +197,13 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro } env := Envelope{ - OK: ok, - Identity: e.identity, - DryRun: opts.DryRun, - Data: data, - Meta: opts.Meta, - Notice: e.notice(), + OK: ok, + Identity: e.identity, + DryRun: opts.DryRun, + Data: data, + Citations: opts.Citations, + Meta: opts.Meta, + Notice: e.notice(), } if scanResult.Alert != nil { env.ContentSafetyAlert = scanResult.Alert diff --git a/internal/output/envelope.go b/internal/output/envelope.go index 67263351fb..5c4d983535 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -3,12 +3,15 @@ package output +import "github.com/larksuite/cli/internal/citation" + // Envelope is the standard success response wrapper. type Envelope struct { OK bool `json:"ok"` Identity string `json:"identity,omitempty"` DryRun bool `json:"dry_run,omitempty"` Data interface{} `json:"data,omitempty"` + Citations []citation.Citation `json:"citations,omitempty"` Meta *Meta `json:"meta,omitempty"` ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"` Notice map[string]interface{} `json:"_notice,omitempty"` diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 5746017da3..7450ba395d 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -22,6 +22,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/citation" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" @@ -56,6 +57,7 @@ type RuntimeContext struct { stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource offline bool // dry-run context: API and credential-backed scope checks are disabled + citation *CitationDefinition // optional builder for JSON-envelope citations } // ── Identity ── @@ -796,10 +798,11 @@ func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer // Out prints a success JSON envelope to stdout. func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{ - Format: "", - Raw: false, - JQ: ctx.JqExpr, - Meta: meta, + Format: "", + Raw: false, + JQ: ctx.JqExpr, + Meta: meta, + Citations: ctx.buildCitations(data, ""), })) } @@ -808,10 +811,11 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { // that should be preserved as-is in JSON output. func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) { ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{ - Format: "", - Raw: true, - JQ: ctx.JqExpr, - Meta: meta, + Format: "", + Raw: true, + JQ: ctx.JqExpr, + Meta: meta, + Citations: ctx.buildCitations(data, ""), })) } @@ -844,11 +848,12 @@ func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta // The Emitter handles content safety scanning for every format. func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) { ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{ - Format: ctx.Format, - Raw: false, - JQ: ctx.JqExpr, - Meta: meta, - Pretty: wrapLegacyPrettyRenderer(prettyFn), + Format: ctx.Format, + Raw: false, + JQ: ctx.JqExpr, + Meta: meta, + Citations: ctx.buildCitations(data, ctx.Format), + Pretty: wrapLegacyPrettyRenderer(prettyFn), })) } @@ -856,14 +861,32 @@ func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, pretty // Use this when the data contains XML/HTML content that should be preserved as-is. func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) { ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{ - Format: ctx.Format, - Raw: true, - JQ: ctx.JqExpr, - Meta: meta, - Pretty: wrapLegacyPrettyRenderer(prettyFn), + Format: ctx.Format, + Raw: true, + JQ: ctx.JqExpr, + Meta: meta, + Citations: ctx.buildCitations(data, ctx.Format), + Pretty: wrapLegacyPrettyRenderer(prettyFn), })) } +func (ctx *RuntimeContext) buildCitations(data interface{}, format string) []citation.Citation { + if ctx == nil || ctx.citation == nil || ctx.citation.Build == nil { + return nil + } + if !CitationOutputEnabled() { + return nil + } + if ctx.JqExpr == "" && format != "" && format != "json" { + return nil + } + return citation.Filter(ctx.citation.Build(data)) +} + +func CitationOutputEnabled() bool { + return os.Getenv("LARKSUITE_CLI_CITATION") == "1" +} + // ── Scope pre-check ── // checkScopePrereqs performs a fast local check: does the token @@ -1236,6 +1259,7 @@ func newRuntimeContextBase(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly: botOnly, resolvedAs: as, Factory: f, + citation: s.Citation, } rctx.declaredScopes = s.DeclaredScopesForIdentity(string(rctx.As())) applyJSONShorthand(cmd, s) diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 8c17dfd37c..b14aeb2ffa 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -6,6 +6,7 @@ package common import ( "context" + "github.com/larksuite/cli/internal/citation" "github.com/spf13/cobra" ) @@ -66,6 +67,7 @@ type Shortcut struct { DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic + Citation *CitationDefinition // optional JSON-envelope citations for successful output // OnInvoke, when non-nil, runs from the command's cobra PreRunE — before // cobra validates required flags — so its side effect fires even when the @@ -102,6 +104,12 @@ type Shortcut struct { typed *compiledCommand } +// CitationDefinition describes how a shortcut derives citations from the data it already outputs. +type CitationDefinition struct { + SourceTypes []citation.SourceType + Build func(data interface{}) []citation.Citation +} + // ScopesForIdentity returns the scopes applicable for the given identity. // If identity-specific scopes (UserScopes/BotScopes) are set, they take // precedence over the default Scopes. diff --git a/shortcuts/mail/mail_citation.go b/shortcuts/mail/mail_citation.go new file mode 100644 index 0000000000..7a9566f0de --- /dev/null +++ b/shortcuts/mail/mail_citation.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "github.com/larksuite/cli/internal/citation" + "github.com/larksuite/cli/shortcuts/common" +) + +func buildMailMessageCitation(msg map[string]interface{}) citation.Citation { + return citation.Citation{ + SourceType: citation.SourceMail, + URL: strVal(msg["applink"]), + Title: strVal(msg["subject"]), + Snippet: mailCitationSnippet(msg), + PublishTime: mailCitationPublishTime(msg), + } +} + +func buildMailMessageCitations(messages []map[string]interface{}) []citation.Citation { + out := make([]citation.Citation, 0, len(messages)) + for _, msg := range messages { + out = append(out, buildMailMessageCitation(msg)) + } + return out +} + +func mailMessageCitationDefinition() *common.CitationDefinition { + return &common.CitationDefinition{ + SourceTypes: []citation.SourceType{citation.SourceMail}, + Build: func(data interface{}) []citation.Citation { + msg, ok := data.(map[string]interface{}) + if !ok { + return nil + } + return []citation.Citation{buildMailMessageCitation(msg)} + }, + } +} + +func mailMessagesCitationDefinition() *common.CitationDefinition { + return &common.CitationDefinition{ + SourceTypes: []citation.SourceType{citation.SourceMail}, + Build: func(data interface{}) []citation.Citation { + switch v := data.(type) { + case mailMessagesOutput: + return buildMailMessageCitations(v.Messages) + case *mailMessagesOutput: + if v == nil { + return nil + } + return buildMailMessageCitations(v.Messages) + default: + return nil + } + }, + } +} + +func mailTriageCitationDefinition() *common.CitationDefinition { + return &common.CitationDefinition{ + SourceTypes: []citation.SourceType{citation.SourceMail}, + Build: func(data interface{}) []citation.Citation { + out, ok := data.(map[string]interface{}) + if !ok { + return nil + } + return buildMailMessageCitations(mapSlice(out["messages"])) + }, + } +} + +func mailCitationSnippet(msg map[string]interface{}) string { + for _, key := range []string{"body_preview", "preview", "summary"} { + if value := strVal(msg[key]); value != "" { + return value + } + } + return "" +} + +func mailCitationPublishTime(msg map[string]interface{}) string { + for _, key := range []string{"internal_date", "create_time", "date"} { + if value := citation.Time(msg[key]); value != "" { + return value + } + } + return "" +} + +func mapSlice(raw interface{}) []map[string]interface{} { + switch v := raw.(type) { + case []map[string]interface{}: + return v + case []interface{}: + out := make([]map[string]interface{}, 0, len(v)) + for _, item := range v { + if msg, ok := item.(map[string]interface{}); ok { + out = append(out, msg) + } + } + return out + default: + return nil + } +} diff --git a/shortcuts/mail/mail_citation_test.go b/shortcuts/mail/mail_citation_test.go new file mode 100644 index 0000000000..3d96c5e018 --- /dev/null +++ b/shortcuts/mail/mail_citation_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMailMessageCitationFromApplink(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CITATION", "1") + f, stdout, _, reg := mailShortcutTestFactory(t) + defer reg.Verify(t) + + const applink = "https://applink.feishu.cn/client/mail/message?thread=thread_1&message=msg_1" + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: mailboxPath("me", "messages", "msg_001") + "?format=full", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message": map[string]interface{}{ + "message_id": "msg_001", + "thread_id": "thread_001", + "subject": "Quarterly plan", + "body_preview": "Preview text", + "internal_date": "1700000000000", + "body_plain_text": "", + "body_html": "", + "message_state": 1, + "applink": applink, + "need_read_receipt": false, + }, + }, + }, + }) + + if err := runMountedMailShortcut(t, MailMessage, []string{ + "+message", "--message-id", "msg_001", + }, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + citation := singleCitation(t, stdout) + if citation["source_type"] != float64(12) { + t.Fatalf("source_type = %v, want 12", citation["source_type"]) + } + if citation["url"] != applink { + t.Fatalf("url = %v, want %s", citation["url"], applink) + } + if citation["title"] != "Quarterly plan" || citation["snippet"] != "Preview text" { + t.Fatalf("citation title/snippet mismatch: %#v", citation) + } + if citation["publish_time"] != "2023-11-14T22:13:20Z" { + t.Fatalf("publish_time = %v", citation["publish_time"]) + } +} + +func TestMailTriageCitationFromBatchGetApplink(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CITATION", "1") + f, stdout, _, reg := mailShortcutTestFactory(t) + defer reg.Verify(t) + + registerMailTriageListStub(reg, "me", []string{"msg_001", "msg_002"}, false, "") + registerMailTriageBatchStub(reg, "me", []map[string]interface{}{ + mailTriageBatchMessageWithCitation("msg_001", "First", "https://applink.feishu.cn/client/mail/message?message=msg_001"), + mailTriageBatchMessageWithCitation("msg_002", "Second", "https://applink.feishu.cn/client/mail/message?message=msg_002"), + }) + + if err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", "--format", "json", "--filter", `{"folder_id":"INBOX"}`, + }, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + citations := envelopeCitations(t, stdout) + if len(citations) != 2 { + t.Fatalf("citations len = %d, want 2; stdout=%s", len(citations), stdout.String()) + } + if citations[0]["title"] != "First" || citations[1]["title"] != "Second" { + t.Fatalf("citation order/title mismatch: %#v", citations) + } + if citations[0]["snippet"] != "Preview msg_001" { + t.Fatalf("snippet = %v", citations[0]["snippet"]) + } +} + +func TestMailTriageSearchWithoutApplinkDropsCitation(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CITATION", "1") + f, stdout, _, reg := mailShortcutTestFactory(t) + defer reg.Verify(t) + + registerMailTriageSearchStub(reg, "me", []interface{}{ + mailTriageSearchItem("msg_search", "Search result"), + }, false, "") + + if err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", "--format", "json", "--query", "keyword", + }, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if citations := envelopeCitations(t, stdout); len(citations) != 0 { + t.Fatalf("citations = %#v, want none", citations) + } +} + +func TestMailCitationDisabledAndTableOutputStayUnchanged(t *testing.T) { + t.Run("env disabled", func(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + defer reg.Verify(t) + + registerMailTriageListStub(reg, "me", []string{"msg_001"}, false, "") + registerMailTriageBatchStub(reg, "me", []map[string]interface{}{ + mailTriageBatchMessageWithCitation("msg_001", "First", "https://applink.feishu.cn/client/mail/message?message=msg_001"), + }) + + if err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", "--format", "json", "--filter", `{"folder_id":"INBOX"}`, + }, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(stdout.String(), `"citations"`) { + t.Fatalf("stdout should not contain citations when env is disabled: %s", stdout.String()) + } + }) + + t.Run("table format", func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CITATION", "1") + f, stdout, _, reg := mailShortcutTestFactory(t) + defer reg.Verify(t) + + registerMailTriageListStub(reg, "me", []string{"msg_001"}, false, "") + registerMailTriageBatchStub(reg, "me", []map[string]interface{}{ + mailTriageBatchMessageWithCitation("msg_001", "First", "https://applink.feishu.cn/client/mail/message?message=msg_001"), + }) + + if err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", "--format", "table", "--filter", `{"folder_id":"INBOX"}`, + }, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(stdout.String(), "citations") { + t.Fatalf("table output should not contain citations: %s", stdout.String()) + } + }) +} + +func envelopeCitations(t *testing.T, stdout *bytes.Buffer) []map[string]interface{} { + t.Helper() + var envelope struct { + OK bool `json:"ok"` + Citations []interface{} `json:"citations"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v; stdout=%s", err, stdout.String()) + } + if !envelope.OK { + t.Fatalf("expected ok envelope: %s", stdout.String()) + } + out := make([]map[string]interface{}, 0, len(envelope.Citations)) + for i, item := range envelope.Citations { + citation, ok := item.(map[string]interface{}) + if !ok { + t.Fatalf("citations[%d] type = %T", i, item) + } + out = append(out, citation) + } + return out +} + +func singleCitation(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + citations := envelopeCitations(t, stdout) + if len(citations) != 1 { + t.Fatalf("citations len = %d, want 1; stdout=%s", len(citations), stdout.String()) + } + return citations[0] +} + +func mailTriageBatchMessageWithCitation(messageID, subject, applink string) map[string]interface{} { + msg := mailTriageBatchMessage(messageID, subject) + msg["applink"] = applink + msg["body_preview"] = "Preview " + messageID + msg["internal_date"] = "1700000000000" + return msg +} diff --git a/shortcuts/mail/mail_message.go b/shortcuts/mail/mail_message.go index 6e79e06a32..787cdc7d47 100644 --- a/shortcuts/mail/mail_message.go +++ b/shortcuts/mail/mail_message.go @@ -19,6 +19,7 @@ var MailMessage = common.Shortcut{ Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, + Citation: mailMessageCitationDefinition(), Flags: []common.Flag{ {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true}, diff --git a/shortcuts/mail/mail_messages.go b/shortcuts/mail/mail_messages.go index 0d4fe3dd82..3ad60ca1e1 100644 --- a/shortcuts/mail/mail_messages.go +++ b/shortcuts/mail/mail_messages.go @@ -28,6 +28,7 @@ var MailMessages = common.Shortcut{ Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, + Citation: mailMessagesCitationDefinition(), Flags: []common.Flag{ {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "message-ids", Desc: `Required. Comma-separated email message IDs. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output. Example: ",,"`, Required: true}, diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 0a3a6d1f2d..5c9df18119 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -56,6 +56,7 @@ var MailTriage = common.Shortcut{ Risk: "read", Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, + Citation: mailTriageCitationDefinition(), Flags: []common.Flag{ {Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"}, {Name: "max", Aliases: []string{"page-size"}, Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"}, @@ -293,7 +294,11 @@ var MailTriage = common.Shortcut{ if notice != "" { outData["notice"] = notice } - output.PrintJson(runtime.IO().Out, outData) + if common.CitationOutputEnabled() { + runtime.Out(outData, nil) + } else { + output.PrintJson(runtime.IO().Out, outData) + } default: // "table" if notice != "" { fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice) @@ -832,6 +837,15 @@ func buildTriageMessageMeta(msg map[string]interface{}, fallbackMessageID string item["thread_id"] = strVal(msg["thread_id"]) item["subject"] = strVal(msg["subject"]) item["folder"] = strVal(msg["folder_id"]) + if applink := strVal(msg["applink"]); applink != "" { + item["applink"] = applink + } + if preview := strVal(msg["body_preview"]); preview != "" { + item["body_preview"] = preview + } + if internalDate := msg["internal_date"]; internalDate != nil { + item["internal_date"] = internalDate + } if d := strVal(msg["date"]); d != "" { item["date"] = d } else if ts, ok := msg["internal_date"]; ok { @@ -876,6 +890,18 @@ func buildTriageMessagesFromSearchItems(raw interface{}) []map[string]interface{ message["thread_id"] = strVal(meta["thread_id"]) message["subject"] = strVal(meta["title"]) message["date"] = strVal(meta["create_time"]) + if applink := strVal(meta["applink"]); applink != "" { + message["applink"] = applink + } + if createTime := meta["create_time"]; createTime != nil { + message["create_time"] = createTime + } + if preview := strVal(meta["preview"]); preview != "" { + message["preview"] = preview + } + if summary := strVal(meta["summary"]); summary != "" { + message["summary"] = summary + } if from, ok := meta["from"].(map[string]interface{}); ok { message["from"] = formatAddress(from) }