Initial commit

This commit is contained in:
diamondburned 2020-01-01 21:39:52 -08:00
commit 09d9651507
26 changed files with 1759 additions and 0 deletions

202
LICENSE Normal file
View File

@ -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.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# arikawa
A Golang library for the Discord API.

49
api/api.go Normal file
View File

@ -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
}

190
api/channel.go Normal file
View File

@ -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())
}

58
api/emoji.go Normal file
View File

@ -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, ":") + ">"
}

22
api/errors.go Normal file
View File

@ -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)
}

7
api/guild.go Normal file
View File

@ -0,0 +1,7 @@
package api
const EndpointGuilds = Endpoint + "guilds/"
type Guild struct{}
type GuildMember struct{}

90
api/invite.go Normal file
View File

@ -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)
}

301
api/message.go Normal file
View File

@ -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))
}

146
api/message_embed.go Normal file
View File

@ -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"`
}

91
api/message_reaction.go Normal file
View File

@ -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)
}

103
api/message_send.go Normal file
View File

@ -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
}

49
api/user.go Normal file
View File

@ -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
)

5
discord/color.go Normal file
View File

@ -0,0 +1,5 @@
package discord
type Color uint
const DefaultColor Color = 0x303030

22
discord/image.go Normal file
View File

@ -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
)

17
discord/seconds.go Normal file
View File

@ -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
}

38
discord/snowflake.go Normal file
View File

@ -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
}

29
discord/time.go Normal file
View File

@ -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
}

4
discord/url.go Normal file
View File

@ -0,0 +1,4 @@
package discord
type URL = string
type Hash = string

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module git.sr.ht/~diamondburned/arikawa
go 1.13
require github.com/pkg/errors v0.8.1

2
go.sum Normal file
View File

@ -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=

133
httputil/client.go Normal file
View File

@ -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...)
}

42
httputil/errors.go Normal file
View File

@ -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

36
httputil/http.go Normal file
View File

@ -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
}

50
httputil/options.go Normal file
View File

@ -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
}
}

65
json/json.go Normal file
View File

@ -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)
}