Refactored to cchat v0.1.3

This commit is contained in:
diamondburned 2020-09-07 21:44:09 -07:00
parent 647c854d7b
commit 0f1cdafec6
43 changed files with 1834 additions and 1543 deletions

View File

@ -1,72 +0,0 @@
package discord
import (
"sort"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
type Category struct {
id discord.ChannelID
guildID discord.GuildID
session *Session
}
var (
_ cchat.Server = (*Category)(nil)
_ cchat.ServerList = (*Category)(nil)
)
func NewCategory(s *Session, ch discord.Channel) *Category {
return &Category{
id: ch.ID,
guildID: ch.GuildID,
session: s,
}
}
func (c *Category) ID() cchat.ID {
return c.id.String()
}
func (c *Category) Name() text.Rich {
t, err := c.session.Channel(c.id)
if err != nil {
// This shouldn't happen.
return text.Rich{Content: c.id.String()}
}
return text.Rich{
Content: t.Name,
}
}
func (c *Category) Servers(container cchat.ServersContainer) error {
t, err := c.session.Channels(c.guildID)
if err != nil {
return errors.Wrap(err, "Failed to get channels")
}
// Filter out channels with this category ID.
var chs = filterAccessible(c.session, filterCategory(t, c.id))
sort.Slice(chs, func(i, j int) bool {
return chs[i].Position < chs[j].Position
})
var chv = make([]cchat.Server, len(chs))
for i := range chs {
c, err := NewChannel(c.session, chs[i])
if err != nil {
return errors.Wrapf(err, "Failed to make channel %s: %v", chs[i].Name, err)
}
chv[i] = c
}
container.SetServers(chv)
return nil
}

View File

@ -1,509 +0,0 @@
package discord
import (
"context"
"sort"
"time"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/segments"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/ningen/states/read"
"github.com/pkg/errors"
)
func chGuildCheck(chType discord.ChannelType) bool {
switch chType {
case discord.GuildCategory, discord.GuildText:
return true
default:
return false
}
}
func filterAccessible(s *Session, chs []discord.Channel) []discord.Channel {
filtered := chs[:0]
for _, ch := range chs {
p, err := s.Permissions(ch.ID, s.userID)
// Treat error as non-fatal and add the channel anyway.
if err != nil || p.Has(discord.PermissionViewChannel) {
filtered = append(filtered, ch)
}
}
return filtered
}
func filterCategory(chs []discord.Channel, catID discord.ChannelID) []discord.Channel {
var filtered = chs[:0]
var catvalid = catID.IsValid()
for _, ch := range chs {
switch {
// If the given ID is not valid, then we look for channels with
// similarly invalid category IDs, because yes, Discord really sends
// inconsistent responses.
case !catvalid && !ch.CategoryID.IsValid():
fallthrough
// Basic comparison.
case ch.CategoryID == catID:
if chGuildCheck(ch.Type) {
filtered = append(filtered, ch)
}
}
}
return filtered
}
type Channel struct {
id discord.ChannelID
guildID discord.GuildID
session *Session
}
var (
_ cchat.Server = (*Channel)(nil)
_ cchat.ServerMessage = (*Channel)(nil)
_ cchat.ServerNickname = (*Channel)(nil)
_ cchat.ServerMessageEditor = (*Channel)(nil)
_ cchat.ServerMessageActioner = (*Channel)(nil)
_ cchat.ServerMessageBacklogger = (*Channel)(nil)
_ cchat.ServerMessageTypingIndicator = (*Channel)(nil)
_ cchat.ServerMessageUnreadIndicator = (*Channel)(nil)
)
func NewChannel(s *Session, ch discord.Channel) (cchat.Server, error) {
p, err := s.Permissions(ch.ID, s.userID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get permission")
}
var channel = NewROChannel(s, ch)
if p.Has(discord.PermissionSendMessages) {
return NewSendableChannel(channel), nil
}
return channel, nil
}
// NewROChannel creates a new read-only channel. This function is mainly used
// internally.
func NewROChannel(s *Session, ch discord.Channel) *Channel {
return &Channel{
id: ch.ID,
guildID: ch.GuildID,
session: s,
}
}
// self does not do IO.
func (ch *Channel) self() (*discord.Channel, error) {
return ch.session.Store.Channel(ch.id)
}
// messages does not do IO.
func (ch *Channel) messages() ([]discord.Message, error) {
return ch.session.Store.Messages(ch.id)
}
func (ch *Channel) guild() (*discord.Guild, error) {
if ch.guildID.IsValid() {
return ch.session.Store.Guild(ch.guildID)
}
return nil, errors.New("channel not in a guild")
}
func (ch *Channel) ID() cchat.ID {
return ch.id.String()
}
func (ch *Channel) Name() text.Rich {
c, err := ch.self()
if err != nil {
return text.Rich{Content: ch.id.String()}
}
if c.NSFW {
return text.Rich{Content: "#!" + c.Name}
} else {
return text.Rich{Content: "#" + c.Name}
}
}
func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) (func(), error) {
// We don't have a nickname if we're not in a guild.
if !ch.guildID.IsValid() {
return func() {}, nil
}
state := ch.session.WithContext(ctx)
// MemberColor should fill up the state cache.
c, err := state.MemberColor(ch.guildID, ch.session.userID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get self member color")
}
m, err := state.Member(ch.guildID, ch.session.userID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get self member")
}
var rich = text.Rich{Content: m.User.Username}
if m.Nick != "" {
rich.Content = m.Nick
}
if c > 0 {
rich.Segments = []text.Segment{
segments.NewColored(len(rich.Content), c.Uint32()),
}
}
labeler.SetLabel(rich)
// Copy the user ID to use.
var selfID = m.User.ID
return ch.session.AddHandler(func(g *gateway.GuildMemberUpdateEvent) {
if g.GuildID != ch.guildID || g.User.ID != selfID {
return
}
var rich = text.Rich{Content: m.User.Username}
if m.Nick != "" {
rich.Content = m.Nick
}
c, err := ch.session.MemberColor(g.GuildID, selfID)
if err == nil {
rich.Segments = []text.Segment{
segments.NewColored(len(rich.Content), c.Uint32()),
}
}
labeler.SetLabel(rich)
}), nil
}
func (ch *Channel) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) {
state := ch.session.WithContext(ctx)
m, err := state.Messages(ch.id)
if err != nil {
return nil, err
}
var addcancel = newCancels()
var constructor func(discord.Message) cchat.MessageCreate
if ch.guildID.IsValid() {
// Create the backlog without any member information.
g, err := state.Guild(ch.guildID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get guild")
}
constructor = func(m discord.Message) cchat.MessageCreate {
return NewBacklogMessage(m, ch.session, *g)
}
// Subscribe to typing events.
ch.session.MemberState.Subscribe(ch.guildID)
// Listen to new members before creating the backlog and requesting members.
addcancel(ch.session.AddHandler(func(c *gateway.GuildMembersChunkEvent) {
if c.GuildID != ch.guildID {
return
}
m, err := ch.messages()
if err != nil {
// TODO: log
return
}
g, err := ch.guild()
if err != nil {
return
}
// Loop over all messages and replace the author. The latest
// messages are in front.
for _, msg := range m {
for _, member := range c.Members {
if msg.Author.ID != member.User.ID {
continue
}
ct.UpdateMessage(NewMessageUpdateAuthor(msg, member, *g, ch.session))
}
}
}))
} else {
constructor = func(m discord.Message) cchat.MessageCreate {
return NewDirectMessage(m, ch.session)
}
}
// Only do all this if we even have any messages.
if len(m) > 0 {
// Sort messages chronologically using the ID so that the oldest messages
// (ones with the smallest snowflake) is in front.
sort.Slice(m, func(i, j int) bool { return m[i].ID < m[j].ID })
// Iterate from the earliest messages to the latest messages.
for _, m := range m {
ct.CreateMessage(constructor(m))
}
// Mark this channel as read.
ch.session.ReadState.MarkRead(ch.id, m[len(m)-1].ID)
}
// Bind the handler.
addcancel(
ch.session.AddHandler(func(m *gateway.MessageCreateEvent) {
if m.ChannelID == ch.id {
ct.CreateMessage(NewMessageCreate(m, ch.session))
ch.session.ReadState.MarkRead(ch.id, m.ID)
}
}),
ch.session.AddHandler(func(m *gateway.MessageUpdateEvent) {
// If the updated content is empty. TODO: add embed support.
if m.ChannelID == ch.id {
ct.UpdateMessage(NewMessageUpdateContent(m.Message, ch.session))
}
}),
ch.session.AddHandler(func(m *gateway.MessageDeleteEvent) {
if m.ChannelID == ch.id {
ct.DeleteMessage(NewHeaderDelete(m))
}
}),
)
return joinCancels(addcancel()), nil
}
func (ch *Channel) MessagesBefore(ctx context.Context, b cchat.ID, c cchat.MessagePrepender) error {
p, err := discord.ParseSnowflake(b)
if err != nil {
return errors.Wrap(err, "Failed to parse snowflake")
}
s := ch.session.WithContext(ctx)
m, err := s.MessagesBefore(ch.id, discord.MessageID(p), uint(ch.session.MaxMessages()))
if err != nil {
return errors.Wrap(err, "Failed to get messages")
}
// Create the backlog without any member information.
g, err := s.Guild(ch.guildID)
if err != nil {
return errors.Wrap(err, "Failed to get guild")
}
for _, m := range m {
// Discord sucks.
m.GuildID = ch.guildID
c.PrependMessage(NewBacklogMessage(m, ch.session, *g))
}
return nil
}
// MessageEditable returns true if the given message ID belongs to the current
// user.
func (ch *Channel) MessageEditable(id string) bool {
s, err := discord.ParseSnowflake(id)
if err != nil {
return false
}
m, err := ch.session.Store.Message(ch.id, discord.MessageID(s))
if err != nil {
return false
}
return m.Author.ID == ch.session.userID
}
// RawMessageContent returns the raw message content from Discord.
func (ch *Channel) RawMessageContent(id string) (string, error) {
s, err := discord.ParseSnowflake(id)
if err != nil {
return "", errors.Wrap(err, "Failed to parse ID")
}
m, err := ch.session.Store.Message(ch.id, discord.MessageID(s))
if err != nil {
return "", errors.Wrap(err, "Failed to get the message")
}
return m.Content, nil
}
// EditMessage edits the message to the given content string.
func (ch *Channel) EditMessage(id, content string) error {
s, err := discord.ParseSnowflake(id)
if err != nil {
return errors.Wrap(err, "Failed to parse ID")
}
_, err = ch.session.EditText(ch.id, discord.MessageID(s), content)
return err
}
const (
ActionDelete = "Delete"
)
var ErrUnknownAction = errors.New("unknown message action")
func (ch *Channel) DoMessageAction(action, id string) error {
s, err := discord.ParseSnowflake(id)
if err != nil {
return errors.Wrap(err, "Failed to parse ID")
}
switch action {
case ActionDelete:
return ch.session.DeleteMessage(ch.id, discord.MessageID(s))
default:
return ErrUnknownAction
}
}
func (ch *Channel) MessageActions(id string) []string {
s, err := discord.ParseSnowflake(id)
if err != nil {
return nil
}
m, err := ch.session.Store.Message(ch.id, discord.MessageID(s))
if err != nil {
return nil
}
// Get the current user.
u, err := ch.session.Store.Me()
if err != nil {
return nil
}
// Can we have delete? We can if this is our own message.
var canDelete = m.Author.ID == u.ID
// We also can if we have the Manage Messages permission, which would allow
// us to delete others' messages.
if !canDelete {
canDelete = ch.canManageMessages(u.ID)
}
if canDelete {
return []string{ActionDelete}
}
return []string{}
}
// canManageMessages returns whether or not the user is allowed to manage
// messages.
func (ch *Channel) canManageMessages(userID discord.UserID) bool {
// If we're not in a guild, then clearly we cannot.
if !ch.guildID.IsValid() {
return false
}
// We need the guild, member and channel to calculate the permission
// overrides.
g, err := ch.guild()
if err != nil {
return false
}
c, err := ch.self()
if err != nil {
return false
}
m, err := ch.session.Store.Member(ch.guildID, userID)
if err != nil {
return false
}
p := discord.CalcOverwrites(*g, *c, *m)
// The Manage Messages permission allows the user to delete others'
// messages, so we'll return true if that is the case.
return p.Has(discord.PermissionManageMessages)
}
func (ch *Channel) Typing() error {
return ch.session.Typing(ch.id)
}
// TypingTimeout returns 10 seconds.
func (ch *Channel) TypingTimeout() time.Duration {
return 10 * time.Second
}
func (ch *Channel) TypingSubscribe(ti cchat.TypingIndicator) (func(), error) {
return ch.session.AddHandler(func(t *gateway.TypingStartEvent) {
// Ignore channel mismatch or if the typing event is ours.
if t.ChannelID != ch.id || t.UserID == ch.session.userID {
return
}
if typer, err := NewTyper(ch.session, t); err == nil {
ti.AddTyper(typer)
}
}), nil
}
// muted returns if this channel is muted. This includes the channel's category
// and guild.
func (ch *Channel) muted() bool {
return (ch.guildID.IsValid() && ch.session.MutedState.Guild(ch.guildID, false)) ||
ch.session.MutedState.Channel(ch.id) ||
ch.session.MutedState.Category(ch.id)
}
func (ch *Channel) UnreadIndicate(indicator cchat.UnreadIndicator) (func(), error) {
if rs := ch.session.ReadState.FindLast(ch.id); rs != nil {
c, err := ch.self()
if err != nil {
return nil, errors.Wrap(err, "Failed to get self channel")
}
if c.LastMessageID > rs.LastMessageID && !ch.muted() {
indicator.SetUnread(true, rs.MentionCount > 0)
}
}
return ch.session.ReadState.OnUpdate(func(ev *read.UpdateEvent) {
if ch.id == ev.ChannelID && !ch.muted() {
indicator.SetUnread(ev.Unread, ev.MentionCount > 0)
}
}), nil
}
func newCancels() func(...func()) []func() {
var cancels []func()
return func(appended ...func()) []func() {
cancels = append(cancels, appended...)
return cancels
}
}
func joinCancels(cancellers []func()) func() {
return func() {
for _, c := range cancellers {
c()
}
}
}

