From 19625fd0ffa35cdcf13b8540005087d3cb018dda Mon Sep 17 00:00:00 2001 From: Akayashuu Date: Tue, 4 Aug 2026 19:48:47 +0200 Subject: [PATCH] feat(messages): Get one message by id, and surface embeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read only reaches the tail of a channel, so a message named by anything other than recency — the one a reply points at — was unreachable. And Message dropped embeds entirely: a webhook or a bot puts its whole report in an embed, so a reader that only sees Content sees an empty message. --- README.md | 2 +- messages.go | 15 +++++++++++ messages_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ types.go | 25 ++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1873269..4f6f21b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ msgs, _ := c.Messages().Read(ctx, "", 20, "") // oldest-first | Accessor | Operations | |----------|------------| -| `Messages()` | `Send` `Reply` `Read` `Edit` `Delete` `LastMessageAt` | +| `Messages()` | `Send` `Reply` `Read` `Get` `Edit` `Delete` `LastMessageAt` | | `Channels()` | `List` `Get` `Type` `Create` `CreateUnder` `Rename` `Update` `Delete` `Ensure` `EnsureUnder` `Archive` | | `Guilds()` | `List` `Sole` | | `Members()` | `List` `Get` `Kick` `Ban` | diff --git a/messages.go b/messages.go index 5391076..ee56dbe 100644 --- a/messages.go +++ b/messages.go @@ -69,6 +69,21 @@ func (m *Messages) Read(ctx context.Context, channelID string, limit int, after return msgs, nil } +// Get returns one message by id from channelID (or the default channel). Read +// only reaches the tail of a channel, so this is the way to a message named by +// something other than recency — the one a reply points at, for instance. +func (m *Messages) Get(ctx context.Context, channelID, messageID string) (*Message, error) { + ch, err := m.def.resolveChannel(channelID) + if err != nil { + return nil, err + } + var msg Message + if err := m.rt.Do(ctx, http.MethodGet, "/channels/"+seg(ch)+"/messages/"+seg(messageID), nil, &msg); err != nil { + return nil, err + } + return &msg, nil +} + func (m *Messages) Edit(ctx context.Context, channelID, messageID, content string) (*Message, error) { ch, err := m.def.resolveChannel(channelID) if err != nil { diff --git a/messages_test.go b/messages_test.go index 9929bc2..0cf2195 100644 --- a/messages_test.go +++ b/messages_test.go @@ -51,6 +51,74 @@ func TestMessagesEdit(t *testing.T) { } } +func TestMessagesGetFetchesOneMessageByID(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"m1","channel_id":"c","content":"look at this"}`) + got, err := msgs(s, "def").Get(context.Background(), "c", "m1") + if err != nil { + t.Fatal(err) + } + if got.ID != "m1" || got.Content != "look at this" { + t.Fatalf("msg = %+v", got) + } + if c := s.Last(); c.Method != "GET" || c.Path != "/channels/c/messages/m1" { + t.Errorf("call = %s %s", c.Method, c.Path) + } +} + +func TestMessagesGetUsesTheDefaultChannel(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"m1"}`) + if _, err := msgs(s, "def").Get(context.Background(), "", "m1"); err != nil { + t.Fatal(err) + } + if c := s.Last(); c.Path != "/channels/def/messages/m1" { + t.Errorf("path = %s", c.Path) + } +} + +// A webhook-driven channel carries its content in embeds, not in `content`. A +// message decoded without them reads as empty, which is how a bot's report +// becomes invisible to anything reading the channel. +func TestMessageDecodesEmbeds(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"m1","content":"","embeds":[{ + "title":"Quest failed","description":"stack trace here","url":"https://example.test/run/1", + "image":{"url":"https://cdn.example.test/shot.png"}, + "thumbnail":{"url":"https://cdn.example.test/thumb.png"}, + "fields":[{"name":"env","value":"prod"}]}]}`) + got, err := msgs(s, "def").Get(context.Background(), "c", "m1") + if err != nil { + t.Fatal(err) + } + if len(got.Embeds) != 1 { + t.Fatalf("embeds = %+v", got.Embeds) + } + e := got.Embeds[0] + if e.Title != "Quest failed" || e.Description != "stack trace here" || e.URL != "https://example.test/run/1" { + t.Errorf("embed = %+v", e) + } + if e.Image == nil || e.Image.URL != "https://cdn.example.test/shot.png" { + t.Errorf("image = %+v", e.Image) + } + if e.Thumbnail == nil || e.Thumbnail.URL != "https://cdn.example.test/thumb.png" { + t.Errorf("thumbnail = %+v", e.Thumbnail) + } + if len(e.Fields) != 1 || e.Fields[0].Name != "env" || e.Fields[0].Value != "prod" { + t.Errorf("fields = %+v", e.Fields) + } +} + +// An embed with no image must decode to a nil pointer rather than an empty +// struct, so a reader can tell "no picture" from "a picture at the empty url". +func TestEmbedWithoutImageDecodesNil(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"m1","embeds":[{"title":"no picture"}]}`) + got, err := msgs(s, "def").Get(context.Background(), "c", "m1") + if err != nil { + t.Fatal(err) + } + if got.Embeds[0].Image != nil || got.Embeds[0].Thumbnail != nil { + t.Fatalf("embed = %+v, want nil media", got.Embeds[0]) + } +} + func TestMessagesDelete(t *testing.T) { s := transport.NewStub() if err := msgs(s, "").Delete(context.Background(), "c", "m1"); err != nil { diff --git a/types.go b/types.go index 807dc23..5adbe20 100644 --- a/types.go +++ b/types.go @@ -30,6 +30,30 @@ type Attachment struct { Size int `json:"size"` } +// EmbedMedia is an image carried by an embed. Only the URL is surfaced: it is +// what a reader needs to fetch the picture. +type EmbedMedia struct { + URL string `json:"url"` +} + +// EmbedField is one name/value pair in an embed's body. +type EmbedField struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Embed is the rich block a bot or a link preview attaches to a message. Most of +// a webhook-driven channel's content lives here rather than in Content, so a +// reader that only looks at Content sees an empty message. +type Embed struct { + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Image *EmbedMedia `json:"image"` + Thumbnail *EmbedMedia `json:"thumbnail"` + Fields []EmbedField `json:"fields"` +} + // Message is the subset of a Discord message we surface. type Message struct { ID string `json:"id"` @@ -38,6 +62,7 @@ type Message struct { Author Author `json:"author"` Timestamp string `json:"timestamp"` Attachments []Attachment `json:"attachments"` + Embeds []Embed `json:"embeds"` } // Guild is a Discord server the bot belongs to.