diff --git a/api/api.go b/api/api.go index e172a11..c5bb477 100644 --- a/api/api.go +++ b/api/api.go @@ -16,7 +16,6 @@ const ( Endpoint = BaseEndpoint + "/v" + APIVersion + "/" EndpointGateway = Endpoint + "gateway" EndpointGatewayBot = EndpointGateway + "/bot" - EndpointWebhooks = Endpoint + "webhooks/" ) var UserAgent = "DiscordBot (https://github.com/diamondburned/arikawa, v0.0.1)" diff --git a/api/message.go b/api/message.go index 6f29f99..b9e07d1 100644 --- a/api/message.go +++ b/api/message.go @@ -123,11 +123,11 @@ func (c *Client) SendMessageComplex( } } - var URL = EndpointChannels + channelID.String() + var URL = EndpointChannels + channelID.String() + "/messages" var msg *discord.Message if len(data.Files) == 0 { - // No files, no need for streaming + // No files, so no need for streaming. return msg, c.RequestJSON(&msg, "POST", URL, httputil.WithJSONBody(c, data)) } @@ -136,7 +136,8 @@ func (c *Client) SendMessageComplex( return data.WriteMultipart(c, w) } - resp, err := c.MeanwhileBody(writer, "POST", URL) + resp, err := c.MeanwhileBody(writer, "POST", URL, + httputil.MultipartRequest) if err != nil { return nil, err } diff --git a/api/message_send.go b/api/message_send.go index abff37c..87e2fe7 100644 --- a/api/message_send.go +++ b/api/message_send.go @@ -3,6 +3,7 @@ package api import ( "fmt" "io" + "log" "mime/multipart" "net/http" "net/textproto" @@ -14,15 +15,7 @@ import ( "github.com/pkg/errors" ) -type SendMessageData struct { - Content string `json:"content"` - Nonce string `json:"nonce"` - TTS bool `json:"tts"` - - Embed *discord.Embed `json:"embed"` - - Files []SendMessageFile `json:"-"` -} +var quoteEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`) type SendMessageFile struct { Name string @@ -30,9 +23,39 @@ type SendMessageFile struct { Reader io.Reader } -var quoteEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`) +type SendMessageData struct { + Content string `json:"content,omitempty"` + Nonce string `json:"nonce,omitempty"` + TTS bool `json:"tts"` + + Embed *discord.Embed `json:"embed,omitempty"` + + Files []SendMessageFile `json:"-"` +} + +func (data *SendMessageData) WriteMultipart( + c json.Driver, w io.Writer) error { + + return writeMultipart(c, w, data, data.Files) +} + +type ExecuteWebhookData struct { + SendMessageData + + Username string `json:"username,omitempty"` + AvatarURL discord.URL `json:"avatar_url,omitempty"` +} + +func (data *ExecuteWebhookData) WriteMultipart( + c json.Driver, w io.Writer) error { + + return writeMultipart(c, w, data, data.Files) +} + +func writeMultipart( + c json.Driver, w io.Writer, + item interface{}, files []SendMessageFile) error { -func (data *SendMessageData) WriteMultipart(c json.Driver, w io.Writer) error { body := multipart.NewWriter(w) // Encode the JSON body first @@ -45,25 +68,24 @@ func (data *SendMessageData) WriteMultipart(c json.Driver, w io.Writer) error { return errors.Wrap(err, "Failed to create bodypart for JSON") } - if err := c.EncodeStream(w, data); err != nil { + j, err := c.Marshal(item) + log.Println(string(j), err) + + if err := c.EncodeStream(w, item); err != nil { return errors.Wrap(err, "Failed to encode JSON") } // Content-Type buffer var buf []byte - for i, file := range data.Files { + for i, file := range files { h := textproto.MIMEHeader{} h.Set("Content-Disposition", fmt.Sprintf( `form-data; name="file%d"; filename="%s"`, i, quoteEscaper.Replace(file.Name), )) - w, err := body.CreatePart(h) - if err != nil { - return errors.Wrap(err, "Failed to create bodypart for "+ - strconv.Itoa(i)) - } + var bufUsed int if file.ContentType == "" { if buf == nil { @@ -77,18 +99,24 @@ func (data *SendMessageData) WriteMultipart(c json.Driver, w io.Writer) error { } file.ContentType = http.DetectContentType(buf[:n]) - data.Files[i] = file + files[i] = file + bufUsed = n + } - h.Set("Content-Type", file.ContentType) + h.Set("Content-Type", file.ContentType) + w, err := body.CreatePart(h) + if err != nil { + return errors.Wrap(err, "Failed to create bodypart for "+ + strconv.Itoa(i)) + } + + if bufUsed > 0 { // Prematurely write - if _, err := w.Write(buf[:n]); err != nil { + if _, err := w.Write(buf[:bufUsed]); err != nil { return errors.Wrap(err, "Failed to write buffer for "+ strconv.Itoa(i)) } - - } else { - h.Set("Content-Type", file.ContentType) } if _, err := io.Copy(w, file.Reader); err != nil { diff --git a/api/webhook.go b/api/webhook.go new file mode 100644 index 0000000..e645113 --- /dev/null +++ b/api/webhook.go @@ -0,0 +1,140 @@ +package api + +import ( + "io" + "net/url" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/arikawa/internal/httputil" + "github.com/pkg/errors" +) + +const EndpointWebhooks = Endpoint + "webhooks/" + +// CreateWebhook creates a new webhook; avatar hash is optional. Requires +// MANAGE_WEBHOOKS. +func (c *Client) CreateWebhook( + channelID discord.Snowflake, + name string, avatar discord.Hash) (*discord.Webhook, error) { + + var param struct { + Name string `json:"name"` + Avatar discord.Hash `json:"avatar"` + } + + param.Name = name + param.Avatar = avatar + + var w *discord.Webhook + return w, c.RequestJSON( + &w, "POST", + EndpointChannels+channelID.String()+"/webhooks", + httputil.WithJSONBody(c, param), + ) +} + +// Webhooks requires MANAGE_WEBHOOKS. +func (c *Client) Webhooks( + guildID discord.Snowflake) ([]discord.Webhook, error) { + + var ws []discord.Webhook + return ws, c.RequestJSON(&ws, "GET", + EndpointGuilds+guildID.String()+"/webhooks") +} + +func (c *Client) Webhook( + webhookID discord.Snowflake) (*discord.Webhook, error) { + + var w *discord.Webhook + return w, c.RequestJSON(&w, "GET", + EndpointWebhooks+webhookID.String()) +} + +func (c *Client) WebhookWithToken( + webhookID discord.Snowflake, token string) (*discord.Webhook, error) { + + var w *discord.Webhook + return w, c.RequestJSON(&w, "GET", + EndpointWebhooks+webhookID.String()+"/"+token) +} + +type ModifyWebhookData struct { + Name string `json:"name,omitempty"` + Avatar discord.Hash `json:"avatar,omitempty"` // TODO: clear avatar how? + ChannelID discord.Snowflake `json:"channel_id,omitempty"` +} + +func (c *Client) ModifyWebhook( + webhookID discord.Snowflake, + data ModifyWebhookData) (*discord.Webhook, error) { + + var w *discord.Webhook + return w, c.RequestJSON(&w, "PATCH", + EndpointWebhooks+webhookID.String()) +} + +func (c *Client) ModifyWebhookWithToken( + webhookID discord.Snowflake, + data ModifyWebhookData, token string) (*discord.Webhook, error) { + + var w *discord.Webhook + return w, c.RequestJSON(&w, "PATCH", + EndpointWebhooks+webhookID.String()+"/"+token) +} + +func (c *Client) DeleteWebhook(webhookID discord.Snowflake) error { + return c.FastRequest("DELETE", EndpointWebhooks+webhookID.String()) +} + +func (c *Client) DeleteWebhookWithToken( + webhookID discord.Snowflake, token string) error { + + return c.FastRequest("DELETE", + EndpointWebhooks+webhookID.String()+"/"+token) +} + +// ExecuteWebhook sends a message to the webhook. If wait is bool, Discord will +// wait for the message to be delivered and will return the message body. This +// also means the returned message will only be there if wait is true. +func (c *Client) ExecuteWebhook( + webhookID discord.Snowflake, token string, wait bool, + data ExecuteWebhookData) (*discord.Message, error) { + + if data.Embed != nil { + if err := data.Embed.Validate(); err != nil { + return nil, errors.Wrap(err, "Embed error") + } + } + + var param = url.Values{} + if wait { + param.Set("wait", "true") + } + + var URL = EndpointWebhooks + webhookID.String() + "?" + param.Encode() + var msg *discord.Message + + if len(data.Files) == 0 { + // No files, so no need for streaming. + return msg, c.RequestJSON(&msg, "POST", URL, + httputil.WithJSONBody(c, data)) + } + + writer := func(w io.Writer) error { + return data.WriteMultipart(c, w) + } + + resp, err := c.MeanwhileBody(writer, "POST", URL) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if !wait { + // Since we didn't tell Discord to wait, we have nothing to parse. + return nil, nil + } + + return msg, c.DecodeStream(resp.Body, &msg) +} diff --git a/discord/webhook.go b/discord/webhook.go new file mode 100644 index 0000000..1f03f36 --- /dev/null +++ b/discord/webhook.go @@ -0,0 +1,22 @@ +package discord + +type Webhook struct { + ID Snowflake `json:"id"` + Type WebhookType `json:"type"` + User User `json:"user"` // creator + + GuildID Snowflake `json:"guild_id,omitempty"` + ChannelID Snowflake `json:"channel_id"` + + Name string `json:"name"` + Avatar Hash `json:"avatar"` + Token string `json:"token"` // incoming webhooks only +} + +type WebhookType uint8 + +const ( + _ WebhookType = iota + IncomingWebhook + ChannelFollowerWebhook +) diff --git a/internal/httputil/client.go b/internal/httputil/client.go index d20a67d..ac0e9ab 100644 --- a/internal/httputil/client.go +++ b/internal/httputil/client.go @@ -6,6 +6,7 @@ import ( "context" "io" "io/ioutil" + "log" "net/http" "time" @@ -35,6 +36,8 @@ func (c *Client) MeanwhileBody(bodyWriter func(io.Writer) error, // We want to cancel the request if our bodyWriter fails ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r, w := io.Pipe() var bgErr error @@ -44,6 +47,9 @@ func (c *Client) MeanwhileBody(bodyWriter func(io.Writer) error, bgErr = err cancel() } + + // Close the writer so the body gets flushed to the HTTP reader. + w.Close() }() resp, err := c.RequestCtx(ctx, method, url, @@ -87,6 +93,7 @@ func (c *Client) RequestCtx(ctx context.Context, r, err := c.Client.Do(req) if err != nil { + log.Println("Do error", url, err) return nil, RequestError{err} } @@ -120,6 +127,11 @@ func (c *Client) RequestCtxJSON(ctx context.Context, defer r.Body.Close() + // No content, working as intended (tm) + if r.StatusCode == http.StatusNoContent { + return nil + } + if err := c.DecodeStream(r.Body, to); err != nil { return JSONError{err} } diff --git a/internal/httputil/client_test.go b/internal/httputil/client_test.go new file mode 100644 index 0000000..6ce27e0 --- /dev/null +++ b/internal/httputil/client_test.go @@ -0,0 +1,63 @@ +package httputil + +import ( + "io" + "io/ioutil" + "net" + "net/http" + "testing" +) + +func TestMeanwhileBody(t *testing.T) { + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal("Can't read body:", err) + } + + if s := string(b); s != "Hime" { + t.Fatal("Unexpected body:", s) + } + + w.Write([]byte("Arikawa")) + }) + + addr := startHTTP(t) + c := NewClient() + w := func(w io.Writer) error { + w.Write([]byte("Hime")) + return nil + } + + r, err := c.MeanwhileBody(w, "GET", "http://"+addr) + if err != nil { + t.Fatal("Failed to send request:", err) + } + + defer r.Body.Close() + + b, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal("Can't read body:", err) + } + + if s := string(b); s != "Arikawa" { + t.Fatal("Unexpected body:", s) + } +} + +func startHTTP(t *testing.T) string { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal("TCP error:", err) + } + + go func() { + if err := http.Serve(listener, nil); err != nil { + t.Fatal("HTTP error:", err) + } + }() + + return listener.Addr().(*net.TCPAddr).String() +} diff --git a/internal/httputil/options.go b/internal/httputil/options.go index c3f178e..bc75f81 100644 --- a/internal/httputil/options.go +++ b/internal/httputil/options.go @@ -16,6 +16,11 @@ func JSONRequest(r *http.Request) error { return nil } +func MultipartRequest(r *http.Request) error { + r.Header.Set("Content-Type", "multipart/form-data") + return nil +} + func WithSchema(schema SchemaEncoder, v interface{}) RequestOption { return func(r *http.Request) error { params, err := schema.Encode(v) @@ -35,7 +40,10 @@ func WithSchema(schema SchemaEncoder, v interface{}) RequestOption { func WithBody(body io.ReadCloser) RequestOption { return func(r *http.Request) error { + // tee := io.TeeReader(body, os.Stderr) + // r.Body = ioutil.NopCloser(tee) r.Body = body + r.ContentLength = -1 return nil } }