Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
15 changes: 15 additions & 0 deletions messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
Loading