View File

@ -1,345 +0,0 @@
package discord
import (
"context"
"fmt"
"strings"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/segments"
"github.com/diamondburned/cchat-discord/urlutils"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/ningen/states/member"
)
func seekPrevGroup(l *member.List, ix int) (item, group gateway.GuildMemberListOpItem) {
l.ViewItems(func(items []gateway.GuildMemberListOpItem) {
// Bound check.
if ix >= len(items) {
return
}
item = items[ix]
// Search backwards.
for i := ix; i >= 0; i-- {
if items[i].Group != nil {
group = items[i]
return
}
}
})
return
}
func (ch *Channel) ListMembers(ctx context.Context, c cchat.MemberListContainer) (func(), error) {
if !ch.guildID.IsValid() {
return func() {}, nil
}
cancel := ch.session.AddHandler(func(u *gateway.GuildMemberListUpdate) {
l, err := ch.session.MemberState.GetMemberList(ch.guildID, ch.id)
if err != nil {
return // wat
}
if l.GuildID() != u.GuildID || l.ID() != u.ID {
return
}
for _, ev := range u.Ops {
switch ev.Op {
case "SYNC":
ch.checkSync(c)
case "INSERT", "UPDATE":
item, group := seekPrevGroup(l, ev.Index)
if item.Member != nil && group.Group != nil {
c.SetMember(group.Group.ID, NewListMember(ch, item))
ch.flushMemberGroups(l, c)
}
case "DELETE":
_, group := seekPrevGroup(l, ev.Index-1)
if group.Group != nil && ev.Item.Member != nil {
c.RemoveMember(group.Group.ID, ev.Item.Member.User.ID.String())
ch.flushMemberGroups(l, c)
}
}
}
})
ch.checkSync(c)
return cancel, nil
}
func (ch *Channel) checkSync(c cchat.MemberListContainer) {
l, err := ch.session.MemberState.GetMemberList(ch.guildID, ch.id)
if err != nil {
ch.session.MemberState.RequestMemberList(ch.guildID, ch.id, 0)
return
}
ch.flushMemberGroups(l, c)
l.ViewItems(func(items []gateway.GuildMemberListOpItem) {
var group gateway.GuildMemberListGroup
for _, item := range items {
switch {
case item.Group != nil:
group = *item.Group
case item.Member != nil:
c.SetMember(group.ID, NewListMember(ch, item))
}
}
})
}
func (ch *Channel) flushMemberGroups(l *member.List, c cchat.MemberListContainer) {
l.ViewGroups(func(groups []gateway.GuildMemberListGroup) {
var sections = make([]cchat.MemberListSection, len(groups))
for i, group := range groups {
sections[i] = NewListSection(l.ID(), ch, group)
}
c.SetSections(sections)
})
}
type ListMember struct {
// Keep stateful references to do on-demand loading.
channel *Channel
// constant states
userID discord.UserID
origName string // use if cache is stale
}
var (
_ cchat.ListMember = (*ListMember)(nil)
_ cchat.Icon = (*ListMember)(nil)
)
// NewListMember creates a new list member. it.Member must not be nil.
func NewListMember(ch *Channel, it gateway.GuildMemberListOpItem) *ListMember {
return &ListMember{
channel: ch,
userID: it.Member.User.ID,
origName: it.Member.User.Username,
}
}
func (l *ListMember) ID() cchat.ID {
return l.userID.String()
}
func (l *ListMember) Name() text.Rich {
g, err := l.channel.guild()
if err != nil {
return text.Plain(l.origName)
}
m, err := l.channel.session.Member(l.channel.guildID, l.userID)
if err != nil {
return text.Plain(l.origName)
}
var name = m.User.Username
if m.Nick != "" {
name = m.Nick
}
mention := segments.MemberSegment(0, len(name), *g, *m)
mention.WithState(l.channel.session.State)
var txt = text.Rich{
Content: name,
Segments: []text.Segment{mention},
}
if c := discord.MemberColor(*g, *m); c != discord.DefaultMemberColor {
txt.Segments = append(txt.Segments, segments.NewColored(len(name), uint32(c)))
}
return txt
}
func (l *ListMember) Icon(ctx context.Context, c cchat.IconContainer) (func(), error) {
m, err := l.channel.session.Member(l.channel.guildID, l.userID)
if err != nil {
return nil, err
}
c.SetIcon(urlutils.AvatarURL(m.User.AvatarURL()))
return func() {}, nil
}
func (l *ListMember) Status() cchat.UserStatus {
p, err := l.channel.session.State.Presence(l.channel.guildID, l.userID)
if err != nil {
return cchat.UnknownStatus
}
switch p.Status {
case discord.OnlineStatus:
return cchat.OnlineStatus
case discord.DoNotDisturbStatus:
return cchat.BusyStatus
case discord.IdleStatus:
return cchat.AwayStatus
case discord.OfflineStatus, discord.InvisibleStatus:
return cchat.OfflineStatus
default:
return cchat.UnknownStatus
}
}
func (l *ListMember) Secondary() text.Rich {
p, err := l.channel.session.State.Presence(l.channel.guildID, l.userID)
if err != nil {
return text.Plain("")
}
if p.Game != nil {
return formatSmallActivity(*p.Game)
}
if len(p.Activities) > 0 {
return formatSmallActivity(p.Activities[0])
}
return text.Plain("")
}
func formatSmallActivity(ac discord.Activity) text.Rich {
switch ac.Type {
case discord.GameActivity:
return text.Plain(fmt.Sprintf("Playing %s", ac.Name))
case discord.ListeningActivity:
return text.Plain(fmt.Sprintf("Listening to %s", ac.Name))
case discord.StreamingActivity:
return text.Plain(fmt.Sprintf("Streaming on %s", ac.Name))
case discord.CustomActivity:
var status strings.Builder
var segmts []text.Segment
if ac.Emoji != nil {
if !ac.Emoji.ID.IsValid() {
status.WriteString(ac.Emoji.Name)
status.WriteByte(' ')
} else {
segmts = append(segmts, segments.EmojiSegment{
Start: status.Len(),
Name: ac.Emoji.Name,
EmojiURL: ac.Emoji.EmojiURL() + "?size=64",
Large: ac.State == "",
})
}
}
status.WriteString(ac.State)
return text.Rich{
Content: status.String(),
Segments: segmts,
}
default:
return text.Rich{}
}
}
type ListSection struct {
// constant states
listID string
id string // roleID or online or offline
name string
total int
channel *Channel
}
var (
_ cchat.MemberListSection = (*ListSection)(nil)
_ cchat.MemberListDynamicSection = (*ListSection)(nil)
)
func NewListSection(listID string, ch *Channel, group gateway.GuildMemberListGroup) *ListSection {
var name string
switch group.ID {
case "online":
name = "Online"
case "offline":
name = "Offline"
default:
p, err := discord.ParseSnowflake(group.ID)
if err != nil {
name = group.ID
} else {
r, err := ch.session.Role(ch.guildID, discord.RoleID(p))
if err != nil {
name = fmt.Sprintf("<@#%s>", p.String())
} else {
name = r.Name
}
}
}
return &ListSection{
listID: listID,
channel: ch,
id: group.ID,
name: name,
total: int(group.Count),
}
}
func (s *ListSection) ID() cchat.ID {
return s.id
// return fmt.Sprintf("%s-%s", s.listID, s.name)
}
func (s *ListSection) Name() text.Rich {
return text.Rich{Content: s.name}
}
func (s *ListSection) Total() int {
return s.total
}
// TODO: document that Load{More,Less} works more like a shifting window.
func (s *ListSection) LoadMore() bool {
// This variable is here purely to make lines shorter.
var memstate = s.channel.session.MemberState
chunk := memstate.GetMemberListChunk(s.channel.guildID, s.channel.id)
if chunk < 0 {
chunk = 0
}
return memstate.RequestMemberList(s.channel.guildID, s.channel.id, chunk) != nil
}
func (s *ListSection) LoadLess() bool {
var memstate = s.channel.session.MemberState
chunk := memstate.GetMemberListChunk(s.channel.guildID, s.channel.id)
if chunk <= 0 {
return false
}
memstate.RequestMemberList(s.channel.guildID, s.channel.id, chunk-1)
return true
}

