Skip to content

Commit c2062c6

Browse files
authored
Merge pull request #2 from cuiko/feat/msg-forward-and-entities
feat(msg): surface forward origin, entities, and inline buttons
2 parents 1b92832 + 2803791 commit c2062c6

5 files changed

Lines changed: 448 additions & 24 deletions

File tree

‎internal/cli/msg/list/list.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func New(f *runtime.Invocation, runF func(*Options) error) *cobra.Command {
5959
cmd.Flags().IntVar(&opts.MinID, "min-id", 0, "Stop once a message id <= this is reached (incremental sync)")
6060
command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true})
6161
output.AddJSONFlags(cmd, &opts.Exporter,
62-
[]string{"ref", "id", "date", "from", "text", "media", "reply_to", "views", "is_pinned"})
62+
[]string{"ref", "id", "date", "from", "text", "entities", "buttons", "forward", "media", "reply_to", "views", "is_pinned"})
6363
return cmd
6464
}
6565

‎internal/output/message.go‎

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,65 @@ import (
1313
// MessageRow is the output of `msg list`. Text is the message text when
1414
// present; for media-only messages, Text is the caption (may be empty).
1515
type MessageRow struct {
16-
ID int `json:"id"`
17-
Ref string `json:"ref,omitempty"`
18-
Date string `json:"date"` // RFC3339
19-
FromID int64 `json:"from_id,omitempty"`
20-
FromRef string `json:"from_ref,omitempty"`
21-
FromKind string `json:"from_kind,omitempty"`
22-
FromTitle string `json:"from_title,omitempty"`
23-
FromUsername string `json:"from_username,omitempty"`
24-
ReplyToID int `json:"reply_to_id,omitempty"`
25-
Text string `json:"text,omitempty"`
26-
HasMedia bool `json:"has_media,omitempty"`
27-
MediaKind string `json:"media_kind,omitempty"` // "photo" | "video" | "document" | "voice" | "audio" | "sticker" | "poll" | "web_page" | "other"
28-
Views int `json:"views,omitempty"`
29-
IsPinned bool `json:"is_pinned,omitempty"`
16+
ID int `json:"id"`
17+
Ref string `json:"ref,omitempty"`
18+
Date string `json:"date"` // RFC3339
19+
FromID int64 `json:"from_id,omitempty"`
20+
FromRef string `json:"from_ref,omitempty"`
21+
FromKind string `json:"from_kind,omitempty"`
22+
FromTitle string `json:"from_title,omitempty"`
23+
FromUsername string `json:"from_username,omitempty"`
24+
ReplyToID int `json:"reply_to_id,omitempty"`
25+
Text string `json:"text,omitempty"`
26+
Entities []MessageEntity `json:"entities,omitempty"`
27+
Buttons []MessageButton `json:"buttons,omitempty"`
28+
Forward *ForwardInfo `json:"forward,omitempty"`
29+
HasMedia bool `json:"has_media,omitempty"`
30+
MediaKind string `json:"media_kind,omitempty"` // "photo" | "video" | "document" | "voice" | "audio" | "sticker" | "poll" | "web_page" | "other"
31+
Views int `json:"views,omitempty"`
32+
IsPinned bool `json:"is_pinned,omitempty"`
33+
}
34+
35+
// MessageEntity is a flattened representation of a Telegram message entity.
36+
type MessageEntity struct {
37+
Type string `json:"type"`
38+
Text string `json:"text,omitempty"`
39+
URL string `json:"url,omitempty"`
40+
}
41+
42+
// MessageButton is a flattened representation of an inline-keyboard button.
43+
// Only buttons we know how to render to a URL are exposed.
44+
type MessageButton struct {
45+
Row int `json:"row"`
46+
Text string `json:"text,omitempty"`
47+
URL string `json:"url,omitempty"`
48+
Type string `json:"type,omitempty"` // "url" | "switch_inline" | "callback" | "web_app" | ...
49+
}
50+
51+
// ForwardInfo describes the origin of a forwarded message.
52+
type ForwardInfo struct {
53+
From *PeerObject `json:"from,omitempty"`
54+
FromName string `json:"from_name,omitempty"`
55+
Date string `json:"date,omitempty"` // RFC3339, original send time
56+
ChannelPostID int `json:"channel_post_id,omitempty"`
57+
PostAuthor string `json:"post_author,omitempty"`
58+
Link string `json:"link,omitempty"` // deep-link to original post when known
3059
}
3160

3261
func (r MessageRow) MarshalJSON() ([]byte, error) {
3362
type messageRowJSON struct {
34-
Ref string `json:"ref,omitempty"`
35-
ID int `json:"id,omitempty"`
36-
Date string `json:"date,omitempty"`
37-
From *PeerObject `json:"from,omitempty"`
38-
Text string `json:"text,omitempty"`
39-
Media *MediaObject `json:"media,omitempty"`
40-
ReplyTo int `json:"reply_to,omitempty"`
41-
Views int `json:"views,omitempty"`
42-
IsPinned bool `json:"is_pinned,omitempty"`
63+
Ref string `json:"ref,omitempty"`
64+
ID int `json:"id,omitempty"`
65+
Date string `json:"date,omitempty"`
66+
From *PeerObject `json:"from,omitempty"`
67+
Text string `json:"text,omitempty"`
68+
Entities []MessageEntity `json:"entities,omitempty"`
69+
Buttons []MessageButton `json:"buttons,omitempty"`
70+
Forward *ForwardInfo `json:"forward,omitempty"`
71+
Media *MediaObject `json:"media,omitempty"`
72+
ReplyTo int `json:"reply_to,omitempty"`
73+
Views int `json:"views,omitempty"`
74+
IsPinned bool `json:"is_pinned,omitempty"`
4375
}
4476
var from *PeerObject
4577
if r.FromID != 0 || r.FromRef != "" || r.FromTitle != "" || r.FromUsername != "" || r.FromKind != "" {
@@ -56,6 +88,9 @@ func (r MessageRow) MarshalJSON() ([]byte, error) {
5688
Date: r.Date,
5789
From: from,
5890
Text: r.Text,
91+
Entities: r.Entities,
92+
Buttons: r.Buttons,
93+
Forward: r.Forward,
5994
Media: media,
6095
ReplyTo: r.ReplyToID,
6196
Views: r.Views,
@@ -199,6 +234,13 @@ func messageHeader(r MessageRow) string {
199234
if r.ReplyToID > 0 {
200235
parts = append(parts, "reply "+strconv.Itoa(r.ReplyToID))
201236
}
237+
if r.Forward != nil {
238+
if label := forwardLabel(r.Forward); label != "" {
239+
parts = append(parts, "fwd "+label)
240+
} else {
241+
parts = append(parts, "fwd")
242+
}
243+
}
202244
if r.Views > 0 {
203245
parts = append(parts, strconv.Itoa(r.Views)+" views")
204246
}
@@ -208,6 +250,24 @@ func messageHeader(r MessageRow) string {
208250
return strings.Join(parts, " · ")
209251
}
210252

253+
func forwardLabel(f *ForwardInfo) string {
254+
if f == nil {
255+
return ""
256+
}
257+
if f.From != nil {
258+
if f.From.Username != "" {
259+
return "@" + f.From.Username
260+
}
261+
if f.From.Title != "" {
262+
return f.From.Title
263+
}
264+
if f.From.Ref != "" {
265+
return f.From.Ref
266+
}
267+
}
268+
return f.FromName
269+
}
270+
211271
func shortMessageDate(s string) string {
212272
t, err := time.Parse(time.RFC3339, s)
213273
if err != nil {

‎internal/output/message_test.go‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package output_test
22

33
import (
4+
"encoding/json"
45
"strings"
56
"testing"
67
"unicode/utf8"
@@ -89,3 +90,72 @@ func TestRenderMessages_TTYDoesNotTruncateLongRefs(t *testing.T) {
8990
require.NoError(t, output.RenderMessages(ios, rows))
9091
require.Contains(t, stdout.String(), ref)
9192
}
93+
94+
func TestMessageRow_MarshalJSON_EntitiesForwardButtons(t *testing.T) {
95+
row := output.MessageRow{
96+
ID: 1,
97+
Ref: "@dst:1",
98+
Date: "2026-04-23T12:00:00Z",
99+
Text: "hi",
100+
Entities: []output.MessageEntity{
101+
{Type: "text_url", Text: "hi", URL: "https://e.com"},
102+
},
103+
Buttons: []output.MessageButton{
104+
{Row: 0, Text: "Open", URL: "https://e.com", Type: "url"},
105+
},
106+
Forward: &output.ForwardInfo{
107+
Date: "2026-04-22T00:00:00Z",
108+
ChannelPostID: 7,
109+
Link: "https://t.me/src/7",
110+
From: &output.PeerObject{
111+
Ref: "@src",
112+
Type: "channel",
113+
Title: "Src",
114+
Username: "src",
115+
Link: "https://t.me/src",
116+
},
117+
},
118+
}
119+
b, err := json.Marshal(row)
120+
require.NoError(t, err)
121+
122+
var got map[string]any
123+
require.NoError(t, json.Unmarshal(b, &got))
124+
125+
require.Contains(t, got, "entities")
126+
require.Contains(t, got, "buttons")
127+
require.Contains(t, got, "forward")
128+
fwd := got["forward"].(map[string]any)
129+
require.Equal(t, "https://t.me/src/7", fwd["link"])
130+
require.InDelta(t, 7, fwd["channel_post_id"], 0)
131+
from := fwd["from"].(map[string]any)
132+
require.Equal(t, "src", from["username"])
133+
}
134+
135+
func TestMessageRow_MarshalJSON_OmitsEmptyForwardAndEntities(t *testing.T) {
136+
row := output.MessageRow{ID: 1, Date: "2026-04-23T12:00:00Z", Text: "hi"}
137+
b, err := json.Marshal(row)
138+
require.NoError(t, err)
139+
140+
var got map[string]any
141+
require.NoError(t, json.Unmarshal(b, &got))
142+
require.NotContains(t, got, "entities")
143+
require.NotContains(t, got, "buttons")
144+
require.NotContains(t, got, "forward")
145+
}
146+
147+
func TestRenderMessages_TTYShowsForwardLabel(t *testing.T) {
148+
ios, _, stdout, _ := ui.Test()
149+
ios.SetStdoutTTY(true)
150+
rows := []output.MessageRow{{
151+
ID: 10,
152+
Ref: "@dst:10",
153+
Date: "2026-04-23T12:00:00Z",
154+
Text: "post",
155+
Forward: &output.ForwardInfo{
156+
From: &output.PeerObject{Username: "src", Title: "Src"},
157+
},
158+
}}
159+
require.NoError(t, output.RenderMessages(ios, rows))
160+
require.Contains(t, stdout.String(), "fwd @src")
161+
}

0 commit comments

Comments
 (0)