arikawa/api/send.go

264 lines
7.4 KiB
Go

package api
import (
"io"
"mime/multipart"
"net/url"
"strconv"
"strings"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/utils/httputil"
"github.com/diamondburned/arikawa/utils/json"
"github.com/pkg/errors"
)
const AttachmentSpoilerPrefix = "SPOILER_"
var quoteEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
// AllowedMentions is a whitelist of mentions for a message.
// https://discordapp.com/developers/docs/resources/channel#allowed-mentions-object
//
// Whitelists
//
// Roles and Users are slices that act as whitelists for IDs that are allowed to
// be mentioned. For example, if only 1 ID is provided in Users, then only that
// ID will be parsed in the message. No other IDs will be. The same example also
// applies for roles.
//
// If Parse is an empty slice and both Users and Roles are empty slices, then no
// mentions will be parsed.
//
// Constraints
//
// If the Users slice is not empty, then Parse must not have AllowUserMention.
// Likewise, if the Roles slice is not empty, then Parse must not have
// AllowRoleMention. This is because everything provided in Parse will make
// Discord parse it completely, meaning they would be mutually exclusive with
// whitelist slices, Roles and Users.
type AllowedMentions struct {
Parse []AllowedMentionType `json:"parse"`
Roles []discord.Snowflake `json:"roles,omitempty"` // max 100
Users []discord.Snowflake `json:"users,omitempty"` // max 100
}
// AllowedMentionType is a constant that tells Discord what is allowed to parse
// from a message content. This can help prevent things such as an unintentional
// @everyone mention.
type AllowedMentionType string
const (
// AllowRoleMention makes Discord parse roles in the content.
AllowRoleMention AllowedMentionType = "roles"
// AllowUserMention makes Discord parse user mentions in the content.
AllowUserMention AllowedMentionType = "users"
// AllowEveryoneMention makes Discord parse @everyone mentions.
AllowEveryoneMention AllowedMentionType = "everyone"
)
// Verify checks the AllowedMentions against constraints mentioned in
// AllowedMentions' documentation. This will be called on SendMessageComplex.
func (am AllowedMentions) Verify() error {
if len(am.Roles) > 100 {
return errors.Errorf("Roles slice length %d is over 100", len(am.Roles))
}
if len(am.Users) > 100 {
return errors.Errorf("Users slice length %d is over 100", len(am.Users))
}
for _, allowed := range am.Parse {
switch allowed {
case AllowRoleMention:
if len(am.Roles) > 0 {
return errors.New(`Parse has AllowRoleMention and Roles slice is not empty`)
}
case AllowUserMention:
if len(am.Users) > 0 {
return errors.New(`Parse has AllowUserMention and Users slice is not empty`)
}
}
}
return nil
}
// ErrEmptyMessage is returned if either a SendMessageData or an
// ExecuteWebhookData has both an empty Content and no Embed(s).
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.
type SendMessageData struct {
// Either of these fields must not be empty.
Content string `json:"content,omitempty"`
Nonce string `json:"nonce,omitempty"`
TTS bool `json:"tts,omitempty"`
Embed *discord.Embed `json:"embed,omitempty"`
Files []SendMessageFile `json:"-"`
AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"`
}
func (data *SendMessageData) WriteMultipart(body *multipart.Writer) error {
return writeMultipart(body, data, data.Files)
}
func (c *Client) SendMessageComplex(
channelID discord.Snowflake, data SendMessageData) (*discord.Message, error) {
if data.Content == "" && data.Embed == nil && len(data.Files) == 0 {
return nil, ErrEmptyMessage
}
if data.AllowedMentions != nil {
if err := data.AllowedMentions.Verify(); err != nil {
return nil, errors.Wrap(err, "AllowedMentions error")
}
}
if data.Embed != nil {
if err := data.Embed.Validate(); err != nil {
return nil, errors.Wrap(err, "Embed error")
}
}
var URL = EndpointChannels + channelID.String() + "/messages"
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(data))
}
writer := func(mw *multipart.Writer) error {
return data.WriteMultipart(mw)
}
resp, err := c.MeanwhileMultipart(writer, "POST", URL)
if err != nil {
return nil, err
}
var body = resp.GetBody()
defer body.Close()
return msg, json.DecodeStream(body, &msg)
}
type ExecuteWebhookData struct {
// Either of these fields must not be empty.
Content string `json:"content,omitempty"`
Nonce string `json:"nonce,omitempty"`
TTS bool `json:"tts,omitempty"`
Embeds []discord.Embed `json:"embeds,omitempty"`
Files []SendMessageFile `json:"-"`
AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"`
// Optional fields specific to Webhooks.
Username string `json:"username,omitempty"`
AvatarURL discord.URL `json:"avatar_url,omitempty"`
}
func (data *ExecuteWebhookData) WriteMultipart(body *multipart.Writer) error {
return writeMultipart(body, data, data.Files)
}
// 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, // if false, then nil returned for *Message.
data ExecuteWebhookData) (*discord.Message, error) {
if data.Content == "" && len(data.Embeds) == 0 && len(data.Files) == 0 {
return nil, ErrEmptyMessage
}
if data.AllowedMentions != nil {
if err := data.AllowedMentions.Verify(); err != nil {
return nil, errors.Wrap(err, "AllowedMentions error")
}
}
for i, embed := range data.Embeds {
if err := embed.Validate(); err != nil {
return nil, errors.Wrap(err, "Embed error at "+strconv.Itoa(i))
}
}
var param = url.Values{}
if wait {
param.Set("wait", "true")
}
var URL = EndpointWebhooks + webhookID.String() + "/" + token + "?" + 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(data))
}
writer := func(mw *multipart.Writer) error {
return data.WriteMultipart(mw)
}
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)
}
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
}