View File

@ -1,76 +0,0 @@
package discord
import (
"github.com/diamondburned/arikawa/api"
"github.com/diamondburned/cchat"
)
type SendableChannel struct {
Channel
}
// NewSendableChannel creates a sendable channel. This function is mainly used
// internally
func NewSendableChannel(ch *Channel) *SendableChannel {
return &SendableChannel{*ch}
}
var (
_ cchat.ServerMessageSender = (*SendableChannel)(nil)
_ cchat.ServerMessageSendCompleter = (*SendableChannel)(nil)
_ cchat.ServerMessageAttachmentSender = (*SendableChannel)(nil)
)
func (ch *SendableChannel) SendMessage(msg cchat.SendableMessage) error {
var send = api.SendMessageData{Content: msg.Content()}
if noncer, ok := msg.(cchat.MessageNonce); ok {
send.Nonce = noncer.Nonce()
}
if attcher, ok := msg.(cchat.SendableMessageAttachments); ok {
send.Files = addAttachments(attcher.Attachments())
}
_, err := ch.session.SendMessageComplex(ch.id, send)
return err
}
func (ch *SendableChannel) SendAttachments(atts []cchat.MessageAttachment) error {
_, err := ch.session.SendMessageComplex(ch.id, api.SendMessageData{
Files: addAttachments(atts),
})
return err
}
func addAttachments(atts []cchat.MessageAttachment) []api.SendMessageFile {
var files = make([]api.SendMessageFile, len(atts))
for i, a := range atts {
files[i] = api.SendMessageFile{
Name: a.Name,
Reader: a,
}
}
return files
}
// CompleteMessage implements message input completion capability for Discord.
// This method supports user mentions, channel mentions and emojis.
//
// For the individual implementations, refer to channel_completion.go.
func (ch *SendableChannel) CompleteMessage(words []string, i int) (entries []cchat.CompletionEntry) {
var word = words[i]
// Word should have at least a character for the char check.
if len(word) < 1 {
return
}
switch word[0] {
case '@':
return ch.completeMentions(word[1:])
case '#':
return ch.completeChannels(word[1:])
case ':':
return ch.completeEmojis(word[1:])
}
return
}

79
discord.go Normal file
View File

@ -0,0 +1,79 @@
package discord
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/session"
"github.com/diamondburned/cchat/services"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
func init() {
services.RegisterService(&Service{})
}
// ErrInvalidSession is returned if SessionRestore is given a bad session.
var ErrInvalidSession = errors.New("invalid session")
type Service struct{}
var (
_ cchat.Iconer = (*Service)(nil)
_ cchat.Service = (*Service)(nil)
)
func (Service) Name() text.Rich {
return text.Rich{Content: "Discord"}
}
// IsIconer returns true.
func (Service) IsIconer() bool { return true }
func (Service) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) {
iconer.SetIcon("https://raw.githubusercontent.com/" +
"diamondburned/cchat-discord/himearikawa/discord_logo.png")
return func() {}, nil
}
func (Service) Authenticate() cchat.Authenticator {
return &Authenticator{}
}
func (s Service) RestoreSession(data map[string]string) (cchat.Session, error) {
tk, ok := data["token"]
if !ok {
return nil, ErrInvalidSession
}
return session.NewFromToken(tk)
}
type Authenticator struct{}
var _ cchat.Authenticator = (*Authenticator)(nil)
func (*Authenticator) AuthenticateForm() []cchat.AuthenticateEntry {
// TODO: username, password and 2FA
return []cchat.AuthenticateEntry{
{
Name: "Token",
Secret: true,
},
{
Name: "(or) Username",
},
}
}
func (*Authenticator) Authenticate(form []string) (cchat.Session, error) {
switch {
case form[0] != "": // Token
return session.NewFromToken(form[0])
case form[1] != "": // Username
return nil, errors.New("username sign-in is not supported yet")
}
return nil, errors.New("malformed authentication form")
}

2
go.mod
View File

@ -4,7 +4,7 @@ go 1.14
require (
github.com/diamondburned/arikawa v1.3.0
github.com/diamondburned/cchat v0.0.49
github.com/diamondburned/cchat v0.1.3
github.com/diamondburned/ningen v0.1.1-0.20200820222640-35796f938a58
github.com/dustin/go-humanize v1.0.0
github.com/go-test/deep v1.0.6

8
go.sum
View File

@ -71,6 +71,14 @@ github.com/diamondburned/cchat v0.0.48 h1:MAzGzKY20JBh/LnirOZVPwbMq07xfqu4Lb4XsV
github.com/diamondburned/cchat v0.0.48/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.0.49 h1:zP6QvjdRU3UqDZt3rEqjkR/5M68XRVms7htHfE9tLOc=
github.com/diamondburned/cchat v0.0.49/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.1.0 h1:TJiMdKFd1mijQOO1KSp35PJMvW+jiif5Go4QmoIhH9I=
github.com/diamondburned/cchat v0.1.0/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.1.1 h1:tx130Vx0bvLQvQxyOJbhvPJ85qoOOs5ZhJVXDDIh7eU=
github.com/diamondburned/cchat v0.1.1/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.1.2 h1:/9/xtHeifirMHiHsf/acL23UPZuS2YdzqWMMR5+sUPU=
github.com/diamondburned/cchat v0.1.2/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.1.3 h1:4xq8Tc+U0OUf2Vr6s8Igb5iADmeJ9oM1Db+M6zF/PDQ=
github.com/diamondburned/cchat v0.1.3/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 h1:yP7kJ+xCGpDz6XbcfACJcju4SH1XDPwlrvbofz3lP8I=
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249/go.mod h1:xW9hpBZsGi8KpAh10TyP+YQlYBo+Xc+2w4TR6N0951A=
github.com/diamondburned/ningen v0.1.1-0.20200708085949-b64e350f3b8c h1:3h/kyk6HplYZF3zLi106itjYJWjbuMK/twijeGLEy2M=

189
guild.go
View File

@ -1,189 +0,0 @@
package discord
import (
"context"
"sort"
"strconv"
"strings"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/segments"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
type GuildFolder struct {
gateway.GuildFolder
session *Session
}
var (
_ cchat.Server = (*Guild)(nil)
_ cchat.ServerList = (*Guild)(nil)
)
func NewGuildFolder(s *Session, gf gateway.GuildFolder) *GuildFolder {
// Name should never be empty.
if gf.Name == "" {
var names = make([]string, 0, len(gf.GuildIDs))
for _, id := range gf.GuildIDs {
if g, _ := s.Store.Guild(id); g != nil {
names = append(names, g.Name)
}
}
gf.Name = strings.Join(names, ", ")
}
return &GuildFolder{
GuildFolder: gf,
session: s,
}
}
func (gf *GuildFolder) ID() cchat.ID {
return strconv.FormatInt(int64(gf.GuildFolder.ID), 10)
}
func (gf *GuildFolder) Name() text.Rich {
var name = text.Rich{
// 1en space for style.
Content: gf.GuildFolder.Name,
}
if gf.GuildFolder.Color > 0 {
name.Segments = []text.Segment{
// The length of this black box is actually 3. Mind == blown.
segments.NewColored(len(name.Content), gf.GuildFolder.Color.Uint32()),
}
}
return name
}
func (gf *GuildFolder) Servers(container cchat.ServersContainer) error {
var servers = make([]cchat.Server, 0, len(gf.GuildIDs))
for _, id := range gf.GuildIDs {
g, err := gf.session.Guild(id)
if err != nil {
continue
}
servers = append(servers, NewGuild(gf.session, g))
}
container.SetServers(servers)
return nil
}
type Guild struct {
id discord.GuildID
session *Session
}
var (
_ cchat.Icon = (*Guild)(nil)
_ cchat.Server = (*Guild)(nil)
_ cchat.ServerList = (*Guild)(nil)
)
func NewGuild(s *Session, g *discord.Guild) *Guild {
return &Guild{
id: g.ID,
session: s,
}
}
func NewGuildFromID(s *Session, gID discord.GuildID) (*Guild, error) {
g, err := s.Guild(gID)
if err != nil {
return nil, err
}
return NewGuild(s, g), nil
}
func (g *Guild) self(ctx context.Context) (*discord.Guild, error) {
return g.session.WithContext(ctx).Guild(g.id)
}
func (g *Guild) selfState() (*discord.Guild, error) {
return g.session.Store.Guild(g.id)
}
func (g *Guild) ID() cchat.ID {
return g.id.String()
}
func (g *Guild) Name() text.Rich {
s, err := g.selfState()
if err != nil {
// This shouldn't happen.
return text.Rich{Content: g.id.String()}
}
return text.Rich{Content: s.Name}
}
func (g *Guild) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) {
s, err := g.self(ctx)
if err != nil {
// This shouldn't happen.
return nil, errors.Wrap(err, "Failed to get guild")
}
// Used for comparison.
var hash = s.Icon
if hash != "" {
iconer.SetIcon(AvatarURL(s.IconURL()))
}
return g.session.AddHandler(func(g *gateway.GuildUpdateEvent) {
if g.Icon != hash {
hash = g.Icon
iconer.SetIcon(AvatarURL(s.IconURL()))
}
}), nil
}
func (g *Guild) Servers(container cchat.ServersContainer) error {
c, err := g.session.Channels(g.id)
if err != nil {
return errors.Wrap(err, "Failed to get channels")
}
// Only get top-level channels (those with category ID being null).
var toplevels = filterAccessible(g.session, filterCategory(c, 0))
// Sort so that positions are correct.
sort.SliceStable(toplevels, func(i, j int) bool {
return toplevels[i].Position < toplevels[j].Position
})
// Sort so that channels are before categories.
sort.SliceStable(toplevels, func(i, _ int) bool {
return toplevels[i].Type != discord.GuildCategory
})
var chs = make([]cchat.Server, 0, len(toplevels))
for _, ch := range toplevels {
switch ch.Type {
case discord.GuildCategory:
chs = append(chs, NewCategory(g.session, ch))
case discord.GuildText:
c, err := NewChannel(g.session, ch)
if err != nil {
return errors.Wrapf(err, "Failed to make channel %q: %v", ch.Name, err)
}
chs = append(chs, c)
}
}
container.SetServers(chs)
return nil
}

View File

@ -0,0 +1,123 @@
package category
import (
"sort"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/channel"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
func ChGuildCheck(chType discord.ChannelType) bool {
switch chType {
case discord.GuildCategory, discord.GuildText:
return true
default:
return false
}
}
func FilterAccessible(s *state.Instance, chs []discord.Channel) []discord.Channel {
filtered := chs[:0]
for _, ch := range chs {
p, err := s.Permissions(ch.ID, s.UserID)
// Treat error as non-fatal and add the channel anyway.
if err != nil || p.Has(discord.PermissionViewChannel) {
filtered = append(filtered, ch)
}
}
return filtered
}
func FilterCategory(chs []discord.Channel, catID discord.ChannelID) []discord.Channel {
var filtered = chs[:0]
var catvalid = catID.IsValid()
for _, ch := range chs {
switch {
// If the given ID is not valid, then we look for channels with
// similarly invalid category IDs, because yes, Discord really sends
// inconsistent responses.
case !catvalid && !ch.CategoryID.IsValid():
fallthrough
// Basic comparison.
case ch.CategoryID == catID:
if ChGuildCheck(ch.Type) {
filtered = append(filtered, ch)
}
}
}
return filtered
}
type Category struct {
id discord.ChannelID
guildID discord.GuildID
state *state.Instance
}
var (
_ cchat.Server = (*Category)(nil)
_ cchat.Lister = (*Category)(nil)
)
func New(s *state.Instance, ch discord.Channel) *Category {
return &Category{
id: ch.ID,
guildID: ch.GuildID,
state: s,
}
}
func (c *Category) ID() cchat.ID {
return c.id.String()
}
func (c *Category) Name() text.Rich {
t, err := c.state.Channel(c.id)
if err != nil {
// This shouldn't happen.
return text.Rich{Content: c.id.String()}
}
return text.Rich{
Content: t.Name,
}
}
func (c *Category) IsLister() bool {
return true
}
func (c *Category) Servers(container cchat.ServersContainer) error {
t, err := c.state.Channels(c.guildID)
if err != nil {
return errors.Wrap(err, "Failed to get channels")
}
// Filter out channels with this category ID.
var chs = FilterAccessible(c.state, FilterCategory(t, c.id))
sort.Slice(chs, func(i, j int) bool {
return chs[i].Position < chs[j].Position
})
var chv = make([]cchat.Server, len(chs))
for i := range chs {
c, err := channel.New(c.state, chs[i])
if err != nil {
return errors.Wrapf(err, "Failed to make channel %s: %v", chs[i].Name, err)
}
chv[i] = c
}
container.SetServers(chv)
return nil
}

View File

@ -0,0 +1,97 @@
package channel
import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/pkg/errors"
)
var _ cchat.Actioner = (*Channel)(nil)
// IsActioner returns true.
func (ch *Channel) IsActioner() bool { return true }
const (
ActionDelete = "Delete"
)
var ErrUnknownAction = errors.New("unknown message action")
func (ch *Channel) DoMessageAction(action, id string) error {
s, err := discord.ParseSnowflake(id)
if err != nil {
return errors.Wrap(err, "Failed to parse ID")
}
switch action {
case ActionDelete:
return ch.state.DeleteMessage(ch.id, discord.MessageID(s))
default:
return ErrUnknownAction
}
}
func (ch *Channel) MessageActions(id string) []string {
s, err := discord.ParseSnowflake(id)
if err != nil {
return nil
}
m, err := ch.state.Store.Message(ch.id, discord.MessageID(s))
if err != nil {
return nil
}
// Get the current user.
u, err := ch.state.Store.Me()
if err != nil {
return nil
}
// Can we have delete? We can if this is our own message.
var canDelete = m.Author.ID == u.ID
// We also can if we have the Manage Messages permission, which would allow
// us to delete others' messages.
if !canDelete {
canDelete = ch.canManageMessages(u.ID)
}
if canDelete {
return []string{ActionDelete}
}
return []string{}
}
// canManageMessages returns whether or not the user is allowed to manage
// messages.
func (ch *Channel) canManageMessages(userID discord.UserID) bool {
// If we're not in a guild, then clearly we cannot.
if !ch.guildID.IsValid() {
return false
}
// We need the guild, member and channel to calculate the permission
// overrides.
g, err := ch.guild()
if err != nil {
return false
}
c, err := ch.self()
if err != nil {
return false
}
m, err := ch.state.Store.Member(ch.guildID, userID)
if err != nil {
return false
}
p := discord.CalcOverwrites(*g, *c, *m)
// The Manage Messages permission allows the user to delete others'
// messages, so we'll return true if that is the case.
return p.Has(discord.PermissionManageMessages)
}

View File

@ -0,0 +1,52 @@
package channel
import (
"context"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/message"
"github.com/pkg/errors"
)
var _ cchat.Backlogger = (*Channel)(nil)
// IsBacklogger returns true if the current user can read the channel's message
// history.
func (ch *Channel) IsBacklogger() bool {
p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID)
if err != nil {
return false
}
return p.Has(discord.PermissionViewChannel) && p.Has(discord.PermissionReadMessageHistory)
}
func (ch *Channel) MessagesBefore(ctx context.Context, b cchat.ID, c cchat.MessagePrepender) error {
p, err := discord.ParseSnowflake(b)
if err != nil {
return errors.Wrap(err, "Failed to parse snowflake")
}
s := ch.state.WithContext(ctx)
m, err := s.MessagesBefore(ch.id, discord.MessageID(p), uint(ch.state.MaxMessages()))
if err != nil {
return errors.Wrap(err, "Failed to get messages")
}
// Create the backlog without any member information.
g, err := s.Guild(ch.guildID)
if err != nil {
return errors.Wrap(err, "Failed to get guild")
}
for _, m := range m {
// Discord sucks.
m.GuildID = ch.guildID
c.PrependMessage(message.NewBacklogMessage(m, ch.state, *g))
}
return nil
}

