From 09d965150749e8549f458b699685e95067818141 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Wed, 1 Jan 2020 21:39:52 -0800 Subject: [PATCH] Initial commit --- LICENSE | 202 +++++++++++++++++++++++++++ README.md | 3 + api/api.go | 49 +++++++ api/channel.go | 190 +++++++++++++++++++++++++ api/emoji.go | 58 ++++++++ api/errors.go | 22 +++ api/guild.go | 7 + api/invite.go | 90 ++++++++++++ api/message.go | 301 ++++++++++++++++++++++++++++++++++++++++ api/message_embed.go | 146 +++++++++++++++++++ api/message_reaction.go | 91 ++++++++++++ api/message_send.go | 103 ++++++++++++++ api/user.go | 49 +++++++ discord/color.go | 5 + discord/image.go | 22 +++ discord/seconds.go | 17 +++ discord/snowflake.go | 38 +++++ discord/time.go | 29 ++++ discord/url.go | 4 + go.mod | 5 + go.sum | 2 + httputil/client.go | 133 ++++++++++++++++++ httputil/errors.go | 42 ++++++ httputil/http.go | 36 +++++ httputil/options.go | 50 +++++++ json/json.go | 65 +++++++++ 26 files changed, 1759 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/api.go create mode 100644 api/channel.go create mode 100644 api/emoji.go create mode 100644 api/errors.go create mode 100644 api/guild.go create mode 100644 api/invite.go create mode 100644 api/message.go create mode 100644 api/message_embed.go create mode 100644 api/message_reaction.go create mode 100644 api/message_send.go create mode 100644 api/user.go create mode 100644 discord/color.go create mode 100644 discord/image.go create mode 100644 discord/seconds.go create mode 100644 discord/snowflake.go create mode 100644 discord/time.go create mode 100644 discord/url.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httputil/client.go create mode 100644 httputil/errors.go create mode 100644 httputil/http.go create mode 100644 httputil/options.go create mode 100644 json/json.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7311b2c --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 diamondburned (Forefront) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f28bb4e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# arikawa + +A Golang library for the Discord API. diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..751b278 --- /dev/null +++ b/api/api.go @@ -0,0 +1,49 @@ +package api + +import ( + "net/http" + + "git.sr.ht/~diamondburned/arikawa/httputil" +) + +const ( + BaseEndpoint = "https://discordapp.com/api" + APIVersion = "6" + + Endpoint = BaseEndpoint + "/v" + APIVersion + "/" + EndpointUsers = Endpoint + "users/" + EndpointGateway = Endpoint + "gateway" + EndpointGatewayBot = EndpointGateway + "/bot" + EndpointWebhooks = Endpoint + "webhooks/" +) + +var UserAgent = "DiscordBot (https://github.com/diamondburned/arikawa, v0.0.1)" + +type Client struct { + httputil.Client + Token string +} + +func NewClient(token string) *Client { + cli := &Client{ + Client: httputil.NewClient(), + Token: token, + } + + tw := httputil.NewTransportWrapper() + tw.Pre = func(r *http.Request) error { + if r.Header.Get("Authorization") == "" { + r.Header.Set("Authorization", cli.Token) + } + + if r.UserAgent() == "" { + r.Header.Set("User-Agent", UserAgent) + } + + return nil + } + + cli.Client.Transport = tw + + return cli +} diff --git a/api/channel.go b/api/channel.go new file mode 100644 index 0000000..853538d --- /dev/null +++ b/api/channel.go @@ -0,0 +1,190 @@ +package api + +import ( + "git.sr.ht/~diamondburned/arikawa/discord" + "git.sr.ht/~diamondburned/arikawa/httputil" + "git.sr.ht/~diamondburned/arikawa/json" +) + +const EndpointChannels = Endpoint + "channels/" + +type Channel struct { + ID discord.Snowflake `json:"id,string"` + Type ChannelType `json:"type"` + + // Fields below may not appear + + GuildID discord.Snowflake `json:"guild_id,string,omitempty"` + + Position int `json:"position,omitempty"` + Name string `json:"name,omitempty"` // 2-100 chars + Topic string `json:"topic,omitempty"` // 0-1024 chars + NSFW bool `json:"nsfw"` + + Icon discord.Hash `json:"icon,omitempty"` + + // Direct Messaging fields + DMOwnerID discord.Snowflake `json:"owner_id,omitempty"` + DMRecipients []User `json:"recipients,omitempty"` + + // AppID of the group DM creator if it's bot-created + AppID discord.Snowflake `json:"application_id,omitempty"` + + // ID of the category the channel is in, if any. + CategoryID discord.Snowflake `json:"parent_id,omitempty"` + + LastPinTime discord.Timestamp `json:"last_pin_timestamp,omitempty"` + + // Explicit permission overrides for members and roles. + Permissions []Overwrite `json:"permission_overwrites,omitempty"` + // ID of the last message, may not point to a valid one. + LastMessageID discord.Snowflake `json:"last_message_id,omitempty"` + + // Slow mode duration. Bots and people with "manage_messages" or + // "manage_channel" permissions are unaffected. + UserRateLimit discord.Seconds `json:"rate_limit_per_user,omitempty"` + + // Voice, so GuildVoice only + VoiceBitrate int `json:"bitrate,omitempty"` + VoiceUserLimit int `json:"user_limit,omitempty"` +} + +type ChannelType uint8 + +const ( + GuildText ChannelType = iota + DirectMessage + GuildVoice + GroupDM + GuildCategory + GuildNews + GuildStore +) + +type Overwrite struct { + ID discord.Snowflake `json:"id,omitempty"` + Type OverwriteType `json:"type"` + Allow uint64 `json:"allow"` + Deny uint64 `json:"deny"` +} + +type OverwriteType string + +const ( + OverwriteRole OverwriteType = "role" + OverwriteMember OverwriteType = "member" +) + +type ChannelModifier struct { + ChannelID discord.Snowflake `json:"id,omitempty"` + + // All types + Name string `json:"name,omitempty"` + Position json.OptionInt `json:"position,omitempty"` + Permissions []Overwrite `json:"permission_overwrites,omitempty"` + + // Text only + Topic json.OptionString `json:"topic,omitempty"` + NSFW json.OptionBool `json:"nsfw,omitempty"` + + // 0-21600, refer to Channel.UserRateLimit + UserRateLimit discord.Seconds `json:"rate_limit_per_user,omitempty"` + + // Voice only + // 8000 - 96000 (or 128000 for Nitro) + Bitrate json.OptionUint `json:"bitrate,omitempty"` + // 0 no limit, 1-99 + UserLimit json.OptionUint `json:"user_limit,omitempty"` + + // Text OR Voice + ParentID discord.Snowflake `json:"parent_id,omitempty"` +} + +func (c *Client) Channel(channelID discord.Snowflake) (*Channel, error) { + var channel *Channel + + return channel, + c.RequestJSON(&channel, "POST", EndpointChannels+channelID.String()) +} + +func (c *Client) EditChannel(mod ChannelModifier) error { + url := EndpointChannels + mod.ChannelID.String() + mod.ChannelID = 0 + + return c.FastRequest("PATCH", url, httputil.WithJSONBody(c, mod)) +} + +func (c *Client) DeleteChannel(channelID discord.Snowflake) error { + return c.FastRequest("DELETE", EndpointChannels+channelID.String()) +} + +func (c *Client) EditChannelPermission(channelID discord.Snowflake, + overwrite Overwrite) error { + + url := EndpointChannels + channelID.String() + "/permissions/" + + overwrite.ID.String() + overwrite.ID = 0 + + return c.FastRequest("PUT", url, httputil.WithJSONBody(c, overwrite)) +} + +func (c *Client) DeleteChannelPermission( + channelID, overwriteID discord.Snowflake) error { + + return c.FastRequest("DELETE", EndpointChannels+channelID.String()+ + "/permissions/"+overwriteID.String()) +} + +// Typing posts a typing indicator to the channel. Undocumented, but the client +// usually clears the typing indicator after 8-10 seconds (or after a message). +func (c *Client) Typing(channelID discord.Snowflake) error { + return c.FastRequest("POST", + EndpointChannels+channelID.String()+"/typing") +} + +func (c *Client) PinnedMessages( + channelID discord.Snowflake) ([]Message, error) { + + var pinned []Message + return pinned, c.RequestJSON(&pinned, "GET", + EndpointChannels+channelID.String()+"/pins") +} + +// PinMessage pins a message, which requires MANAGE_MESSAGES/ +func (c *Client) PinMessage(channelID, messageID discord.Snowflake) error { + return c.FastRequest("PUT", + EndpointChannels+channelID.String()+"/pins/"+messageID.String()) +} + +// UnpinMessage also requires MANAGE_MESSAGES. +func (c *Client) UnpinMessage(channelID, messageID discord.Snowflake) error { + return c.FastRequest("DELETE", + EndpointChannels+channelID.String()+"/pins/"+messageID.String()) +} + +// AddRecipient adds a user to a group direct message. As accessToken is needed, +// clearly this endpoint should only be used for OAuth. AccessToken can be +// obtained with the "gdm.join" scope. +func (c *Client) AddRecipient(channelID, userID discord.Snowflake, + accessToken, nickname string) error { + + var params struct { + AccessToken string `json:"access_token"` + Nickname string `json:"nickname"` + } + + params.AccessToken = accessToken + params.Nickname = nickname + + return c.FastRequest( + "PUT", + EndpointChannels+channelID.String()+"/recipients/"+userID.String(), + httputil.WithJSONBody(c, params), + ) +} + +// RemoveRecipient removes a user from a group direct message. +func (c *Client) RemoveRecipient(channelID, userID discord.Snowflake) error { + return c.FastRequest("DELETE", + EndpointChannels+channelID.String()+"/recipients/"+userID.String()) +} diff --git a/api/emoji.go b/api/emoji.go new file mode 100644 index 0000000..7e13455 --- /dev/null +++ b/api/emoji.go @@ -0,0 +1,58 @@ +package api + +import ( + "strings" + + "git.sr.ht/~diamondburned/arikawa/discord" +) + +type Emoji struct { + ID discord.Snowflake `json:"id"` // 0 for Unicode emojis + Name string `json:"name"` + + // These fields are optional + + RoleIDs []discord.Snowflake `json:"roles,omitempty"` + User User `json:"user,omitempty"` + + RequireColons bool `json:"require_colons,omitempty"` + Managed bool `json:"managed,omitempty"` + Animated bool `json:"animated,omitempty"` +} + +// EmojiAPI is a special format that the API wants. +type EmojiAPI = string + +func FormatEmojiAPI(id discord.Snowflake, name string) string { + if id == 0 { + return name + } + + return id.String() + ":" + name +} + +// APIString returns a string usable for sending over to the API. +func (e Emoji) APIString() EmojiAPI { + if e.ID == 0 { + return e.Name // is unicode + } + + return e.ID.String() + ":" + e.Name +} + +// String formats the string like how the client does. +func (e Emoji) String() string { + if e.ID == 0 { + return e.Name + } + + var parts = []string{ + "", e.Name, e.ID.String(), + } + + if e.Animated { + parts[0] = "a" + } + + return "<" + strings.Join(parts, ":") + ">" +} diff --git a/api/errors.go b/api/errors.go new file mode 100644 index 0000000..a662ed0 --- /dev/null +++ b/api/errors.go @@ -0,0 +1,22 @@ +package api + +import ( + "fmt" +) + +type ErrOverbound struct { + Count int + Max int + + Thing string +} + +var _ error = (*ErrOverbound)(nil) + +func (e ErrOverbound) Error() string { + if e.Thing == "" { + return fmt.Sprintf("Overbound error: %d > %d", e.Count, e.Max) + } + + return fmt.Sprintf(e.Thing+" overbound: %d > %d", e.Count, e.Max) +} diff --git a/api/guild.go b/api/guild.go new file mode 100644 index 0000000..a02054d --- /dev/null +++ b/api/guild.go @@ -0,0 +1,7 @@ +package api + +const EndpointGuilds = Endpoint + "guilds/" + +type Guild struct{} + +type GuildMember struct{} diff --git a/api/invite.go b/api/invite.go new file mode 100644 index 0000000..0d32b73 --- /dev/null +++ b/api/invite.go @@ -0,0 +1,90 @@ +package api + +import "git.sr.ht/~diamondburned/arikawa/discord" + +const EndpointInvites = Endpoint + "invites/" + +type MetaInvite struct { + Inviter User `json:"user"` + Uses uint `json:"uses"` + MaxUses uint `json:"max_uses"` + + MaxAge discord.Seconds `json:"max_age"` + + Temporary bool `json:"temporary"` + CreatedAt discord.Timestamp `json:"created_at"` +} + +type Invite struct { + Code string `json:"code"` + Channel Channel `json:"channel"` // partial + Guild *Guild `json:"guild,omitempty"` // partial + + ApproxMembers uint `json:"approximate_members_count,omitempty"` + + Target *User `json:"target_user,omitempty"` // partial + TargetType InviteUserType `json:"target_user_type,omitempty"` + + // Only available if Target is + ApproxPresences uint `json:"approximate_presence_count,omitempty"` +} + +type InviteUserType uint8 + +const ( + InviteNormalUser InviteUserType = iota + InviteUserStream +) + +func (c *Client) Invite(code string) (*Invite, error) { + var params struct { + WithCounts bool `json:"with_counts,omitempty"` + } + + // Nothing says I can't! + params.WithCounts = true + + var inv *Invite + return inv, c.RequestJSON(&inv, "GET", EndpointInvites+code) +} + +// Invites is only for guild channels. +func (c *Client) Invites(channelID discord.Snowflake) ([]Invite, error) { + var invs []Invite + return invs, c.RequestJSON(&invs, "GET", + EndpointChannels+channelID.String()+"/invites") +} + +// CreateInvite is only for guild channels. This endpoint requires +// CREATE_INSTANT_INVITE. +// +// MaxAge is the duration before expiry, 0 for never. MaxUses is the maximum +// number of uses, 0 for unlimited. Temporary is whether this invite grants +// temporary membership. Unique, if true, tries not to reuse a similar invite, +// useful for creating unique one time use invites. +func (c *Client) CreateInvite(channelID discord.Snowflake, + maxAge discord.Seconds, maxUses uint, temp, unique bool) (*Invite, error) { + + var params struct { + MaxAge uint `json:"max_age"` + MaxUses uint `json:"max_uses"` + Temporary bool `json:"temporary"` + Unique bool `json:"unique"` + } + + params.MaxAge = uint(maxAge) + params.MaxUses = maxUses + params.Temporary = temp + params.Unique = unique + + var inv *Invite + return inv, c.RequestJSON(&inv, "POST", + EndpointChannels+channelID.String()+"/invites") +} + +// DeleteInvite requires either MANAGE_CHANNELS on the target channel, or +// MANAGE_GUILD to remove any invite in the guild. +func (c *Client) DeleteInvite(code string) (*Invite, error) { + var inv *Invite + return inv, c.RequestJSON(&inv, "DELETE", EndpointInvites+code) +} diff --git a/api/message.go b/api/message.go new file mode 100644 index 0000000..0ca608f --- /dev/null +++ b/api/message.go @@ -0,0 +1,301 @@ +package api + +import ( + "io" + + "git.sr.ht/~diamondburned/arikawa/discord" + "git.sr.ht/~diamondburned/arikawa/httputil" + "github.com/pkg/errors" +) + +type Message struct { + ID discord.Snowflake `json:"id"` + Type MessageType `json:"type"` + ChannelID discord.Snowflake `json:"channel_id"` + GuildID discord.Snowflake `json:"guild_id,omitempty"` + + // The author object follows the structure of the user object, but is only + // a valid user in the case where the message is generated by a user or bot + // user. If the message is generated by a webhook, the author object + // corresponds to the webhook's id, username, and avatar. You can tell if a + // message is generated by a webhook by checking for the webhook_id on the + // message object. + Author User `json:"author"` + + // The member object exists in MESSAGE_CREATE and MESSAGE_UPDATE + // events from text-based guild channels. + Member *GuildMember `json:"member,omitempty"` + + Content string `json:"content"` + + Timestamp discord.Timestamp `json:"timestamp,omitempty"` + EditedTimestamp *discord.Timestamp `json:"edited_timestamp,omitempty"` + + TTS bool `json:"tts"` + Pinned bool `json:"pinned"` + + // The user objects in the mentions array will only have the partial + // member field present in MESSAGE_CREATE and MESSAGE_UPDATE events from + // text-based guild channels. + Mentions []GuildUser `json:"mentions"` + + MentionRoleIDs []discord.Snowflake `json:"mention_roles"` + MentionEveryone bool `json:"mention_everyone"` + + // Not all channel mentions in a message will appear in mention_channels. + MentionChannels []ChannelMention `json:"mention_channels,omitempty"` + + Attachments []Attachment `json:"attachments"` + Embeds []Embed `json:"embeds"` + + Reactions []Reaction `json:"reaction,omitempty"` + + // Used for validating a message was sent + Nonce string `json:"nonce,omitempty"` + + WebhookID discord.Snowflake `json:"webhook_id,omitempty"` + Activity *MessageActivity `json:"activity,omitempty"` + Application *MessageApplication `json:"application,omitempty"` + Reference *MessageReference `json:"message_reference,omitempty"` + Flags MessageFlags `json:"flags"` +} + +type MessageType uint8 + +const ( + DefaultMessage MessageType = iota + RecipientAddMessage + RecipientRemoveMessage + CallMessage + ChannelNameChangeMessage + ChannelIconChangeMessage + ChannelPinnedMessage + GuildMemberJoinMessage + NitroBoostMessage + NitroTier1Message + NitroTier2Message + NitroTier3Message + ChannelFollowAddMessage +) + +type MessageFlags uint8 + +const ( + CrosspostedMessage MessageFlags = 1 << iota + MessageIsCrosspost + SuppressEmbeds + SourceMessageDeleted + UrgentMessage +) + +type ChannelMention struct { + ChannelID discord.Snowflake `json:"id"` + GuildID discord.Snowflake `json:"guild_id"` + ChannelType ChannelType `json:"type"` + ChannelName string `json:"name"` +} + +type GuildUser struct { + User + Member *GuildMember `json:"member,omitempty"` +} + +// + +type MessageActivity struct { + Type MessageActivityType `json:"type"` + + // From a Rich Presence event + PartyID string `json:"party_id,omitempty"` +} + +type MessageActivityType uint8 + +const ( + JoinMessage MessageActivityType = iota + 1 + SpectateMessage + ListenMessage + JoinRequestMessage +) + +// + +type MessageApplication struct { + ID discord.Snowflake `json:"id"` + CoverID string `json:"cover_image,omitempty"` + Description string `json:"description"` + Icon string `json:"icon"` + Name string `json:"name"` +} + +// + +type MessageReference struct { + ChannelID discord.Snowflake `json:"channel_id"` + + // Field might not be provided + MessageID discord.Snowflake `json:"message_id,omitempty"` + GuildID discord.Snowflake `json:"guild_id,omitempty"` +} + +// + +type Attachment struct { + ID discord.Snowflake `json:"id"` + Filename string `json:"filename"` + Size uint64 `json:"size"` + + URL discord.URL `json:"url"` + Proxy discord.URL `json:"proxy_url"` + + // Only if Image + Height uint `json:"height,omitempty"` + Width uint `json:"width,omitempty"` +} + +// + +func (c *Client) Messages( + channelID discord.Snowflake, limit uint) ([]Message, error) { + + return c.messages(channelID, limit, nil) +} + +func (c *Client) MessagesAround( + channelID, around discord.Snowflake, limit uint) ([]Message, error) { + + return c.messages(channelID, limit, map[string]interface{}{ + "around": around, + }) +} + +func (c *Client) MessagesBefore( + channelID, before discord.Snowflake, limit uint) ([]Message, error) { + + return c.messages(channelID, limit, map[string]interface{}{ + "before": before, + }) +} + +func (c *Client) MessagesAfter( + channelID, after discord.Snowflake, limit uint) ([]Message, error) { + + return c.messages(channelID, limit, map[string]interface{}{ + "after": after, + }) +} + +func (c *Client) messages(channelID discord.Snowflake, + limit uint, body map[string]interface{}) ([]Message, error) { + + if body == nil { + body = map[string]interface{}{} + } + + switch { + case limit == 0: + limit = 50 + case limit > 100: + limit = 100 + } + + body["limit"] = limit + + var msgs []Message + return msgs, c.RequestJSON(&msgs, "GET", + EndpointChannels+channelID.String(), httputil.WithJSONBody(c, body)) +} + +func (c *Client) Message( + channelID, messageID discord.Snowflake) (*Message, error) { + + var msg *Message + return msg, c.RequestJSON(&msg, "GET", + EndpointChannels+channelID.String()+"/messages/"+messageID.String()) +} + +func (c *Client) SendMessage(channelID discord.Snowflake, + content string, embed *Embed) (*Message, error) { + + return c.SendMessageComplex(channelID, SendMessageData{ + Content: content, + Embed: embed, + }) +} + +func (c *Client) SendMessageComplex(channelID discord.Snowflake, + data SendMessageData) (*Message, error) { + + if data.Embed != nil { + if err := data.Embed.Validate(); err != nil { + return nil, errors.Wrap(err, "Embed error") + } + } + + var URL = EndpointChannels + channelID.String() + var msg *Message + + if len(data.Files) == 0 { + // No files, 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() + + return msg, c.DecodeStream(resp.Body, &msg) +} + +func (c *Client) EditMessage(channelID, messageID discord.Snowflake, + content string, embed *Embed, suppressEmbeds bool) (*Message, error) { + + var param struct { + Content string `json:"content,omitempty"` + Embed *Embed `json:"embed,omitempty"` + Flags MessageFlags `json:"flags,omitempty"` + } + + param.Content = content + param.Embed = embed + if suppressEmbeds { + param.Flags = SuppressEmbeds + } + + var msg *Message + return msg, c.RequestJSON( + &msg, "PATCH", + EndpointChannels+channelID.String()+"/messages/"+messageID.String(), + httputil.WithJSONBody(c, param), + ) +} + +// DeleteMessage deletes a message. Requires MANAGE_MESSAGES if the message is +// not made by yourself. +func (c *Client) DeleteMessage(channelID, messageID discord.Snowflake) error { + return c.FastRequest("DELETE", EndpointChannels+channelID.String()+ + "/messages/"+messageID.String()) +} + +// DeleteMessages only works for bots. It can't delete messages older than 2 +// weeks, and will fail if tried. This endpoint requires MANAGE_MESSAGES. +func (c *Client) DeleteMessages(channelID discord.Snowflake, + messageIDs []discord.Snowflake) error { + + var param struct { + Messages []discord.Snowflake `json:"messages"` + } + + param.Messages = messageIDs + + return c.FastRequest("POST", EndpointChannels+channelID.String()+ + "/messages/bulk-delete", httputil.WithJSONBody(c, param)) +} diff --git a/api/message_embed.go b/api/message_embed.go new file mode 100644 index 0000000..fafaf25 --- /dev/null +++ b/api/message_embed.go @@ -0,0 +1,146 @@ +package api + +import ( + "fmt" + + "git.sr.ht/~diamondburned/arikawa/discord" +) + +type Embed struct { + Title string `json:"title,omitempty"` + Type EmbedType `json:"type,omitempty"` + Description string `json:"description,omitempty"` + + URL discord.URL `json:"url,omitempty"` + + Timestamp discord.Timestamp `json:"timestamp,omitempty"` + Color discord.Color `json:"color,omitempty"` + + Footer *EmbedFooter `json:"footer,omitempty"` + Image *EmbedImage `json:"image,omitempty"` + Thumbnail *EmbedThumbnail `json:"thumbnail,omitempty"` + Video *EmbedVideo `json:"video,omitempty"` + Provider *EmbedProvider `json:"provider,omitempty"` + Author *EmbedAuthor `json:"author,omitempty"` + Fields []EmbedField `json:"fields,omitempty"` +} + +func NewEmbed() *Embed { + return &Embed{ + Type: NormalEmbed, + Color: discord.DefaultColor, + } +} + +func (e *Embed) Validate() error { + if e.Type == "" { + e.Type = NormalEmbed + } + + if e.Color == 0 { + e.Color = discord.DefaultColor + } + + if len(e.Title) > 256 { + return &ErrOverbound{len(e.Title), 256, "Title"} + } + + if len(e.Description) > 2048 { + return &ErrOverbound{len(e.Description), 2048, "Description"} + } + + if len(e.Fields) > 25 { + return &ErrOverbound{len(e.Fields), 25, "Fields"} + } + + var sum = 0 + + len(e.Title) + + len(e.Description) + + if e.Footer != nil { + if len(e.Footer.Text) > 2048 { + return &ErrOverbound{len(e.Footer.Text), 2048, "Footer text"} + } + + sum += len(e.Footer.Text) + } + + if e.Author != nil { + if len(e.Author.Name) > 256 { + return &ErrOverbound{len(e.Author.Name), 256, "Author name"} + } + + sum += len(e.Author.Name) + } + + for i, field := range e.Fields { + if len(field.Name) > 256 { + return &ErrOverbound{len(field.Name), 256, + fmt.Sprintf("field %d name", i)} + } + + if len(field.Value) > 1024 { + return &ErrOverbound{len(field.Value), 1024, + fmt.Sprintf("field %s value", i)} + } + + sum += len(field.Name) + len(field.Value) + } + + if sum > 6000 { + return &ErrOverbound{sum, 6000, "Sum of all characters"} + } + + return nil +} + +type EmbedType string + +const ( + NormalEmbed = "rich" + ImageEmbed = "image" + VideoEmbed = "video" + // Undocumented +) + +type EmbedFooter struct { + Text string `json:"text"` + Icon discord.URL `json:"icon_url,omitempty"` + ProxyIcon discord.URL `json:"proxy_icon_url,omitempty"` +} + +type EmbedImage struct { + URL discord.URL `json:"url"` + Proxy discord.URL `json:"proxy_url"` +} + +type EmbedThumbnail struct { + URL discord.URL `json:"url,omitempty"` + Proxy discord.URL `json:"proxy_url,omitempty"` + Height uint `json:"height,omitempty"` + Width uint `json:"width,omitempty"` +} + +type EmbedVideo struct { + URL discord.URL `json:"url"` + Height uint `json:"height"` + Width uint `json:"width"` +} + +type EmbedProvider struct { + Name string `json:"name"` + URL discord.URL `json:"url"` +} + +type EmbedAuthor struct { + Name string `json:"name,omitempty"` + URL discord.URL `json:"url,omitempty"` + Icon discord.URL `json:"icon_url,omitempty"` + ProxyIcon discord.URL `json:"proxy_icon_url,omitempty"` +} + +type EmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline,omitempty"` +} diff --git a/api/message_reaction.go b/api/message_reaction.go new file mode 100644 index 0000000..27824f7 --- /dev/null +++ b/api/message_reaction.go @@ -0,0 +1,91 @@ +package api + +import ( + "git.sr.ht/~diamondburned/arikawa/discord" + "git.sr.ht/~diamondburned/arikawa/httputil" +) + +type Reaction struct { + Count int `json:"count"` + Me bool `json:"me"` // for current user + Emoji Emoji `json:"emoji"` +} + +// React adds a reaction to the message. This requires READ_MESSAGE_HISTORY (and +// additionally ADD_REACTIONS) to react. +func (c *Client) React(chID, msgID discord.Snowflake, + emoji EmojiAPI) error { + + var msgURL = EndpointChannels + chID.String() + + "/messages/" + msgID.String() + + "/reactions/" + emoji + "/@me" + return c.FastRequest("PUT", msgURL) +} + +func (c *Client) Reactions(chID, msgID discord.Snowflake, + limit uint, emoji EmojiAPI) ([]User, error) { + + return c.ReactionRange(chID, msgID, 0, 0, limit, emoji) +} + +// ReactionRange get users before and after IDs. Before, after, and limit are +// optional. +func (c *Client) ReactionRange(chID, msgID, before, after discord.Snowflake, + limit uint, emoji EmojiAPI) ([]User, error) { + + if limit == 0 { + limit = 25 + } + + if limit > 100 { + limit = 100 + } + + var query struct { + Before discord.Snowflake `json:"before,omitempty"` + After discord.Snowflake `json:"after,omitempty"` + + Limit uint `json:"limit"` + } + + var users []User + var msgURL = EndpointChannels + chID.String() + + "/messages/" + msgID.String() + + "/reactions/" + emoji + + return users, c.RequestJSON(&users, "GET", msgURL, + httputil.WithJSONBody(c, query)) +} + +// DeleteReaction requires MANAGE_MESSAGES if not @me. +func (c *Client) DeleteReaction( + chID, msgID, userID discord.Snowflake, emoji EmojiAPI) error { + + var user = "@me" + if userID > 0 { + user = userID.String() + } + + var msgURL = EndpointChannels + chID.String() + + "/messages/" + msgID.String() + + "/reactions/" + emoji + "/" + user + + return c.FastRequest("DELETE", msgURL) +} + +func (c *Client) DeleteOwnReaction( + chID, msgID discord.Snowflake, emoji EmojiAPI) error { + + return c.DeleteReaction(chID, msgID, 0, emoji) +} + +// DeleteAllReactions equires MANAGE_MESSAGE. +func (c *Client) DeleteAllReactions( + chID, msgID discord.Snowflake, emoji EmojiAPI) error { + + var msgURL = EndpointChannels + chID.String() + + "/messages/" + msgID.String() + + "/reactions/" + emoji + + return c.FastRequest("DELETE", msgURL) +} diff --git a/api/message_send.go b/api/message_send.go new file mode 100644 index 0000000..c7291ab --- /dev/null +++ b/api/message_send.go @@ -0,0 +1,103 @@ +package api + +import ( + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strconv" + "strings" + + "git.sr.ht/~diamondburned/arikawa/json" + "github.com/pkg/errors" +) + +type SendMessageData struct { + Content string `json:"content"` + Nonce string `json:"nonce"` + TTS bool `json:"tts"` + Embed *Embed `json:"embed"` + + Files []SendMessageFile `json:"-"` +} + +type SendMessageFile struct { + Name string + ContentType string // auto-detect if empty + Reader io.Reader +} + +var quoteEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`) + +func (data *SendMessageData) WriteMultipart(c json.Driver, w io.Writer) error { + body := multipart.NewWriter(w) + + // Encode the JSON body first + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", `form-data; name="payload_json"`) + h.Set("Content-Type", "application/json") + + w, err := body.CreatePart(h) + if err != nil { + return errors.Wrap(err, "Failed to create bodypart for JSON") + } + + if err := c.EncodeStream(w, data); err != nil { + return errors.Wrap(err, "Failed to encode JSON") + } + + // Content-Type buffer + var buf []byte + + for i, file := range data.Files { + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", fmt.Sprintf( + `form-data; name="file%s"; 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)) + } + + if file.ContentType == "" { + if buf == nil { + buf = make([]byte, 512) + } + + n, err := file.Reader.Read(buf) + if err != nil { + return errors.Wrap(err, "Failed to read first 512 bytes for "+ + strconv.Itoa(i)) + } + + file.ContentType = http.DetectContentType(buf[:n]) + data.Files[i] = file + + h.Set("Content-Type", file.ContentType) + + // Prematurely write + if _, err := w.Write(buf[:n]); 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 { + return errors.Wrap(err, "Failed to write file for "+ + strconv.Itoa(i)) + } + } + + if err := body.Close(); err != nil { + return errors.Wrap(err, "Failed to close body writer") + } + + return nil +} diff --git a/api/user.go b/api/user.go new file mode 100644 index 0000000..7271ba4 --- /dev/null +++ b/api/user.go @@ -0,0 +1,49 @@ +package api + +import "git.sr.ht/~diamondburned/arikawa/discord" + +type User struct { + UserID discord.Snowflake `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator"` + Avatar discord.Hash `json:"avatar"` + + // These fields may be omitted + + Bot bool `json:"bot,omitempty"` + MFA bool `json:"mfa_enabled,omitempty"` + + DiscordSystem bool `json:"system,omitempty"` + EmailVerified bool `json:"verified,omitempty"` + + Locale string `json:"locale,omitempty"` + Email string `json:"email,omitempty"` + + Flags UserFlags `json:"flags,omitempty"` + Nitro Nitro `json:"premium_type,omitempty"` +} + +type UserFlags uint16 + +const ( + NoFlag UserFlags = 0 + + DiscordEmployee UserFlags = 1 << iota + DiscordPartner + HypeSquadEvents + BugHunter + HouseBravery + HouseBrilliance + HouseBalance + EarlySupporter + TeamUser + System +) + +type Nitro uint8 + +const ( + NoNitro Nitro = iota + NitroClassic + NitroFull +) diff --git a/discord/color.go b/discord/color.go new file mode 100644 index 0000000..4eb30a4 --- /dev/null +++ b/discord/color.go @@ -0,0 +1,5 @@ +package discord + +type Color uint + +const DefaultColor Color = 0x303030 diff --git a/discord/image.go b/discord/image.go new file mode 100644 index 0000000..82aa539 --- /dev/null +++ b/discord/image.go @@ -0,0 +1,22 @@ +package discord + +type Image string + +const ( + ImageBaseURL = "https://cdn.discordapp.com/" +) + +type ImageType uint8 + +const ( + CustomEmoji ImageType = iota + GuildIcon + GuildSplash + GuildBanner + DefaultUserAvatar + UserAvatar + ApplicationIcon + ApplicationAsset + AchievementIcon + TeamIcon +) diff --git a/discord/seconds.go b/discord/seconds.go new file mode 100644 index 0000000..c6a5b62 --- /dev/null +++ b/discord/seconds.go @@ -0,0 +1,17 @@ +package discord + +import "time" + +type Seconds uint + +func DurationToSeconds(dura time.Duration) Seconds { + return Seconds(dura.Seconds()) +} + +func (s Seconds) String() string { + return s.Duration().String() +} + +func (s Seconds) Duration() time.Duration { + return time.Duration(s) * time.Second +} diff --git a/discord/snowflake.go b/discord/snowflake.go new file mode 100644 index 0000000..8e8c933 --- /dev/null +++ b/discord/snowflake.go @@ -0,0 +1,38 @@ +package discord + +import ( + "strconv" + "time" +) + +const DiscordEpoch = 1420070400000 * int64(time.Millisecond) + +type Snowflake uint64 + +func NewSnowflake(t time.Time) Snowflake { + return Snowflake(TimeToDiscordEpoch(t) << 22) +} + +func (s Snowflake) String() string { + return strconv.FormatUint(uint64(s), 10) +} + +func (s Snowflake) Time() time.Time { + return time.Unix(0, int64(s)>>22*1000000+DiscordEpoch) +} + +func (s Snowflake) Worker() uint8 { + return uint8(s & 0x3E0000) +} + +func (s Snowflake) PID() uint8 { + return uint8(s & 0x1F000 >> 12) +} + +func (s Snowflake) Increment() uint16 { + return uint16(s & 0xFFF) +} + +func TimeToDiscordEpoch(t time.Time) int64 { + return t.UnixNano()/int64(time.Millisecond) - DiscordEpoch +} diff --git a/discord/time.go b/discord/time.go new file mode 100644 index 0000000..a9fb60f --- /dev/null +++ b/discord/time.go @@ -0,0 +1,29 @@ +package discord + +import ( + "encoding/json" + "time" +) + +type Timestamp time.Time + +const TimestampFormat = time.RFC3339 // same as ISO8601 + +var ( + _ json.Unmarshaler = (*Timestamp)(nil) + _ json.Marshaler = (*Timestamp)(nil) +) + +func (t *Timestamp) UnmarshalJSON(v []byte) error { + r, err := time.Parse(TimestampFormat, string(v)) + if err != nil { + return err + } + + *t = Timestamp(r) + return nil +} + +func (t Timestamp) MarshalJSON() ([]byte, error) { + return []byte(`"` + time.Time(t).Format(TimestampFormat) + `"`), nil +} diff --git a/discord/url.go b/discord/url.go new file mode 100644 index 0000000..50bab02 --- /dev/null +++ b/discord/url.go @@ -0,0 +1,4 @@ +package discord + +type URL = string +type Hash = string diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2aa6b34 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.sr.ht/~diamondburned/arikawa + +go 1.13 + +require github.com/pkg/errors v0.8.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f29ab35 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/httputil/client.go b/httputil/client.go new file mode 100644 index 0000000..d8e25b6 --- /dev/null +++ b/httputil/client.go @@ -0,0 +1,133 @@ +package httputil + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "time" + + "git.sr.ht/~diamondburned/arikawa/json" +) + +type Client struct { + http.Client + json.Driver +} + +func NewClient() Client { + return Client{ + Client: http.Client{ + Timeout: 10 * time.Second, + }, + Driver: json.Default{}, + } +} + +func (c *Client) MeanwhileBody(bodyWriter func(io.Writer) error, + method, url string, opts ...RequestOption) (*http.Response, error) { + + // We want to cancel the request if our bodyWriter fails + ctx, cancel := context.WithCancel(context.Background()) + r, w := io.Pipe() + + var bgErr error + + go func() { + if err := bodyWriter(w); err != nil { + bgErr = err + cancel() + } + }() + + resp, err := c.RequestCtx(ctx, method, url, + append([]RequestOption{WithBody(r)}, opts...)...) + + if err != nil && bgErr != nil { + if resp.Body != nil { + resp.Body.Close() + } + + return nil, bgErr + } + + return resp, err +} + +func (c *Client) FastRequest( + method, url string, opts ...RequestOption) error { + r, err := c.Request(method, url, opts...) + if err != nil { + return err + } + + return r.Body.Close() +} + +func (c *Client) RequestCtx(ctx context.Context, + method, url string, opts ...RequestOption) (*http.Response, error) { + + req, err := http.NewRequestWithContext(ctx, method, url, nil) + if err != nil { + return nil, RequestError{err} + } + + for _, opt := range opts { + if err := opt(req); err != nil { + return nil, err + } + } + + r, err := c.Client.Do(req) + if err != nil { + return nil, RequestError{err} + } + + if r.StatusCode < 200 || r.StatusCode > 299 { + httpErr := &HTTPError{ + Status: r.StatusCode, + } + + b, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, httpErr + } + + httpErr.Body = b + + c.Unmarshal(b, &httpErr) + return nil, httpErr + } + + return r, nil +} + +func (c *Client) RequestCtxJSON(ctx context.Context, + to interface{}, method, url string, opts ...RequestOption) error { + + r, err := c.RequestCtx(ctx, method, url, + append([]RequestOption{JSONRequest}, opts...)...) + if err != nil { + return err + } + + defer r.Body.Close() + + if err := c.DecodeStream(r.Body, to); err != nil { + return JSONError{err} + } + + return nil +} + +func (c *Client) Request( + method, url string, opts ...RequestOption) (*http.Response, error) { + + return c.RequestCtx(context.Background(), method, url, opts...) +} + +func (c *Client) RequestJSON( + to interface{}, method, url string, opts ...RequestOption) error { + + return c.RequestCtxJSON(context.Background(), to, method, url, opts...) +} diff --git a/httputil/errors.go b/httputil/errors.go new file mode 100644 index 0000000..0cb4967 --- /dev/null +++ b/httputil/errors.go @@ -0,0 +1,42 @@ +package httputil + +import ( + "fmt" + "strconv" +) + +type JSONError struct { + error +} + +type RequestError struct { + error +} + +type HTTPError struct { + Status int `json:"-"` + Body []byte `json:"-"` + + Code ErrorCode `json:"code"` + Message string `json:"message,omitempty"` +} + +func (err HTTPError) Error() string { + switch { + case err.Message != "": + return "Discord error: " + err.Message + + case err.Code > 0: + return fmt.Sprintf("Discord returned status %d error code %d", + err.Status, err.Code) + + case len(err.Body) > 0: + return fmt.Sprintf("Discord returned status %d body %s", + err.Status, string(err.Body)) + + default: + return "Discord returned status " + strconv.Itoa(err.Status) + } +} + +type ErrorCode uint diff --git a/httputil/http.go b/httputil/http.go new file mode 100644 index 0000000..28528be --- /dev/null +++ b/httputil/http.go @@ -0,0 +1,36 @@ +package httputil + +import "net/http" + +type TransportWrapper struct { + http.RoundTripper + + Pre func(*http.Request) error + Post func(*http.Response) error +} + +func NewTransportWrapper() *TransportWrapper { + return &TransportWrapper{ + RoundTripper: http.DefaultTransport, + + Pre: func(*http.Request) error { return nil }, + Post: func(*http.Response) error { return nil }, + } +} + +func (c *TransportWrapper) RoundTrip(req *http.Request) (*http.Response, error) { + if err := c.Pre(req); err != nil { + return nil, err + } + + r, err := c.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + + if err := c.Post(r); err != nil { + return nil, err + } + + return r, nil +} diff --git a/httputil/options.go b/httputil/options.go new file mode 100644 index 0000000..b5c8487 --- /dev/null +++ b/httputil/options.go @@ -0,0 +1,50 @@ +package httputil + +import ( + "bytes" + "io" + "io/ioutil" + "net/http" + + "git.sr.ht/~diamondburned/arikawa/json" +) + +type RequestOption func(*http.Request) error + +func JSONRequest(r *http.Request) error { + r.Header.Set("Content-Type", "application/json") + return nil +} + +func WithBody(body io.ReadCloser) func(*http.Request) error { + return func(r *http.Request) error { + r.Body = body + return nil + } +} + +func WithJSONBody( + json json.Driver, v interface{}) func(r *http.Request) error { + + if v == nil { + return func(*http.Request) error { + return nil + } + } + + var buf bytes.Buffer + var err error + + go func() { + err = json.EncodeStream(&buf, v) + }() + + return func(r *http.Request) error { + if err != nil { + return err + } + + r.Body = ioutil.NopCloser(&buf) + return nil + } +} diff --git a/json/json.go b/json/json.go new file mode 100644 index 0000000..89d4c13 --- /dev/null +++ b/json/json.go @@ -0,0 +1,65 @@ +package json + +import ( + "encoding/json" + "io" +) + +type ( + OptionBool = *bool + OptionString = *string + OptionUint = *uint + OptionInt = *int +) + +var ( + True = getBool(true) + False = getBool(false) + + ZeroUint = Uint(0) + ZeroInt = Int(0) + + EmptyString = String("") +) + +func Uint(u uint) OptionUint { + return &u +} + +func Int(i int) OptionInt { + return &i +} + +func String(s string) OptionString { + return &s +} + +func getBool(Bool bool) OptionBool { + return &Bool +} + +type Driver interface { + Marshal(v interface{}) ([]byte, error) + Unmarshal(data []byte, v interface{}) error + + DecodeStream(r io.Reader, v interface{}) error + EncodeStream(w io.Writer, v interface{}) error +} + +type Default struct{} + +func (d Default) Marshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (d Default) Unmarshal(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +func (d Default) DecodeStream(r io.Reader, v interface{}) error { + return json.NewDecoder(r).Decode(v) +} + +func (d Default) EncodeStream(w io.Writer, v interface{}) error { + return json.NewEncoder(w).Encode(v) +}