mirror of
https://github.com/diamondburned/arikawa.git
synced 2024-11-30 10:43:30 +00:00
Moved Discord structs to package discord
This commit is contained in:
parent
09d9651507
commit
e41a2c7c42
|
@ -8,80 +8,13 @@ import (
|
|||
|
||||
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"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Position json.OptionInt `json:"position,omitempty"`
|
||||
Permissions []discord.Overwrite `json:"permission_overwrites,omitempty"`
|
||||
|
||||
// Text only
|
||||
Topic json.OptionString `json:"topic,omitempty"`
|
||||
|
@ -100,8 +33,8 @@ type ChannelModifier struct {
|
|||
ParentID discord.Snowflake `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Channel(channelID discord.Snowflake) (*Channel, error) {
|
||||
var channel *Channel
|
||||
func (c *Client) Channel(channelID discord.Snowflake) (*discord.Channel, error) {
|
||||
var channel *discord.Channel
|
||||
|
||||
return channel,
|
||||
c.RequestJSON(&channel, "POST", EndpointChannels+channelID.String())
|
||||
|
@ -119,7 +52,7 @@ func (c *Client) DeleteChannel(channelID discord.Snowflake) error {
|
|||
}
|
||||
|
||||
func (c *Client) EditChannelPermission(channelID discord.Snowflake,
|
||||
overwrite Overwrite) error {
|
||||
overwrite discord.Overwrite) error {
|
||||
|
||||
url := EndpointChannels + channelID.String() + "/permissions/" +
|
||||
overwrite.ID.String()
|
||||
|
@ -143,9 +76,9 @@ func (c *Client) Typing(channelID discord.Snowflake) error {
|
|||
}
|
||||
|
||||
func (c *Client) PinnedMessages(
|
||||
channelID discord.Snowflake) ([]Message, error) {
|
||||
channelID discord.Snowflake) ([]discord.Message, error) {
|
||||
|
||||
var pinned []Message
|
||||
var pinned []discord.Message
|
||||
return pinned, c.RequestJSON(&pinned, "GET",
|
||||
EndpointChannels+channelID.String()+"/pins")
|
||||
}
|
||||
|
|
48
api/emoji.go
48
api/emoji.go
|
@ -1,25 +1,9 @@
|
|||
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
|
||||
|
||||
|
@ -31,28 +15,20 @@ func FormatEmojiAPI(id discord.Snowflake, name string) string {
|
|||
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
|
||||
}
|
||||
func (c *Client) Emojis(
|
||||
guildID discord.Snowflake) ([]discord.Emoji, error) {
|
||||
|
||||
return e.ID.String() + ":" + e.Name
|
||||
var emjs []discord.Emoji
|
||||
return emjs, c.RequestJSON(&emjs, "GET",
|
||||
EndpointGuilds+guildID.String()+"/emojis")
|
||||
}
|
||||
|
||||
// String formats the string like how the client does.
|
||||
func (e Emoji) String() string {
|
||||
if e.ID == 0 {
|
||||
return e.Name
|
||||
}
|
||||
func (c *Client) Emoji(
|
||||
guildID, emojiID discord.Snowflake) (*discord.Emoji, error) {
|
||||
|
||||
var parts = []string{
|
||||
"", e.Name, e.ID.String(),
|
||||
}
|
||||
|
||||
if e.Animated {
|
||||
parts[0] = "a"
|
||||
}
|
||||
|
||||
return "<" + strings.Join(parts, ":") + ">"
|
||||
var emj *discord.Emoji
|
||||
return emj, c.RequestJSON(&emj, "GET",
|
||||
EndpointGuilds+guildID.String()+"/emojis/"+emojiID.String())
|
||||
}
|
||||
|
||||
// func (c *Client) CreateEmoji()
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
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)
|
||||
}
|
|
@ -1,7 +1,3 @@
|
|||
package api
|
||||
|
||||
const EndpointGuilds = Endpoint + "guilds/"
|
||||
|
||||
type Guild struct{}
|
||||
|
||||
type GuildMember struct{}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
package api
|
||||
|
||||
import "git.sr.ht/~diamondburned/arikawa/discord"
|
||||
import (
|
||||
"git.sr.ht/~diamondburned/arikawa/discord"
|
||||
)
|
||||
|
||||
const EndpointInvites = Endpoint + "invites/"
|
||||
|
||||
// Still unsure what this is
|
||||
type MetaInvite struct {
|
||||
Inviter User `json:"user"`
|
||||
Uses uint `json:"uses"`
|
||||
MaxUses uint `json:"max_uses"`
|
||||
Inviter discord.User `json:"user"`
|
||||
Uses uint `json:"uses"`
|
||||
MaxUses uint `json:"max_uses"`
|
||||
|
||||
MaxAge discord.Seconds `json:"max_age"`
|
||||
|
||||
|
@ -15,28 +18,7 @@ type MetaInvite struct {
|
|||
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) {
|
||||
func (c *Client) Invite(code string) (*discord.Invite, error) {
|
||||
var params struct {
|
||||
WithCounts bool `json:"with_counts,omitempty"`
|
||||
}
|
||||
|
@ -44,13 +26,13 @@ func (c *Client) Invite(code string) (*Invite, error) {
|
|||
// Nothing says I can't!
|
||||
params.WithCounts = true
|
||||
|
||||
var inv *Invite
|
||||
var inv *discord.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
|
||||
func (c *Client) Invites(channelID discord.Snowflake) ([]discord.Invite, error) {
|
||||
var invs []discord.Invite
|
||||
return invs, c.RequestJSON(&invs, "GET",
|
||||
EndpointChannels+channelID.String()+"/invites")
|
||||
}
|
||||
|
@ -63,7 +45,7 @@ func (c *Client) Invites(channelID discord.Snowflake) ([]Invite, error) {
|
|||
// 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) {
|
||||
maxAge discord.Seconds, maxUses uint, temp, unique bool) (*discord.Invite, error) {
|
||||
|
||||
var params struct {
|
||||
MaxAge uint `json:"max_age"`
|
||||
|
@ -77,14 +59,14 @@ func (c *Client) CreateInvite(channelID discord.Snowflake,
|
|||
params.Temporary = temp
|
||||
params.Unique = unique
|
||||
|
||||
var inv *Invite
|
||||
var inv *discord.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
|
||||
func (c *Client) DeleteInvite(code string) (*discord.Invite, error) {
|
||||
var inv *discord.Invite
|
||||
return inv, c.RequestJSON(&inv, "DELETE", EndpointInvites+code)
|
||||
}
|
||||
|
|
190
api/message.go
190
api/message.go
|
@ -8,177 +8,30 @@ import (
|
|||
"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) {
|
||||
func (c *Client) Messages(channelID discord.Snowflake,
|
||||
limit uint) ([]discord.Message, error) {
|
||||
|
||||
return c.messages(channelID, limit, nil)
|
||||
}
|
||||
|
||||
func (c *Client) MessagesAround(
|
||||
channelID, around discord.Snowflake, limit uint) ([]Message, error) {
|
||||
func (c *Client) MessagesAround(channelID, around discord.Snowflake,
|
||||
limit uint) ([]discord.Message, error) {
|
||||
|
||||
return c.messages(channelID, limit, map[string]interface{}{
|
||||
"around": around,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) MessagesBefore(
|
||||
channelID, before discord.Snowflake, limit uint) ([]Message, error) {
|
||||
func (c *Client) MessagesBefore(channelID, before discord.Snowflake,
|
||||
limit uint) ([]discord.Message, error) {
|
||||
|
||||
return c.messages(channelID, limit, map[string]interface{}{
|
||||
"before": before,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) MessagesAfter(
|
||||
channelID, after discord.Snowflake, limit uint) ([]Message, error) {
|
||||
func (c *Client) MessagesAfter(channelID, after discord.Snowflake,
|
||||
limit uint) ([]discord.Message, error) {
|
||||
|
||||
return c.messages(channelID, limit, map[string]interface{}{
|
||||
"after": after,
|
||||
|
@ -186,7 +39,7 @@ func (c *Client) MessagesAfter(
|
|||
}
|
||||
|
||||
func (c *Client) messages(channelID discord.Snowflake,
|
||||
limit uint, body map[string]interface{}) ([]Message, error) {
|
||||
limit uint, body map[string]interface{}) ([]discord.Message, error) {
|
||||
|
||||
if body == nil {
|
||||
body = map[string]interface{}{}
|
||||
|
@ -201,21 +54,21 @@ func (c *Client) messages(channelID discord.Snowflake,
|
|||
|
||||
body["limit"] = limit
|
||||
|
||||
var msgs []Message
|
||||
var msgs []discord.Message
|
||||
return msgs, c.RequestJSON(&msgs, "GET",
|
||||
EndpointChannels+channelID.String(), httputil.WithJSONBody(c, body))
|
||||
}
|
||||
|
||||
func (c *Client) Message(
|
||||
channelID, messageID discord.Snowflake) (*Message, error) {
|
||||
channelID, messageID discord.Snowflake) (*discord.Message, error) {
|
||||
|
||||
var msg *Message
|
||||
var msg *discord.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) {
|
||||
content string, embed *discord.Embed) (*discord.Message, error) {
|
||||
|
||||
return c.SendMessageComplex(channelID, SendMessageData{
|
||||
Content: content,
|
||||
|
@ -224,7 +77,7 @@ func (c *Client) SendMessage(channelID discord.Snowflake,
|
|||
}
|
||||
|
||||
func (c *Client) SendMessageComplex(channelID discord.Snowflake,
|
||||
data SendMessageData) (*Message, error) {
|
||||
data SendMessageData) (*discord.Message, error) {
|
||||
|
||||
if data.Embed != nil {
|
||||
if err := data.Embed.Validate(); err != nil {
|
||||
|
@ -233,7 +86,7 @@ func (c *Client) SendMessageComplex(channelID discord.Snowflake,
|
|||
}
|
||||
|
||||
var URL = EndpointChannels + channelID.String()
|
||||
var msg *Message
|
||||
var msg *discord.Message
|
||||
|
||||
if len(data.Files) == 0 {
|
||||
// No files, no need for streaming
|
||||
|
@ -256,21 +109,22 @@ func (c *Client) SendMessageComplex(channelID discord.Snowflake,
|
|||
}
|
||||
|
||||
func (c *Client) EditMessage(channelID, messageID discord.Snowflake,
|
||||
content string, embed *Embed, suppressEmbeds bool) (*Message, error) {
|
||||
content string, embed *discord.Embed, suppressEmbeds bool,
|
||||
) (*discord.Message, error) {
|
||||
|
||||
var param struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Embed *Embed `json:"embed,omitempty"`
|
||||
Flags MessageFlags `json:"flags,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Embed *discord.Embed `json:"embed,omitempty"`
|
||||
Flags discord.MessageFlags `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
param.Content = content
|
||||
param.Embed = embed
|
||||
if suppressEmbeds {
|
||||
param.Flags = SuppressEmbeds
|
||||
param.Flags = discord.SuppressEmbeds
|
||||
}
|
||||
|
||||
var msg *Message
|
||||
var msg *discord.Message
|
||||
return msg, c.RequestJSON(
|
||||
&msg, "PATCH",
|
||||
EndpointChannels+channelID.String()+"/messages/"+messageID.String(),
|
||||
|
|
|
@ -5,12 +5,6 @@ import (
|
|||
"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,
|
||||
|
@ -23,15 +17,16 @@ func (c *Client) React(chID, msgID discord.Snowflake,
|
|||
}
|
||||
|
||||
func (c *Client) Reactions(chID, msgID discord.Snowflake,
|
||||
limit uint, emoji EmojiAPI) ([]User, error) {
|
||||
limit uint, emoji EmojiAPI) ([]discord.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) {
|
||||
func (c *Client) ReactionRange(
|
||||
chID, msgID, before, after discord.Snowflake,
|
||||
limit uint, emoji EmojiAPI) ([]discord.User, error) {
|
||||
|
||||
if limit == 0 {
|
||||
limit = 25
|
||||
|
@ -48,7 +43,7 @@ func (c *Client) ReactionRange(chID, msgID, before, after discord.Snowflake,
|
|||
Limit uint `json:"limit"`
|
||||
}
|
||||
|
||||
var users []User
|
||||
var users []discord.User
|
||||
var msgURL = EndpointChannels + chID.String() +
|
||||
"/messages/" + msgID.String() +
|
||||
"/reactions/" + emoji
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~diamondburned/arikawa/discord"
|
||||
"git.sr.ht/~diamondburned/arikawa/json"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
@ -17,7 +18,8 @@ type SendMessageData struct {
|
|||
Content string `json:"content"`
|
||||
Nonce string `json:"nonce"`
|
||||
TTS bool `json:"tts"`
|
||||
Embed *Embed `json:"embed"`
|
||||
|
||||
Embed *discord.Embed `json:"embed"`
|
||||
|
||||
Files []SendMessageFile `json:"-"`
|
||||
}
|
||||
|
@ -53,7 +55,7 @@ func (data *SendMessageData) WriteMultipart(c json.Driver, w io.Writer) error {
|
|||
for i, file := range data.Files {
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", fmt.Sprintf(
|
||||
`form-data; name="file%s"; filename="%s"`,
|
||||
`form-data; name="file%d"; filename="%s"`,
|
||||
i, quoteEscaper.Replace(file.Name),
|
||||
))
|
||||
|
||||
|
|
68
discord/channel.go
Normal file
68
discord/channel.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package discord
|
||||
|
||||
type Channel struct {
|
||||
ID Snowflake `json:"id,string"`
|
||||
Type ChannelType `json:"type"`
|
||||
|
||||
// Fields below may not appear
|
||||
|
||||
GuildID 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 Hash `json:"icon,omitempty"`
|
||||
|
||||
// Direct Messaging fields
|
||||
DMOwnerID Snowflake `json:"owner_id,omitempty"`
|
||||
DMRecipients []User `json:"recipients,omitempty"`
|
||||
|
||||
// AppID of the group DM creator if it's bot-created
|
||||
AppID Snowflake `json:"application_id,omitempty"`
|
||||
|
||||
// ID of the category the channel is in, if any.
|
||||
CategoryID Snowflake `json:"parent_id,omitempty"`
|
||||
|
||||
LastPinTime 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 Snowflake `json:"last_message_id,omitempty"`
|
||||
|
||||
// Slow mode duration. Bots and people with "manage_messages" or
|
||||
// "manage_channel" permissions are unaffected.
|
||||
UserRateLimit 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 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"
|
||||
)
|
|
@ -1,5 +0,0 @@
|
|||
package discord
|
||||
|
||||
type Color uint
|
||||
|
||||
const DefaultColor Color = 0x303030
|
43
discord/emoji.go
Normal file
43
discord/emoji.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
package discord
|
||||
|
||||
import "strings"
|
||||
|
||||
type Emoji struct {
|
||||
ID Snowflake `json:"id"` // 0 for Unicode emojis
|
||||
Name string `json:"name"`
|
||||
|
||||
// These fields are optional
|
||||
|
||||
RoleIDs []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"`
|
||||
}
|
||||
|
||||
// APIString returns a string usable for sending over to the API.
|
||||
func (e Emoji) APIString() string {
|
||||
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, ":") + ">"
|
||||
}
|
5
discord/guild.go
Normal file
5
discord/guild.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package discord
|
||||
|
||||
type Guild struct{}
|
||||
|
||||
type GuildMember struct{}
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
)
|
22
discord/invite.go
Normal file
22
discord/invite.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package discord
|
||||
|
||||
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
|
||||
)
|
154
discord/message.go
Normal file
154
discord/message.go
Normal file
|
@ -0,0 +1,154 @@
|
|||
package discord
|
||||
|
||||
type Message struct {
|
||||
ID Snowflake `json:"id"`
|
||||
Type MessageType `json:"type"`
|
||||
ChannelID Snowflake `json:"channel_id"`
|
||||
GuildID 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 Timestamp `json:"timestamp,omitempty"`
|
||||
EditedTimestamp *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 []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 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 Snowflake `json:"id"`
|
||||
GuildID 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 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 Snowflake `json:"channel_id"`
|
||||
|
||||
// Field might not be provided
|
||||
MessageID Snowflake `json:"message_id,omitempty"`
|
||||
GuildID Snowflake `json:"guild_id,omitempty"`
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
type Attachment struct {
|
||||
ID Snowflake `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Size uint64 `json:"size"`
|
||||
|
||||
URL URL `json:"url"`
|
||||
Proxy URL `json:"proxy_url"`
|
||||
|
||||
// Only if Image
|
||||
Height uint `json:"height,omitempty"`
|
||||
Width uint `json:"width,omitempty"`
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
type Reaction struct {
|
||||
Count int `json:"count"`
|
||||
Me bool `json:"me"` // for current user
|
||||
Emoji Emoji `json:"emoji"`
|
||||
}
|
|
@ -1,20 +1,20 @@
|
|||
package api
|
||||
package discord
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
import "fmt"
|
||||
|
||||
"git.sr.ht/~diamondburned/arikawa/discord"
|
||||
)
|
||||
type Color uint
|
||||
|
||||
const DefaultColor Color = 0x303030
|
||||
|
||||
type Embed struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Type EmbedType `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
URL discord.URL `json:"url,omitempty"`
|
||||
URL URL `json:"url,omitempty"`
|
||||
|
||||
Timestamp discord.Timestamp `json:"timestamp,omitempty"`
|
||||
Color discord.Color `json:"color,omitempty"`
|
||||
Timestamp Timestamp `json:"timestamp,omitempty"`
|
||||
Color Color `json:"color,omitempty"`
|
||||
|
||||
Footer *EmbedFooter `json:"footer,omitempty"`
|
||||
Image *EmbedImage `json:"image,omitempty"`
|
||||
|
@ -28,17 +28,34 @@ type Embed struct {
|
|||
func NewEmbed() *Embed {
|
||||
return &Embed{
|
||||
Type: NormalEmbed,
|
||||
Color: discord.DefaultColor,
|
||||
Color: DefaultColor,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (e *Embed) Validate() error {
|
||||
if e.Type == "" {
|
||||
e.Type = NormalEmbed
|
||||
}
|
||||
|
||||
if e.Color == 0 {
|
||||
e.Color = discord.DefaultColor
|
||||
e.Color = DefaultColor
|
||||
}
|
||||
|
||||
if len(e.Title) > 256 {
|
||||
|
@ -104,39 +121,39 @@ const (
|
|||
)
|
||||
|
||||
type EmbedFooter struct {
|
||||
Text string `json:"text"`
|
||||
Icon discord.URL `json:"icon_url,omitempty"`
|
||||
ProxyIcon discord.URL `json:"proxy_icon_url,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Icon URL `json:"icon_url,omitempty"`
|
||||
ProxyIcon URL `json:"proxy_icon_url,omitempty"`
|
||||
}
|
||||
|
||||
type EmbedImage struct {
|
||||
URL discord.URL `json:"url"`
|
||||
Proxy discord.URL `json:"proxy_url"`
|
||||
URL URL `json:"url"`
|
||||
Proxy 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"`
|
||||
URL URL `json:"url,omitempty"`
|
||||
Proxy 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"`
|
||||
URL URL `json:"url"`
|
||||
Height uint `json:"height"`
|
||||
Width uint `json:"width"`
|
||||
}
|
||||
|
||||
type EmbedProvider struct {
|
||||
Name string `json:"name"`
|
||||
URL discord.URL `json:"url"`
|
||||
Name string `json:"name"`
|
||||
URL 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"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL URL `json:"url,omitempty"`
|
||||
Icon URL `json:"icon_url,omitempty"`
|
||||
ProxyIcon URL `json:"proxy_icon_url,omitempty"`
|
||||
}
|
||||
|
||||
type EmbedField struct {
|
|
@ -1,17 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -27,3 +27,17 @@ func (t *Timestamp) UnmarshalJSON(v []byte) error {
|
|||
func (t Timestamp) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + time.Time(t).Format(TimestampFormat) + `"`), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
package api
|
||||
|
||||
import "git.sr.ht/~diamondburned/arikawa/discord"
|
||||
package discord
|
||||
|
||||
type User struct {
|
||||
UserID discord.Snowflake `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Discriminator string `json:"discriminator"`
|
||||
Avatar discord.Hash `json:"avatar"`
|
||||
UserID Snowflake `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Discriminator string `json:"discriminator"`
|
||||
Avatar Hash `json:"avatar"`
|
||||
|
||||
// These fields may be omitted
|
||||
|
5
go.mod
5
go.mod
|
@ -2,4 +2,7 @@ module git.sr.ht/~diamondburned/arikawa
|
|||
|
||||
go 1.13
|
||||
|
||||
require github.com/pkg/errors v0.8.1
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.20.2
|
||||
github.com/pkg/errors v0.8.1
|
||||
)
|
||||
|
|
6
go.sum
6
go.sum
|
@ -1,2 +1,8 @@
|
|||
github.com/bwmarrin/discordgo v0.20.2 h1:nA7jiTtqUA9lT93WL2jPjUp8ZTEInRujBdx1C9gkr20=
|
||||
github.com/bwmarrin/discordgo v0.20.2/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q=
|
||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
|
|
|
@ -56,6 +56,7 @@ func (c *Client) MeanwhileBody(bodyWriter func(io.Writer) error,
|
|||
|
||||
func (c *Client) FastRequest(
|
||||
method, url string, opts ...RequestOption) error {
|
||||
|
||||
r, err := c.Request(method, url, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
Loading…
Reference in a new issue