View File

@ -0,0 +1,65 @@
package channel
import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
type Channel struct {
id discord.ChannelID
guildID discord.GuildID
state *state.Instance
}
var _ cchat.Server = (*Channel)(nil)
func New(s *state.Instance, ch discord.Channel) (cchat.Server, error) {
// Ensure the state keeps the channel's permission.
_, err := s.Permissions(ch.ID, s.UserID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get permission")
}
return &Channel{
id: ch.ID,
guildID: ch.GuildID,
state: s,
}, nil
}
// self does not do IO.
func (ch *Channel) self() (*discord.Channel, error) {
return ch.state.Store.Channel(ch.id)
}
// messages does not do IO.
func (ch *Channel) messages() ([]discord.Message, error) {
return ch.state.Store.Messages(ch.id)
}
func (ch *Channel) guild() (*discord.Guild, error) {
if ch.guildID.IsValid() {
return ch.state.Store.Guild(ch.guildID)
}
return nil, errors.New("channel not in a guild")
}
func (ch *Channel) ID() cchat.ID {
return ch.id.String()
}
func (ch *Channel) Name() text.Rich {
c, err := ch.self()
if err != nil {
return text.Rich{Content: ch.id.String()}
}
if c.NSFW {
return text.Rich{Content: "#!" + c.Name}
} else {
return text.Rich{Content: "#" + c.Name}
}
}

View File

