1
0
Fork 0
mirror of https://github.com/diamondburned/arikawa.git synced 2024-11-17 12:23:08 +00:00

API: Move ExecuteWebhookData, add package sendpart for uploads

This commit moved ExecuteWebhookData from package api to package webhook
inside package api. This change required splitting the multipart
abstractions away from package api, so they are now inside package
sendpart in utils.

This commit will break code that uploads anything, as the type name is
now sendpart.File from api.SendMessageFile. The behavior should be the
same as before.
This commit is contained in:
diamondburned 2020-12-16 13:11:11 -08:00
parent 91dc41e388
commit 525d0bb3f6
5 changed files with 168 additions and 116 deletions

View file

@ -1,16 +1,13 @@
package api package api
import ( import (
"io"
"mime/multipart" "mime/multipart"
"strconv"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/diamondburned/arikawa/v2/discord" "github.com/diamondburned/arikawa/v2/discord"
"github.com/diamondburned/arikawa/v2/utils/httputil"
"github.com/diamondburned/arikawa/v2/utils/json"
"github.com/diamondburned/arikawa/v2/utils/json/option" "github.com/diamondburned/arikawa/v2/utils/json/option"
"github.com/diamondburned/arikawa/v2/utils/sendpart"
) )
const AttachmentSpoilerPrefix = "SPOILER_" const AttachmentSpoilerPrefix = "SPOILER_"
@ -93,12 +90,6 @@ func (am AllowedMentions) Verify() error {
// ExecuteWebhookData has both an empty Content and no Embed(s). // ExecuteWebhookData has both an empty Content and no Embed(s).
var ErrEmptyMessage = errors.New("message is empty") var ErrEmptyMessage = errors.New("message is empty")
// SendMessageFile represents a file to be uploaded to Discord.
type SendMessageFile struct {
Name string
Reader io.Reader
}
// SendMessageData is the full structure to send a new message to Discord with. // SendMessageData is the full structure to send a new message to Discord with.
type SendMessageData struct { type SendMessageData struct {
// Content are the message contents (up to 2000 characters). // Content are the message contents (up to 2000 characters).
@ -111,7 +102,7 @@ type SendMessageData struct {
// Embed is embedded rich content. // Embed is embedded rich content.
Embed *discord.Embed `json:"embed,omitempty"` Embed *discord.Embed `json:"embed,omitempty"`
Files []SendMessageFile `json:"-"` Files []sendpart.File `json:"-"`
// AllowedMentions are the allowed mentions for a message. // AllowedMentions are the allowed mentions for a message.
AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"` AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"`
@ -124,8 +115,13 @@ type SendMessageData struct {
Reference *discord.MessageReference `json:"message_reference,omitempty"` Reference *discord.MessageReference `json:"message_reference,omitempty"`
} }
func (data *SendMessageData) WriteMultipart(body *multipart.Writer) error { // NeedsMultipart returns true if the SendMessageData has files.
return writeMultipart(body, data, data.Files) func (data SendMessageData) NeedsMultipart() bool {
return len(data.Files) > 0
}
func (data SendMessageData) WriteMultipart(body *multipart.Writer) error {
return sendpart.Write(body, data, data.Files)
} }
// SendMessageComplex posts a message to a guild text or DM channel. If // SendMessageComplex posts a message to a guild text or DM channel. If
@ -168,77 +164,5 @@ func (c *Client) SendMessageComplex(
var URL = EndpointChannels + channelID.String() + "/messages" var URL = EndpointChannels + channelID.String() + "/messages"
var msg *discord.Message var msg *discord.Message
return msg, sendpart.POST(c.Client, data, &msg, URL)
if len(data.Files) == 0 {
// No files, so no need for streaming.
return msg, c.RequestJSON(&msg, "POST", URL, httputil.WithJSONBody(data))
}
resp, err := c.MeanwhileMultipart(data.WriteMultipart, "POST", URL)
if err != nil {
return nil, err
}
var body = resp.GetBody()
defer body.Close()
return msg, json.DecodeStream(body, &msg)
}
// https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params
type ExecuteWebhookData struct {
// Content are the message contents (up to 2000 characters).
//
// Required: one of content, file, embeds
Content string `json:"content,omitempty"`
// Username overrides the default username of the webhook
Username string `json:"username,omitempty"`
// AvatarURL overrides the default avatar of the webhook.
AvatarURL discord.URL `json:"avatar_url,omitempty"`
// TTS is true if this is a TTS message.
TTS bool `json:"tts,omitempty"`
// Embeds contains embedded rich content.
//
// Required: one of content, file, embeds
Embeds []discord.Embed `json:"embeds,omitempty"`
Files []SendMessageFile `json:"-"`
// AllowedMentions are the allowed mentions for the message.
AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"`
}
func (data *ExecuteWebhookData) WriteMultipart(body *multipart.Writer) error {
return writeMultipart(body, data, data.Files)
}
func writeMultipart(body *multipart.Writer, item interface{}, files []SendMessageFile) error {
defer body.Close()
// Encode the JSON body first
w, err := body.CreateFormField("payload_json")
if err != nil {
return errors.Wrap(err, "failed to create bodypart for JSON")
}
if err := json.EncodeStream(w, item); err != nil {
return errors.Wrap(err, "failed to encode JSON")
}
for i, file := range files {
num := strconv.Itoa(i)
w, err := body.CreateFormFile("file"+num, file.Name)
if err != nil {
return errors.Wrap(err, "failed to create bodypart for "+num)
}
if _, err := io.Copy(w, file.Reader); err != nil {
return errors.Wrap(err, "failed to write for file "+num)
}
}
return nil
} }

View file

@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/diamondburned/arikawa/v2/discord" "github.com/diamondburned/arikawa/v2/discord"
"github.com/diamondburned/arikawa/v2/utils/sendpart"
) )
func TestMarshalAllowedMentions(t *testing.T) { func TestMarshalAllowedMentions(t *testing.T) {
@ -112,7 +113,7 @@ func TestSendMessage(t *testing.T) {
t.Run("files only", func(t *testing.T) { t.Run("files only", func(t *testing.T) {
var empty = SendMessageData{ var empty = SendMessageData{
Files: []SendMessageFile{{Name: "test.jpg"}}, Files: []sendpart.File{{Name: "test.jpg"}},
} }
if err := send(empty); err != nil { if err := send(empty); err != nil {

View file

@ -3,6 +3,7 @@
package webhook package webhook
import ( import (
"mime/multipart"
"net/url" "net/url"
"strconv" "strconv"
@ -11,8 +12,8 @@ import (
"github.com/diamondburned/arikawa/v2/api" "github.com/diamondburned/arikawa/v2/api"
"github.com/diamondburned/arikawa/v2/discord" "github.com/diamondburned/arikawa/v2/discord"
"github.com/diamondburned/arikawa/v2/utils/httputil" "github.com/diamondburned/arikawa/v2/utils/httputil"
"github.com/diamondburned/arikawa/v2/utils/json"
"github.com/diamondburned/arikawa/v2/utils/json/option" "github.com/diamondburned/arikawa/v2/utils/json/option"
"github.com/diamondburned/arikawa/v2/utils/sendpart"
) )
// Client is the client used to interact with a webhook. // Client is the client used to interact with a webhook.
@ -61,21 +62,59 @@ func (c *Client) Delete() error {
return c.FastRequest("DELETE", api.EndpointWebhooks+c.ID.String()+"/"+c.Token) return c.FastRequest("DELETE", api.EndpointWebhooks+c.ID.String()+"/"+c.Token)
} }
// https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params
type ExecuteWebhookData struct {
// Content are the message contents (up to 2000 characters).
//
// Required: one of content, file, embeds
Content string `json:"content,omitempty"`
// Username overrides the default username of the webhook
Username string `json:"username,omitempty"`
// AvatarURL overrides the default avatar of the webhook.
AvatarURL discord.URL `json:"avatar_url,omitempty"`
// TTS is true if this is a TTS message.
TTS bool `json:"tts,omitempty"`
// Embeds contains embedded rich content.
//
// Required: one of content, file, embeds
Embeds []discord.Embed `json:"embeds,omitempty"`
// Files represents a list of files to upload. This will not be JSON-encoded
// and will only be available through WriteMultipart.
Files []sendpart.File `json:"-"`
// AllowedMentions are the allowed mentions for the message.
AllowedMentions *api.AllowedMentions `json:"allowed_mentions,omitempty"`
}
// NeedsMultipart returns true if the ExecuteWebhookData has files.
func (data ExecuteWebhookData) NeedsMultipart() bool {
return len(data.Files) > 0
}
// WriteMultipart writes the webhook data into the given multipart body. It does
// not close body.
func (data ExecuteWebhookData) WriteMultipart(body *multipart.Writer) error {
return sendpart.Write(body, data, data.Files)
}
// Execute sends a message to the webhook, but doesn't wait for the message to // Execute sends a message to the webhook, but doesn't wait for the message to
// get created. This is generally faster, but only applicable if no further // get created. This is generally faster, but only applicable if no further
// interaction is required. // interaction is required.
func (c *Client) Execute(data api.ExecuteWebhookData) (err error) { func (c *Client) Execute(data ExecuteWebhookData) (err error) {
_, err = c.execute(data, false) _, err = c.execute(data, false)
return return
} }
// ExecuteAndWait executes the webhook, and waits for the generated // ExecuteAndWait executes the webhook, and waits for the generated
// discord.Message to be returned. // discord.Message to be returned.
func (c *Client) ExecuteAndWait(data api.ExecuteWebhookData) (*discord.Message, error) { func (c *Client) ExecuteAndWait(data ExecuteWebhookData) (*discord.Message, error) {
return c.execute(data, true) return c.execute(data, true)
} }
func (c *Client) execute(data api.ExecuteWebhookData, wait bool) (*discord.Message, error) { func (c *Client) execute(data ExecuteWebhookData, wait bool) (*discord.Message, error) {
if data.Content == "" && len(data.Embeds) == 0 && len(data.Files) == 0 { if data.Content == "" && len(data.Embeds) == 0 && len(data.Files) == 0 {
return nil, api.ErrEmptyMessage return nil, api.ErrEmptyMessage
} }
@ -92,36 +131,20 @@ func (c *Client) execute(data api.ExecuteWebhookData, wait bool) (*discord.Messa
} }
} }
var param = url.Values{} var param url.Values
if wait { if wait {
param.Set("wait", "true") param = url.Values{"wait": {"true"}}
} }
var URL = api.EndpointWebhooks + c.ID.String() + "/" + c.Token + "?" + param.Encode() var URL = api.EndpointWebhooks + c.ID.String() + "/" + c.Token + "?" + param.Encode()
var msg *discord.Message var msg *discord.Message
var ptr interface{}
if len(data.Files) == 0 { if wait {
// No files, so no need for streaming. ptr = &msg
return msg, c.RequestJSON(&msg, "POST", URL,
httputil.WithJSONBody(data))
} }
writer := data.WriteMultipart return msg, sendpart.POST(c.Client, data, ptr, URL)
resp, err := c.MeanwhileMultipart(writer, "POST", URL)
if err != nil {
return nil, err
}
var body = resp.GetBody()
defer body.Close()
if !wait {
// Since we didn't tell Discord to wait, we have nothing to parse.
return nil, nil
}
return msg, json.DecodeStream(body, &msg)
} }
// https://discord.com/developers/docs/resources/webhook#edit-webhook-message-jsonform-params // https://discord.com/developers/docs/resources/webhook#edit-webhook-message-jsonform-params

View file

@ -91,14 +91,34 @@ func (c *Client) applyOptions(r httpdriver.Request, extra []RequestOption) (e er
return return
} }
// MultipartWriter is the interface for a data structure that can write into a
// multipart writer.
type MultipartWriter interface {
WriteMultipart(body *multipart.Writer) error
}
// MeanwhileMultipart concurrently encodes and writes the given multipart writer
// at the same time. The writer will be called in another goroutine, but the
// writer will be closed when MeanwhileMultipart returns.
func (c *Client) MeanwhileMultipart( func (c *Client) MeanwhileMultipart(
writer func(*multipart.Writer) error, writer MultipartWriter,
method, url string, opts ...RequestOption) (httpdriver.Response, error) { method, url string, opts ...RequestOption) (httpdriver.Response, error) {
r, w := io.Pipe() r, w := io.Pipe()
body := multipart.NewWriter(w) body := multipart.NewWriter(w)
go func() { w.CloseWithError(writer(body)) }() // Ensure the writer is closed by the time this function exits, so
// WriteMultipart will exit.
defer w.Close()
go func() {
err := writer.WriteMultipart(body)
if err != nil {
err = body.Close()
}
w.CloseWithError(err)
}()
// Prepend the multipart writer and the correct Content-Type header options. // Prepend the multipart writer and the correct Content-Type header options.
opts = PrependOptions( opts = PrependOptions(
@ -135,6 +155,10 @@ func (c *Client) RequestJSON(to interface{}, method, url string, opts ...Request
if status == httpdriver.NoContent { if status == httpdriver.NoContent {
return nil return nil
} }
// to is nil for some reason. Ignore.
if to == nil {
return nil
}
if err := json.DecodeStream(body, to); err != nil { if err := json.DecodeStream(body, to); err != nil {
return JSONError{err} return JSONError{err}

View file

@ -0,0 +1,80 @@
package sendpart
import (
"io"
"mime/multipart"
"strconv"
"github.com/diamondburned/arikawa/v2/utils/httputil"
"github.com/diamondburned/arikawa/v2/utils/json"
"github.com/pkg/errors"
)
// File represents a file to be uploaded to Discord.
type File struct {
Name string
Reader io.Reader
}
// DataMultipartWriter is a MultipartWriter that also contains data that's
// JSON-marshalable.
type DataMultipartWriter interface {
// NeedsMultipart returns true if the data interface must be sent using
// multipart form.
NeedsMultipart() bool
httputil.MultipartWriter
}
// POST sends a POST request using client to the given URL and unmarshal the
// body into v if it's not nil. It will only send using multipart if files is
// true.
func POST(c *httputil.Client, data DataMultipartWriter, v interface{}, url string) error {
if !data.NeedsMultipart() {
// No files, so no need for streaming.
return c.RequestJSON(v, "POST", url, httputil.WithJSONBody(data))
}
resp, err := c.MeanwhileMultipart(data, "POST", url)
if err != nil {
return err
}
var body = resp.GetBody()
defer body.Close()
if v == nil {
return nil
}
return json.DecodeStream(body, v)
}
// Write writes the item into payload_json and the list of files into the
// multipart writer. Write does not close the body.
func Write(body *multipart.Writer, item interface{}, files []File) error {
// Encode the JSON body first
w, err := body.CreateFormField("payload_json")
if err != nil {
return errors.Wrap(err, "failed to create bodypart for JSON")
}
if err := json.EncodeStream(w, item); err != nil {
return errors.Wrap(err, "failed to encode JSON")
}
for i, file := range files {
num := strconv.Itoa(i)
w, err := body.CreateFormFile("file"+num, file.Name)
if err != nil {
return errors.Wrap(err, "failed to create bodypart for "+num)
}
if _, err := io.Copy(w, file.Reader); err != nil {
return errors.Wrap(err, "failed to write for file "+num)
}
}
return nil
}