@ -1,23 +1,61 @@
package discord
package channel
import (
"strings"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/urlutils"
"github.com/diamondburned/cchat-discord/internal/discord/message"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
)
const MaxCompletion = 15
func completionUserEntry(s *Session, u discord.User, g *discord.Guild) cchat.CompletionEntry {
var _ cchat.MessageCompleter = (*Channel)(nil)
// IsMessageCompleter returns true if the user can send messages in this
// channel.
func (ch *Channel) IsMessageCompleter() bool {
p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID)
if err != nil {
return false
}
return p.Has(discord.PermissionSendMessages)
}
// CompleteMessage implements message input completion capability for Discord.
// This method supports user mentions, channel mentions and emojis.
//
// For the individual implementations, refer to channel_completion.go.
func (ch *Channel) CompleteMessage(words []string, i int) (entries []cchat.CompletionEntry) {
var word = words[i]
// Word should have at least a character for the char check.
if len(word) < 1 {
return
}
switch word[0] {
case '@':
return ch.completeMentions(word[1:])
case '#':
return ch.completeChannels(word[1:])
case ':':
return ch.completeEmojis(word[1:])
}
return
}
func completionUser(s *state.Instance, u discord.User, g *discord.Guild) cchat.CompletionEntry {
if g != nil {
m, err := s.Store.Member(g.ID, u.ID)
if err == nil {
return cchat.CompletionEntry{
Raw: u.Mention(),
Text: RenderMemberName(*m, *g, s),
Text: message.RenderMemberName(*m, *g, s),
Secondary: text.Rich{Content: u.Username + "#" + u.Discriminator},
IconURL: u.AvatarURL(),
}
@ -32,7 +70,7 @@ func completionUserEntry(s *Session, u discord.User, g *discord.Guild) cchat.Com
}
}
func (ch *SendableChannel) completeMentions(word string) (entries []cchat.CompletionEntry) {
func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntry) {
// If there is no input, then we should grab the latest messages.
if word == "" {
msgs, _ := ch.messages()
@ -50,7 +88,7 @@ func (ch *SendableChannel) completeMentions(word string) (entries []cchat.Comple
// Record the current author and add the entry to the list.
authors[msg.Author.ID] = struct{}{}
entries = append(entries, completionUserEntry(ch.session, msg.Author, g))
entries = append(entries, completionUser(ch.state, msg.Author, g))
if len(entries) >= MaxCompletion {
return
@ -89,7 +127,7 @@ func (ch *SendableChannel) completeMentions(word string) (entries []cchat.Comple
}
// If we're in a guild, then we should search for (all) members.
m, merr := ch.session.Store.Members(ch.guildID)
m, merr := ch.state.Store.Members(ch.guildID)
g, gerr := ch.guild()
if merr != nil || gerr != nil {
@ -99,7 +137,7 @@ func (ch *SendableChannel) completeMentions(word string) (entries []cchat.Comple
// If we couldn't find any members, then we can request Discord to
// search for them.
if len(m) == 0 {
ch.session.MemberState.SearchMember(ch.guildID, word)
ch.state.MemberState.SearchMember(ch.guildID, word)
return
}
@ -107,7 +145,7 @@ func (ch *SendableChannel) completeMentions(word string) (entries []cchat.Comple
if contains(match, mem.User.Username, mem.Nick) {
entries = append(entries, cchat.CompletionEntry{
Raw: mem.User.Mention(),
Text: RenderMemberName(mem, *g, ch.session),
Text: message.RenderMemberName(mem, *g, ch.state),
Secondary: text.Rich{Content: mem.User.Username + "#" + mem.User.Discriminator},
IconURL: mem.User.AvatarURL(),
})
@ -120,7 +158,7 @@ func (ch *SendableChannel) completeMentions(word string) (entries []cchat.Comple
return
}
func (ch *SendableChannel) completeChannels(word string) (entries []cchat.CompletionEntry) {
func (ch *Channel) completeChannels(word string) (entries []cchat.CompletionEntry) {
// Ignore if empty word.
if word == "" {
return
@ -131,7 +169,7 @@ func (ch *SendableChannel) completeChannels(word string) (entries []cchat.Comple
return
}
c, err := ch.session.State.Channels(ch.guildID)
c, err := ch.state.State.Channels(ch.guildID)
if err != nil {
return
}
@ -145,7 +183,7 @@ func (ch *SendableChannel) completeChannels(word string) (entries []cchat.Comple
var category string
if channel.CategoryID.IsValid() {
if c, _ := ch.session.Store.Channel(channel.CategoryID); c != nil {
if c, _ := ch.state.Store.Channel(channel.CategoryID); c != nil {
category = c.Name
}
}
@ -164,13 +202,13 @@ func (ch *SendableChannel) completeChannels(word string) (entries []cchat.Comple
return
}
func (ch *SendableChannel) completeEmojis(word string) (entries []cchat.CompletionEntry) {
func (ch *Channel) completeEmojis(word string) (entries []cchat.CompletionEntry) {
// Ignore if empty word.
if word == "" {
return
}
e, err := ch.session.EmojiState.Get(ch.guildID)
e, err := ch.state.EmojiState.Get(ch.guildID)
if err != nil {
return
}

View File

@ -0,0 +1,61 @@
package channel
import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/pkg/errors"
)
var _ cchat.Editor = (*Channel)(nil)
// IsEditor returns true if the user can send messages in this channel.
func (ch *Channel) IsEditor() bool {
p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID)
if err != nil {
return false
}
return p.Has(discord.PermissionSendMessages)
}
// MessageEditable returns true if the given message ID belongs to the current
// user.
func (ch *Channel) MessageEditable(id string) bool {
s, err := discord.ParseSnowflake(id)
if err != nil {
return false
}
m, err := ch.state.Store.Message(ch.id, discord.MessageID(s))
if err != nil {
return false
}
return m.Author.ID == ch.state.UserID
}
// RawMessageContent returns the raw message content from Discord.
func (ch *Channel) RawMessageContent(id string) (string, error) {
s, err := discord.ParseSnowflake(id)
if err != nil {
return "", errors.Wrap(err, "Failed to parse ID")
}
m, err := ch.state.Store.Message(ch.id, discord.MessageID(s))
if err != nil {
return "", errors.Wrap(err, "Failed to get the message")
}
return m.Content, nil
}
// EditMessage edits the message to the given content string.
func (ch *Channel) EditMessage(id, content string) error {
s, err := discord.ParseSnowflake(id)
if err != nil {
return errors.Wrap(err, "Failed to parse ID")
}
_, err = ch.state.EditText(ch.id, discord.MessageID(s), content)
return err
}

View File

@ -0,0 +1,70 @@
package channel
import (
"time"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/channel/typer"
"github.com/diamondburned/ningen/states/read"
"github.com/pkg/errors"
)
var (
_ cchat.TypingIndicator = (*Channel)(nil)
_ cchat.UnreadIndicator = (*Channel)(nil)
)
// IsTypingIndicator returns true.
func (ch *Channel) IsTypingIndicator() bool { return true }
func (ch *Channel) Typing() error {
return ch.state.Typing(ch.id)
}
// TypingTimeout returns 10 seconds.
func (ch *Channel) TypingTimeout() time.Duration {
return 10 * time.Second
}
func (ch *Channel) TypingSubscribe(ti cchat.TypingContainer) (func(), error) {
return ch.state.AddHandler(func(t *gateway.TypingStartEvent) {
// Ignore channel mismatch or if the typing event is ours.
if t.ChannelID != ch.id || t.UserID == ch.state.UserID {
return
}
if typer, err := typer.New(ch.state, t); err == nil {
ti.AddTyper(typer)
}
}), nil
}
// muted returns if this channel is muted. This includes the channel's category
// and guild.
func (ch *Channel) muted() bool {
return (ch.guildID.IsValid() && ch.state.MutedState.Guild(ch.guildID, false)) ||
ch.state.MutedState.Channel(ch.id) ||
ch.state.MutedState.Category(ch.id)
}
// IsUnreadIndicator returns true.
func (ch *Channel) IsUnreadIndicator() bool { return true }
func (ch *Channel) UnreadIndicate(indicator cchat.UnreadContainer) (func(), error) {
if rs := ch.state.ReadState.FindLast(ch.id); rs != nil {
c, err := ch.self()
if err != nil {
return nil, errors.Wrap(err, "Failed to get self channel")
}
if c.LastMessageID > rs.LastMessageID && !ch.muted() {
indicator.SetUnread(true, rs.MentionCount > 0)
}
}
return ch.state.ReadState.OnUpdate(func(ev *read.UpdateEvent) {
if ch.id == ev.ChannelID && !ch.muted() {
indicator.SetUnread(ev.Unread, ev.MentionCount > 0)
}
}), nil
}

View File

@ -0,0 +1,164 @@
package memberlist
import (
"context"
"fmt"
"strings"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/segments"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
)
type Member struct {
Channel
state *state.Instance
userID discord.UserID
origName string // use if cache is stale
}
var (
_ cchat.ListMember = (*Member)(nil)
_ cchat.Iconer = (*Member)(nil)
)
// New creates a new list member. it.Member must not be nil.
func (c Channel) NewMember(opItem gateway.GuildMemberListOpItem) *Member {
return &Member{
Channel: c,
userID: opItem.Member.User.ID,
origName: opItem.Member.User.Username,
}
}
func (l *Member) ID() cchat.ID {
return l.userID.String()
}
func (l *Member) Name() text.Rich {
g, err := l.state.Store.Guild(l.guildID)
if err != nil {
return text.Plain(l.origName)
}
m, err := l.state.Store.Member(l.guildID, l.userID)
if err != nil {
return text.Plain(l.origName)
}
var name = m.User.Username
if m.Nick != "" {
name = m.Nick
}
mention := segments.MemberSegment(0, len(name), *g, *m)
mention.WithState(l.state.State)
var txt = text.Rich{
Content: name,
Segments: []text.Segment{mention},
}
if c := discord.MemberColor(*g, *m); c != discord.DefaultMemberColor {
txt.Segments = append(txt.Segments, segments.NewColored(len(name), uint32(c)))
}
return txt
}
// IsIconer returns true.
func (l *Member) IsIconer() bool { return true }
func (l *Member) Icon(ctx context.Context, c cchat.IconContainer) (func(), error) {
m, err := l.state.Member(l.guildID, l.userID)
if err != nil {
return nil, err
}
c.SetIcon(urlutils.AvatarURL(m.User.AvatarURL()))
return func() {}, nil
}
func (l *Member) Status() cchat.UserStatus {
p, err := l.state.Store.Presence(l.guildID, l.userID)
if err != nil {
return cchat.UnknownStatus
}
switch p.Status {
case discord.OnlineStatus:
return cchat.OnlineStatus
case discord.DoNotDisturbStatus:
return cchat.BusyStatus
case discord.IdleStatus:
return cchat.AwayStatus
case discord.OfflineStatus, discord.InvisibleStatus:
return cchat.OfflineStatus
default:
return cchat.UnknownStatus
}
}
func (l *Member) Secondary() text.Rich {
p, err := l.state.Store.Presence(l.guildID, l.userID)
if err != nil {
return text.Plain("")
}
if p.Game != nil {
return formatSmallActivity(*p.Game)
}
if len(p.Activities) > 0 {
return formatSmallActivity(p.Activities[0])
}
return text.Plain("")
}
func formatSmallActivity(ac discord.Activity) text.Rich {
switch ac.Type {
case discord.GameActivity:
return text.Plain(fmt.Sprintf("Playing %s", ac.Name))
case discord.ListeningActivity:
return text.Plain(fmt.Sprintf("Listening to %s", ac.Name))
case discord.StreamingActivity:
return text.Plain(fmt.Sprintf("Streaming on %s", ac.Name))
case discord.CustomActivity:
var status strings.Builder
var segmts []text.Segment
if ac.Emoji != nil {
if !ac.Emoji.ID.IsValid() {
status.WriteString(ac.Emoji.Name)
status.WriteByte(' ')
} else {
segmts = append(segmts, segments.EmojiSegment{
Start: status.Len(),
Name: ac.Emoji.Name,
EmojiURL: ac.Emoji.EmojiURL() + "?size=64",
Large: ac.State == "",
})
}
}
status.WriteString(ac.State)
return text.Rich{
Content: status.String(),
Segments: segmts,
}
default:
return text.Rich{}
}
}

View File

@ -0,0 +1,36 @@
package memberlist
import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/ningen/states/member"
)
type Channel struct {
// Keep stateful references to do on-demand loading.
state *state.Instance
// constant states
channelID discord.ChannelID
guildID discord.GuildID
}
func NewChannel(s *state.Instance, ch discord.ChannelID, g discord.GuildID) Channel {
return Channel{
state: s,
channelID: ch,
guildID: g,
}
}
func (ch Channel) FlushMemberGroups(l *member.List, c cchat.MemberListContainer) {
l.ViewGroups(func(groups []gateway.GuildMemberListGroup) {
var sections = make([]cchat.MemberSection, len(groups))
for i, group := range groups {
sections[i] = ch.NewSection(l.ID(), group)
}
c.SetSections(sections)
})
}

View File

@ -0,0 +1,91 @@
package memberlist
import (
"fmt"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat/text"
)
type Section struct {
Channel
// constant states
listID string
id string // roleID or online or offline
name string
total int
}
var (
_ cchat.MemberSection = (*Section)(nil)
_ cchat.MemberDynamicSection = (*Section)(nil)
)
func (ch Channel) NewSection(listID string, group gateway.GuildMemberListGroup) *Section {
var name string
switch group.ID {
case "online":
name = "Online"
case "offline":
name = "Offline"
default:
p, err := discord.ParseSnowflake(group.ID)
if err != nil {
name = group.ID
} else {
r, err := ch.state.Role(ch.guildID, discord.RoleID(p))
if err != nil {
name = fmt.Sprintf("<@#%s>", p.String())
} else {
name = r.Name
}
}
}
return &Section{
Channel: ch,
listID: listID,
id: group.ID,
name: name,
total: int(group.Count),
}
}
func (s *Section) ID() cchat.ID {
return s.id
}
func (s *Section) Name() text.Rich {
return text.Rich{Content: s.name}
}
func (s *Section) Total() int {
return s.total
}
func (s *Section) IsMemberDynamicSection() bool { return true }
// TODO: document that Load{More,Less} works more like a shifting window.
func (s *Section) LoadMore() bool {
chunk := s.state.MemberState.GetMemberListChunk(s.guildID, s.channelID)
if chunk < 0 {
chunk = 0
}
return s.state.MemberState.RequestMemberList(s.guildID, s.channelID, chunk) != nil
}
func (s *Section) LoadLess() bool {
chunk := s.state.MemberState.GetMemberListChunk(s.guildID, s.channelID)
if chunk <= 0 {
return false
}
s.state.MemberState.RequestMemberList(s.guildID, s.channelID, chunk-1)
return true
}

View File

@ -0,0 +1,111 @@
package channel
import (
"context"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/channel/memberlist"
"github.com/diamondburned/ningen/states/member"
)
func seekPrevGroup(l *member.List, ix int) (item, group gateway.GuildMemberListOpItem) {
l.ViewItems(func(items []gateway.GuildMemberListOpItem) {
// Bound check.
if ix >= len(items) {
return
}
item = items[ix]
// Search backwards.
for i := ix; i >= 0; i-- {
if items[i].Group != nil {
group = items[i]
return
}
}
})
return
}
var _ cchat.MemberLister = (*Channel)(nil)
// IsMemberLister returns true if the channel is a guild channel.
func (ch *Channel) IsMemberLister() bool {
return ch.guildID.IsValid()
}
func (ch *Channel) memberListCh() memberlist.Channel {
return memberlist.NewChannel(ch.state, ch.id, ch.guildID)
}
func (ch *Channel) ListMembers(ctx context.Context, c cchat.MemberListContainer) (func(), error) {
if !ch.guildID.IsValid() {
return func() {}, nil
}
cancel := ch.state.AddHandler(func(u *gateway.GuildMemberListUpdate) {
l, err := ch.state.MemberState.GetMemberList(ch.guildID, ch.id)
if err != nil {
return // wat
}
if l.GuildID() != u.GuildID || l.ID() != u.ID {
return
}
var listCh = ch.memberListCh()
for _, ev := range u.Ops {
switch ev.Op {
case "SYNC":
ch.checkSync(c)
case "INSERT", "UPDATE":
item, group := seekPrevGroup(l, ev.Index)
if item.Member != nil && group.Group != nil {
c.SetMember(group.Group.ID, listCh.NewMember(item))
listCh.FlushMemberGroups(l, c)
}
case "DELETE":
_, group := seekPrevGroup(l, ev.Index-1)
if group.Group != nil && ev.Item.Member != nil {
c.RemoveMember(group.Group.ID, ev.Item.Member.User.ID.String())
listCh.FlushMemberGroups(l, c)
}
}
}
})
ch.checkSync(c)
return cancel, nil
}
func (ch *Channel) checkSync(c cchat.MemberListContainer) {
l, err := ch.state.MemberState.GetMemberList(ch.guildID, ch.id)
if err != nil {
ch.state.MemberState.RequestMemberList(ch.guildID, ch.id, 0)
return
}
listCh := ch.memberListCh()
listCh.FlushMemberGroups(l, c)
l.ViewItems(func(items []gateway.GuildMemberListOpItem) {
var group gateway.GuildMemberListGroup
for _, item := range items {
switch {
case item.Group != nil:
group = *item.Group
case item.Member != nil:
c.SetMember(group.ID, listCh.NewMember(item))
}
}
})
}

View File

@ -0,0 +1,125 @@
package channel
import (
"context"
"sort"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/message"
"github.com/diamondburned/cchat-discord/internal/funcutil"
"github.com/pkg/errors"
)
var _ cchat.Messenger = (*Channel)(nil)
// IsMessenger returns true if the current user is allowed to see the channel.
func (ch *Channel) IsMessenger() bool {
p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID)
if err != nil {
return false
}
return p.Has(discord.PermissionViewChannel)
}
func (ch *Channel) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) {
state := ch.state.WithContext(ctx)
m, err := state.Messages(ch.id)
if err != nil {
return nil, err
}
var addcancel = funcutil.NewCancels()
var constructor func(discord.Message) cchat.MessageCreate
if ch.guildID.IsValid() {
// Create the backlog without any member information.
g, err := state.Guild(ch.guildID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get guild")
}
constructor = func(m discord.Message) cchat.MessageCreate {
return message.NewBacklogMessage(m, ch.state, *g)
}
// Subscribe to typing events.
ch.state.MemberState.Subscribe(ch.guildID)
// Listen to new members before creating the backlog and requesting members.
addcancel(ch.state.AddHandler(func(c *gateway.GuildMembersChunkEvent) {
if c.GuildID != ch.guildID {
return
}
m, err := ch.messages()
if err != nil {
// TODO: log
return
}
g, err := ch.guild()
if err != nil {
return
}
// Loop over all messages and replace the author. The latest
// messages are in front.
for _, msg := range m {
for _, member := range c.Members {
if msg.Author.ID != member.User.ID {
continue
}
ct.UpdateMessage(message.NewMessageUpdateAuthor(msg, member, *g, ch.state))
}
}
}))
} else {
constructor = func(m discord.Message) cchat.MessageCreate {
return message.NewDirectMessage(m, ch.state)
}
}
// Only do all this if we even have any messages.
if len(m) > 0 {
// Sort messages chronologically using the ID so that the oldest messages
// (ones with the smallest snowflake) is in front.
sort.Slice(m, func(i, j int) bool { return m[i].ID < m[j].ID })
// Iterate from the earliest messages to the latest messages.
for _, m := range m {
ct.CreateMessage(constructor(m))
}
// Mark this channel as read.
ch.state.ReadState.MarkRead(ch.id, m[len(m)-1].ID)
}
// Bind the handler.
addcancel(
ch.state.AddHandler(func(m *gateway.MessageCreateEvent) {
if m.ChannelID == ch.id {
ct.CreateMessage(message.NewMessageCreate(m, ch.state))
ch.state.ReadState.MarkRead(ch.id, m.ID)
}
}),
ch.state.AddHandler(func(m *gateway.MessageUpdateEvent) {
// If the updated content is empty. TODO: add embed support.
if m.ChannelID == ch.id {
ct.UpdateMessage(message.NewMessageUpdateContent(m.Message, ch.state))
}
}),
ch.state.AddHandler(func(m *gateway.MessageDeleteEvent) {
if m.ChannelID == ch.id {
ct.DeleteMessage(message.NewHeaderDelete(m))
}
}),
)
return funcutil.JoinCancels(addcancel()), nil
}

View File

@ -0,0 +1,73 @@
package channel
import (
"context"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/segments"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
var _ cchat.Nicknamer = (*Channel)(nil)
// IsNicknamer returns true if the current channel is in a guild.
func (ch *Channel) IsNicknamer() bool {
return ch.guildID.IsValid()
}
func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) (func(), error) {
// We don't have a nickname if we're not in a guild.
if !ch.guildID.IsValid() {
return func() {}, nil
}
state := ch.state.WithContext(ctx)
// MemberColor should fill up the state cache.
c, err := state.MemberColor(ch.guildID, ch.state.UserID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get self member color")
}
m, err := state.Member(ch.guildID, ch.state.UserID)
if err != nil {
return nil, errors.Wrap(err, "Failed to get self member")
}
var rich = text.Rich{Content: m.User.Username}
if m.Nick != "" {
rich.Content = m.Nick
}
if c > 0 {
rich.Segments = []text.Segment{
segments.NewColored(len(rich.Content), c.Uint32()),
}
}
labeler.SetLabel(rich)
// Copy the user ID to use.
var selfID = m.User.ID
return ch.state.AddHandler(func(g *gateway.GuildMemberUpdateEvent) {
if g.GuildID != ch.guildID || g.User.ID != selfID {
return
}
var rich = text.Rich{Content: m.User.Username}
if m.Nick != "" {
rich.Content = m.Nick
}
c, err := ch.state.MemberColor(g.GuildID, selfID)
if err == nil {
rich.Segments = []text.Segment{
segments.NewColored(len(rich.Content), c.Uint32()),
}
}
labeler.SetLabel(rich)
}), nil
}

View File

@ -0,0 +1,62 @@
package channel
import (
"github.com/diamondburned/arikawa/api"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
)
var (
_ cchat.MessageSender = (*Channel)(nil)
_ cchat.AttachmentSender = (*Channel)(nil)
)
func (ch *Channel) IsMessageSender() bool {
p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID)
if err != nil {
return false
}
return p.Has(discord.PermissionSendMessages)
}
func (ch *Channel) SendMessage(msg cchat.SendableMessage) error {
var send = api.SendMessageData{Content: msg.Content()}
if noncer, ok := msg.(cchat.MessageNonce); ok {
send.Nonce = noncer.Nonce()
}
if attcher, ok := msg.(cchat.Attachments); ok {
send.Files = addAttachments(attcher.Attachments())
}
_, err := ch.state.SendMessageComplex(ch.id, send)
return err
}
// IsAttachmentSender returns true if the channel can attach files.
func (ch *Channel) IsAttachmentSender() bool {
p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID)
if err != nil {
return false
}
return p.Has(discord.PermissionAttachFiles)
}
func (ch *Channel) SendAttachments(atts []cchat.MessageAttachment) error {
_, err := ch.state.SendMessageComplex(ch.id, api.SendMessageData{
Files: addAttachments(atts),
})
return err
}
func addAttachments(atts []cchat.MessageAttachment) []api.SendMessageFile {
var files = make([]api.SendMessageFile, len(atts))
for i, a := range atts {
files[i] = api.SendMessageFile{
Name: a.Name,
Reader: a,
}
}
return files
}

View File

@ -1,29 +1,31 @@
package discord
package typer
import (
"errors"
"time"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/pkg/errors"
"github.com/diamondburned/cchat-discord/internal/discord/message"
"github.com/diamondburned/cchat-discord/internal/discord/state"
)
type Typer struct {
Author
message.Author
time discord.UnixTimestamp
}
var _ cchat.Typer = (*Typer)(nil)
func NewTyperAuthor(author Author, ev *gateway.TypingStartEvent) Typer {
func NewFromAuthor(author message.Author, ev *gateway.TypingStartEvent) Typer {
return Typer{
Author: author,
time: ev.Timestamp,
}
}
func NewTyper(s *Session, ev *gateway.TypingStartEvent) (*Typer, error) {
func New(s *state.Instance, ev *gateway.TypingStartEvent) (*Typer, error) {
if ev.GuildID.IsValid() {
g, err := s.Store.Guild(ev.GuildID)
if err != nil {
@ -38,7 +40,7 @@ func NewTyper(s *Session, ev *gateway.TypingStartEvent) (*Typer, error) {
}
return &Typer{
Author: NewGuildMember(*ev.Member, *g, s),
Author: message.NewGuildMember(*ev.Member, *g, s),
time: ev.Timestamp,
}, nil
}
@ -51,7 +53,7 @@ func NewTyper(s *Session, ev *gateway.TypingStartEvent) (*Typer, error) {
for _, user := range c.DMRecipients {
if user.ID == ev.UserID {
return &Typer{
Author: NewUser(user, s),
Author: message.NewUser(user, s),
time: ev.Timestamp,
}, nil
}

View File

@ -0,0 +1,82 @@
package folder
import (
"strconv"
"strings"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/guild"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/segments"
"github.com/diamondburned/cchat/text"
)
type GuildFolder struct {
gateway.GuildFolder
state *state.Instance
}
var (
_ cchat.Server = (*GuildFolder)(nil)
_ cchat.Lister = (*GuildFolder)(nil)
)
func New(s *state.Instance, gf gateway.GuildFolder) *GuildFolder {
// Name should never be empty.
if gf.Name == "" {
var names = make([]string, 0, len(gf.GuildIDs))
for _, id := range gf.GuildIDs {
if g, _ := s.Store.Guild(id); g != nil {
names = append(names, g.Name)
}
}
gf.Name = strings.Join(names, ", ")
}
return &GuildFolder{
GuildFolder: gf,
state: s,
}
}
func (gf *GuildFolder) ID() cchat.ID {
return strconv.FormatInt(int64(gf.GuildFolder.ID), 10)
}
func (gf *GuildFolder) Name() text.Rich {
var name = text.Rich{
// 1en space for style.
Content: gf.GuildFolder.Name,
}
if gf.GuildFolder.Color > 0 {
name.Segments = []text.Segment{
// The length of this black box is actually 3. Mind == blown.
segments.NewColored(len(name.Content), gf.GuildFolder.Color.Uint32()),
}
}
return name
}
// IsLister returns true.
func (gf *GuildFolder) IsLister() bool { return true }
func (gf *GuildFolder) Servers(container cchat.ServersContainer) error {
var servers = make([]cchat.Server, 0, len(gf.GuildIDs))
for _, id := range gf.GuildIDs {
g, err := gf.state.Guild(id)
if err != nil {
continue
}
servers = append(servers, guild.New(gf.state, g))
}
container.SetServers(servers)
return nil
}

View File

@ -0,0 +1,131 @@
package guild
import (
"context"
"sort"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/category"
"github.com/diamondburned/cchat-discord/internal/discord/channel"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
type Guild struct {
id discord.GuildID
state *state.Instance
}
var (
_ cchat.Iconer = (*Guild)(nil)
_ cchat.Server = (*Guild)(nil)
_ cchat.Lister = (*Guild)(nil)
)
func New(s *state.Instance, g *discord.Guild) *Guild {
return &Guild{
id: g.ID,
state: s,
}
}
func NewFromID(s *state.Instance, gID discord.GuildID) (*Guild, error) {
g, err := s.Guild(gID)
if err != nil {
return nil, err
}
return New(s, g), nil
}
func (g *Guild) self(ctx context.Context) (*discord.Guild, error) {
return g.state.WithContext(ctx).Guild(g.id)
}
func (g *Guild) selfState() (*discord.Guild, error) {
return g.state.Store.Guild(g.id)
}
func (g *Guild) ID() cchat.ID {
return g.id.String()
}
func (g *Guild) Name() text.Rich {
s, err := g.selfState()
if err != nil {
// This shouldn't happen.
return text.Rich{Content: g.id.String()}
}
return text.Rich{Content: s.Name}
}
// IsIconer returns true if the guild has an icon.
func (g *Guild) IsIconer() bool {
s, err := g.selfState()
return err == nil && s.Icon != ""
}
func (g *Guild) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) {
s, err := g.self(ctx)
if err != nil {
// This shouldn't happen.
return nil, errors.Wrap(err, "Failed to get guild")
}
// Used for comparison.
if s.Icon != "" {
iconer.SetIcon(urlutils.AvatarURL(s.IconURL()))
}
return g.state.AddHandler(func(update *gateway.GuildUpdateEvent) {
if g.id == update.ID {
iconer.SetIcon(urlutils.AvatarURL(s.IconURL()))
}
}), nil
}
// IsLister returns true.
func (g *Guild) IsLister() bool { return true }
func (g *Guild) Servers(container cchat.ServersContainer) error {
c, err := g.state.Channels(g.id)
if err != nil {
return errors.Wrap(err, "Failed to get channels")
}
// Only get top-level channels (those with category ID being null).
var toplevels = category.FilterAccessible(g.state, category.FilterCategory(c, 0))
// Sort so that positions are correct.
sort.SliceStable(toplevels, func(i, j int) bool {
return toplevels[i].Position < toplevels[j].Position
})
// Sort so that channels are before categories.
sort.SliceStable(toplevels, func(i, _ int) bool {
return toplevels[i].Type != discord.GuildCategory
})
var chs = make([]cchat.Server, 0, len(toplevels))
for _, ch := range toplevels {
switch ch.Type {
case discord.GuildCategory:
chs = append(chs, category.New(g.state, ch))
case discord.GuildText:
c, err := channel.New(g.state, ch)
if err != nil {
return errors.Wrapf(err, "Failed to make channel %q: %v", ch.Name, err)
}
chs = append(chs, c)
}
}
container.SetServers(chs)
return nil
}

View File

@ -0,0 +1,90 @@
package message
import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/segments"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
)
type Author struct {
id discord.UserID
name text.Rich
avatar string
}
func NewUser(u discord.User, s *state.Instance) Author {
var name = text.Rich{Content: u.Username}
if u.Bot {
name.Content += " "
name.Segments = append(name.Segments,
segments.NewBlurpleSegment(segments.Write(&name, "[BOT]")),
)
}
// Append a clickable user popup.
useg := segments.UserSegment(0, len(name.Content), u)
useg.WithState(s.State)
name.Segments = append(name.Segments, useg)
return Author{
id: u.ID,
name: name,
avatar: urlutils.AvatarURL(u.AvatarURL()),
}
}
func NewGuildMember(m discord.Member, g discord.Guild, s *state.Instance) Author {
return Author{
id: m.User.ID,
name: RenderMemberName(m, g, s),
avatar: urlutils.AvatarURL(m.User.AvatarURL()),
}
}
func RenderMemberName(m discord.Member, g discord.Guild, s *state.Instance) text.Rich {
var name = text.Rich{
Content: m.User.Username,
}
// Update the nickname.
if m.Nick != "" {
name.Content = m.Nick
}
// Update the color.
if c := discord.MemberColor(g, m); c > 0 {
name.Segments = append(name.Segments,
segments.NewColored(len(name.Content), c.Uint32()),
)
}
// Append the bot prefix if the user is a bot.
if m.User.Bot {
name.Content += " "
name.Segments = append(name.Segments,
segments.NewBlurpleSegment(segments.Write(&name, "[BOT]")),
)
}
// Append a clickable user popup.
useg := segments.MemberSegment(0, len(name.Content), g, m)
useg.WithState(s.State)
name.Segments = append(name.Segments, useg)
return name
}
func (a Author) ID() cchat.ID {
return a.id.String()
}
func (a Author) Name() text.Rich {
return a.name
}
func (a Author) Avatar() string {
return a.avatar
}

View File

@ -1,4 +1,4 @@
package discord
package message
import (
"time"
@ -6,8 +6,8 @@ import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/segments"
"github.com/diamondburned/cchat-discord/urlutils"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/segments"
"github.com/diamondburned/cchat/text"
)
@ -52,91 +52,6 @@ func (m messageHeader) Time() time.Time {
return m.time.Time()
}
// AvatarURL wraps the URL with URL queries for the avatar.
func AvatarURL(URL string) string {
return urlutils.AvatarURL(URL)
}
type Author struct {
id discord.UserID
name text.Rich
avatar string
}
func NewUser(u discord.User, s *Session) Author {
var name = text.Rich{Content: u.Username}
if u.Bot {
name.Content += " "
name.Segments = append(name.Segments,
segments.NewBlurpleSegment(segments.Write(&name, "[BOT]")),
)
}
// Append a clickable user popup.
useg := segments.UserSegment(0, len(name.Content), u)
useg.WithState(s.State)
name.Segments = append(name.Segments, useg)
return Author{
id: u.ID,
name: name,
avatar: AvatarURL(u.AvatarURL()),
}
}
func NewGuildMember(m discord.Member, g discord.Guild, s *Session) Author {
return Author{
id: m.User.ID,
name: RenderMemberName(m, g, s),
avatar: AvatarURL(m.User.AvatarURL()),
}
}
func RenderMemberName(m discord.Member, g discord.Guild, s *Session) text.Rich {
var name = text.Rich{
Content: m.User.Username,
}
// Update the nickname.
if m.Nick != "" {
name.Content = m.Nick
}
// Update the color.
if c := discord.MemberColor(g, m); c > 0 {
name.Segments = append(name.Segments,
segments.NewColored(len(name.Content), c.Uint32()),
)
}
// Append the bot prefix if the user is a bot.
if m.User.Bot {
name.Content += " "
name.Segments = append(name.Segments,
segments.NewBlurpleSegment(segments.Write(&name, "[BOT]")),
)
}
// Append a clickable user popup.
useg := segments.MemberSegment(0, len(name.Content), g, m)
useg.WithState(s.State)
name.Segments = append(name.Segments, useg)
return name
}
func (a Author) ID() cchat.ID {
return a.id.String()
}
func (a Author) Name() text.Rich {
return a.name
}
func (a Author) Avatar() string {
return a.avatar
}
type Message struct {
messageHeader
@ -147,7 +62,7 @@ type Message struct {
mentioned bool
}
func NewMessageUpdateContent(msg discord.Message, s *Session) Message {
func NewMessageUpdateContent(msg discord.Message, s *state.Instance) Message {
// Check if content is empty.
if msg.Content == "" {
// Then grab the content from the state.
@ -164,7 +79,7 @@ func NewMessageUpdateContent(msg discord.Message, s *Session) Message {
}
func NewMessageUpdateAuthor(
msg discord.Message, member discord.Member, g discord.Guild, s *Session) Message {
msg discord.Message, member discord.Member, g discord.Guild, s *state.Instance) Message {
return Message{
messageHeader: newHeader(msg),
@ -174,7 +89,7 @@ func NewMessageUpdateAuthor(
// NewMessageCreate uses the session to create a message. It does not do
// API calls. Member is optional.
func NewMessageCreate(c *gateway.MessageCreateEvent, s *Session) Message {
func NewMessageCreate(c *gateway.MessageCreateEvent, s *state.Instance) Message {
// This should not error.
g, err := s.Store.Guild(c.GuildID)
if err != nil {
@ -195,7 +110,7 @@ func NewMessageCreate(c *gateway.MessageCreateEvent, s *Session) Message {
// NewBacklogMessage uses the session to create a message fetched from the
// backlog. It takes in an existing guild and tries to fetch a new member, if
// it's nil.
func NewBacklogMessage(m discord.Message, s *Session, g discord.Guild) Message {
func NewBacklogMessage(m discord.Message, s *state.Instance, g discord.Guild) Message {
// If the message doesn't have a guild, then we don't need all the
// complicated member fetching process.
if !m.GuildID.IsValid() {
@ -211,11 +126,11 @@ func NewBacklogMessage(m discord.Message, s *Session, g discord.Guild) Message {
return NewMessage(m, s, NewGuildMember(*mem, g, s))
}
func NewDirectMessage(m discord.Message, s *Session) Message {
func NewDirectMessage(m discord.Message, s *state.Instance) Message {
return NewMessage(m, s, NewUser(m.Author, s))
}
func NewMessage(m discord.Message, s *Session, author Author) Message {
func NewMessage(m discord.Message, s *state.Instance, author Author) Message {
// Render the message content.
var content = segments.ParseMessage(&m, s.Store)
@ -248,7 +163,7 @@ func NewMessage(m discord.Message, s *Session, author Author) Message {
}
}
func (m Message) Author() cchat.MessageAuthor {
func (m Message) Author() cchat.Author {
if !m.author.id.IsValid() {
return nil
}

View File

@ -0,0 +1,155 @@
package session
import (
"context"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/arikawa/session"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-discord/internal/discord/folder"
"github.com/diamondburned/cchat-discord/internal/discord/guild"
"github.com/diamondburned/cchat-discord/internal/discord/state"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/ningen"
"github.com/pkg/errors"
)
type Session struct {
*state.Instance
}
var (
_ cchat.Iconer = (*Session)(nil)
_ cchat.Session = (*Session)(nil)
_ cchat.SessionSaver = (*Session)(nil)
)
func NewFromToken(token string) (*Session, error) {
i, err := state.NewFromToken(token)
if err != nil {
return nil, err
}
return &Session{i}, nil
}
func (s *Session) ID() cchat.ID {
return s.UserID.String()
}
func (s *Session) Name() text.Rich {
u, err := s.Store.Me()
if err != nil {
// This shouldn't happen, ever.
return text.Rich{Content: "<@" + s.UserID.String() + ">"}
}
return text.Rich{Content: u.Username + "#" + u.Discriminator}
}
// IsIconer returns true.
func (s *Session) IsIconer() bool { return true }
func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) {
u, err := s.Me()
if err != nil {
return nil, errors.Wrap(err, "Failed to get the current user")
}
// Thanks to arikawa, AvatarURL is never empty.
iconer.SetIcon(urlutils.AvatarURL(u.AvatarURL()))
return s.AddHandler(func(*gateway.UserUpdateEvent) {
// Bypass the event and use the state cache.
if u, err := s.Store.Me(); err == nil {
iconer.SetIcon(urlutils.AvatarURL(u.AvatarURL()))
}
}), nil
}
func (s *Session) Disconnect() error {
return s.Close()
}
// IsSessionSaver returns true.
func (s *Session) IsSessionSaver() bool { return true }
func (s *Session) SaveSession() map[string]string {
return map[string]string{
"token": s.Token,
}
}
func (s *Session) Servers(container cchat.ServersContainer) error {
// Reset the entire container when the session is closed.
s.AddHandler(func(*session.Closed) {
container.SetServers(nil)
})
// Set the entire container again once reconnected.
s.AddHandler(func(*ningen.Connected) {
s.servers(container)
})
return s.servers(container)
}
func (s *Session) servers(container cchat.ServersContainer) error {
switch {
// If the user has guild folders:
case len(s.Ready.Settings.GuildFolders) > 0:
// TODO: account for missing guilds.
var toplevels = make([]cchat.Server, 0, len(s.Ready.Settings.GuildFolders))
for _, guildFolder := range s.Ready.Settings.GuildFolders {
// TODO: correct.
switch {
case guildFolder.ID > 0:
fallthrough
case len(guildFolder.GuildIDs) > 1:
toplevels = append(toplevels, folder.New(s.Instance, guildFolder))
case len(guildFolder.GuildIDs) == 1:
g, err := guild.NewFromID(s.Instance, guildFolder.GuildIDs[0])
if err != nil {
continue
}
toplevels = append(toplevels, g)
}
}
container.SetServers(toplevels)
// If the user doesn't have guild folders but has sorted their guilds
// before:
case len(s.Ready.Settings.GuildPositions) > 0:
var guilds = make([]cchat.Server, 0, len(s.Ready.Settings.GuildPositions))
for _, id := range s.Ready.Settings.GuildPositions {
g, err := guild.NewFromID(s.Instance, id)
if err != nil {
continue
}
guilds = append(guilds, g)
}
container.SetServers(guilds)
// None of the above:
default:
g, err := s.Guilds()
if err != nil {
return err
}
var servers = make([]cchat.Server, len(g))
for i := range g {
servers[i] = guild.New(s.Instance, &g[i])
}
container.SetServers(servers)
}
return nil
}

View File

@ -0,0 +1,62 @@
// Package state provides a shared state instance for other packages to use.
package state
import (
"context"
"log"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/state"
"github.com/diamondburned/arikawa/utils/httputil/httpdriver"
"github.com/diamondburned/ningen"
"github.com/pkg/errors"
)
type Instance struct {
*ningen.State
UserID discord.UserID
}
func NewFromToken(token string) (*Instance, error) {
s, err := state.New(token)
if err != nil {
return nil, err
}
return New(s)
}
func New(s *state.State) (*Instance, error) {
// Prefetch user.
u, err := s.Me()
if err != nil {
return nil, errors.Wrap(err, "Failed to get current user")
}
n, err := ningen.FromState(s)
if err != nil {
return nil, errors.Wrap(err, "Failed to create a state wrapper")
}
n.Client.OnRequest = append(n.Client.OnRequest, func(r httpdriver.Request) error {
log.Println("[Discord] Request", r.GetPath())
return nil
})
if err := n.Open(); err != nil {
return nil, err
}
return &Instance{
UserID: u.ID,
State: n,
}, nil
}
// StateOnly returns a shallow copy of *State with an already-expired context.
func (s *Instance) StateOnly() *state.State {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return s.WithContext(ctx)
}

View File

@ -0,0 +1,19 @@
package funcutil
// NweCancels creates a stateful closure for adding callbacks into a list.
func NewCancels() func(...func()) []func() {
var cancels []func()
return func(appended ...func()) []func() {
cancels = append(cancels, appended...)
return cancels
}
}
// JoinCancels joins multiple cancel callbacks into one.
func JoinCancels(cancellers []func()) func() {
return func() {
for _, c := range cancellers {
c()
}
}
}

View File

@ -6,7 +6,7 @@ import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/state"
"github.com/diamondburned/cchat-discord/urlutils"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/ningen/md"
"github.com/dustin/go-humanize"

View File

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

View File

@ -7,7 +7,7 @@ import (
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/state"
"github.com/diamondburned/cchat-discord/urlutils"
"github.com/diamondburned/cchat-discord/internal/urlutils"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/ningen"
"github.com/diamondburned/ningen/md"

View File

@ -16,6 +16,10 @@ func AvatarURL(URL string) string {
// Sized wraps the URL with the size query.
func Sized(URL string, size int) string {
if URL == "" {
return ""
}
u, err := url.Parse(URL)
if err != nil {
return URL

View File

@ -1,233 +0,0 @@
package discord
import (
"context"
"log"
"github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/arikawa/session"
"github.com/diamondburned/arikawa/state"
"github.com/diamondburned/arikawa/utils/httputil/httpdriver"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat/services"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/ningen"
"github.com/pkg/errors"
)
func init() {
services.RegisterService(&Service{})
}
// ErrInvalidSession is returned if SessionRestore is given a bad session.
var ErrInvalidSession = errors.New("invalid session")
type Service struct{}
var (
_ cchat.Icon = (*Service)(nil)
_ cchat.Service = (*Service)(nil)
)
func (Service) Name() text.Rich {
return text.Rich{Content: "Discord"}
}
func (Service) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) {
iconer.SetIcon("https://raw.githubusercontent.com/" +
"diamondburned/cchat-discord/himearikawa/discord_logo.png")
return func() {}, nil
}
func (Service) Authenticate() cchat.Authenticator {
return Authenticator{}
}
func (s Service) RestoreSession(data map[string]string) (cchat.Session, error) {
tk, ok := data["token"]
if !ok {
return nil, ErrInvalidSession
}
return NewSessionToken(tk)
}
type Authenticator struct{}
var _ cchat.Authenticator = (*Authenticator)(nil)
func (Authenticator) AuthenticateForm() []cchat.AuthenticateEntry {
// TODO: username, password and 2FA
return []cchat.AuthenticateEntry{
{
Name: "Token",
Secret: true,
},
}
}
func (Authenticator) Authenticate(form []string) (cchat.Session, error) {
return NewSessionToken(form[0])
}
type Session struct {
*ningen.State
userID discord.UserID
}
var (
_ cchat.Icon = (*Session)(nil)
_ cchat.Session = (*Session)(nil)
_ cchat.SessionSaver = (*Session)(nil)
)
func NewSessionToken(token string) (*Session, error) {
s, err := state.New(token)
if err != nil {
return nil, err
}
return NewSession(s)
}
func NewSession(s *state.State) (*Session, error) {
// Prefetch user.
u, err := s.Me()
if err != nil {
return nil, errors.Wrap(err, "Failed to get current user")
}
n, err := ningen.FromState(s)
if err != nil {
return nil, errors.Wrap(err, "Failed to create a state wrapper")
}
n.Client.OnRequest = append(n.Client.OnRequest, func(r httpdriver.Request) error {
log.Println("[Discord] Request", r.GetPath())
return nil
})
if err := n.Open(); err != nil {
return nil, err
}
return &Session{
userID: u.ID,
State: n,
}, nil
}
func (s *Session) ID() cchat.ID {
return s.userID.String()
}
func (s *Session) Name() text.Rich {
u, err := s.Store.Me()
if err != nil {
// This shouldn't happen, ever.
return text.Rich{Content: "<@" + s.userID.String() + ">"}
}
return text.Rich{Content: u.Username + "#" + u.Discriminator}
}
func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) {
u, err := s.Me()
if err != nil {
return nil, errors.Wrap(err, "Failed to get the current user")
}
// Thanks to arikawa, AvatarURL is never empty.
iconer.SetIcon(AvatarURL(u.AvatarURL()))
return s.AddHandler(func(*gateway.UserUpdateEvent) {
// Bypass the event and use the state cache.
if u, err := s.Store.Me(); err == nil {
iconer.SetIcon(AvatarURL(u.AvatarURL()))
}
}), nil
}
func (s *Session) Disconnect() error {
return s.Close()
}
func (s *Session) Save() (map[string]string, error) {
return map[string]string{
"token": s.Token,
}, nil
}
func (s *Session) Servers(container cchat.ServersContainer) error {
// Reset the entire container when the session is closed.
s.AddHandler(func(*session.Closed) {
container.SetServers(nil)
})
// Set the entire container again once reconnected.
s.AddHandler(func(*ningen.Connected) {
s.servers(container)
})
return s.servers(container)
}
func (s *Session) servers(container cchat.ServersContainer) error {
switch {
// If the user has guild folders:
case len(s.Ready.Settings.GuildFolders) > 0:
// TODO: account for missing guilds.
var toplevels = make([]cchat.Server, 0, len(s.Ready.Settings.GuildFolders))
for _, folder := range s.Ready.Settings.GuildFolders {
// TODO: correct.
switch {
case folder.ID > 0:
fallthrough
case len(folder.GuildIDs) > 1:
toplevels = append(toplevels, NewGuildFolder(s, folder))
case len(folder.GuildIDs) == 1:
g, err := NewGuildFromID(s, folder.GuildIDs[0])
if err != nil {
continue
}
toplevels = append(toplevels, g)
}
}
container.SetServers(toplevels)
// If the user doesn't have guild folders but has sorted their guilds
// before:
case len(s.Ready.Settings.GuildPositions) > 0:
var guilds = make([]cchat.Server, 0, len(s.Ready.Settings.GuildPositions))
for _, id := range s.Ready.Settings.GuildPositions {
g, err := NewGuildFromID(s, id)
if err != nil {
continue
}
guilds = append(guilds, g)
}
container.SetServers(guilds)
// None of the above:
default:
g, err := s.Guilds()
if err != nil {
return err
}
var servers = make([]cchat.Server, len(g))
for i := range g {
servers[i] = NewGuild(s, &g[i])
}
container.SetServers(servers)
}
return nil
}