From 0f1cdafec6dcd4fc17f5767bed7973a02355bc18 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Mon, 7 Sep 2020 21:44:09 -0700 Subject: [PATCH] Refactored to cchat v0.1.3 --- category.go | 72 --- channel.go | 509 ------------------ channel_memberlist.go | 345 ------------ channel_send.go | 76 --- discord.go | 79 +++ go.mod | 2 +- go.sum | 8 + guild.go | 189 ------- internal/discord/category/category.go | 123 +++++ internal/discord/channel/actioner.go | 97 ++++ internal/discord/channel/backlogger.go | 52 ++ internal/discord/channel/channel.go | 65 +++ .../discord/channel/completer.go | 66 ++- internal/discord/channel/editor.go | 61 +++ internal/discord/channel/indicators.go | 70 +++ internal/discord/channel/memberlist/member.go | 164 ++++++ .../discord/channel/memberlist/memberlist.go | 36 ++ .../discord/channel/memberlist/section.go | 91 ++++ internal/discord/channel/memberlister.go | 111 ++++ internal/discord/channel/messenger.go | 125 +++++ internal/discord/channel/nicknamer.go | 73 +++ internal/discord/channel/sender.go | 62 +++ .../discord/channel/typer/typer.go | 16 +- internal/discord/folder/folder.go | 82 +++ internal/discord/guild/guild.go | 131 +++++ internal/discord/message/author.go | 90 ++++ .../discord/message/message.go | 105 +--- internal/discord/session/session.go | 155 ++++++ internal/discord/state/state.go | 62 +++ internal/funcutil/funcutil.go | 19 + {segments => internal/segments}/blockquote.go | 0 {segments => internal/segments}/codeblock.go | 0 {segments => internal/segments}/colored.go | 0 {segments => internal/segments}/embed.go | 2 +- {segments => internal/segments}/emoji.go | 0 .../segments}/inline_attr.go | 0 .../segments}/inline_attr.jpg | Bin {segments => internal/segments}/link.go | 0 {segments => internal/segments}/md.go | 0 {segments => internal/segments}/md_test.go | 0 {segments => internal/segments}/mention.go | 2 +- {urlutils => internal/urlutils}/urlutils.go | 4 + service.go | 233 -------- 43 files changed, 1834 insertions(+), 1543 deletions(-) delete mode 100644 category.go delete mode 100644 channel.go delete mode 100644 channel_memberlist.go delete mode 100644 channel_send.go create mode 100644 discord.go delete mode 100644 guild.go create mode 100644 internal/discord/category/category.go create mode 100644 internal/discord/channel/actioner.go create mode 100644 internal/discord/channel/backlogger.go create mode 100644 internal/discord/channel/channel.go rename channel_completion.go => internal/discord/channel/completer.go (66%) create mode 100644 internal/discord/channel/editor.go create mode 100644 internal/discord/channel/indicators.go create mode 100644 internal/discord/channel/memberlist/member.go create mode 100644 internal/discord/channel/memberlist/memberlist.go create mode 100644 internal/discord/channel/memberlist/section.go create mode 100644 internal/discord/channel/memberlister.go create mode 100644 internal/discord/channel/messenger.go create mode 100644 internal/discord/channel/nicknamer.go create mode 100644 internal/discord/channel/sender.go rename typer.go => internal/discord/channel/typer/typer.go (68%) create mode 100644 internal/discord/folder/folder.go create mode 100644 internal/discord/guild/guild.go create mode 100644 internal/discord/message/author.go rename message.go => internal/discord/message/message.go (62%) create mode 100644 internal/discord/session/session.go create mode 100644 internal/discord/state/state.go create mode 100644 internal/funcutil/funcutil.go rename {segments => internal/segments}/blockquote.go (100%) rename {segments => internal/segments}/codeblock.go (100%) rename {segments => internal/segments}/colored.go (100%) rename {segments => internal/segments}/embed.go (98%) rename {segments => internal/segments}/emoji.go (100%) rename {segments => internal/segments}/inline_attr.go (100%) rename {segments => internal/segments}/inline_attr.jpg (100%) rename {segments => internal/segments}/link.go (100%) rename {segments => internal/segments}/md.go (100%) rename {segments => internal/segments}/md_test.go (100%) rename {segments => internal/segments}/mention.go (99%) rename {urlutils => internal/urlutils}/urlutils.go (97%) delete mode 100644 service.go diff --git a/category.go b/category.go deleted file mode 100644 index d0265f8..0000000 --- a/category.go +++ /dev/null @@ -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 -} diff --git a/channel.go b/channel.go deleted file mode 100644 index 079ad6e..0000000 --- a/channel.go +++ /dev/null @@ -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() - } - } -} diff --git a/channel_memberlist.go b/channel_memberlist.go deleted file mode 100644 index 50312a6..0000000 --- a/channel_memberlist.go +++ /dev/null @@ -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 -} diff --git a/channel_send.go b/channel_send.go deleted file mode 100644 index 03f8202..0000000 --- a/channel_send.go +++ /dev/null @@ -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 -} diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..4ccb2a3 --- /dev/null +++ b/discord.go @@ -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") +} diff --git a/go.mod b/go.mod index 253795e..feb18db 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f37d357..7fb7fa8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/guild.go b/guild.go deleted file mode 100644 index 6b3fb30..0000000 --- a/guild.go +++ /dev/null @@ -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 -} diff --git a/internal/discord/category/category.go b/internal/discord/category/category.go new file mode 100644 index 0000000..b475373 --- /dev/null +++ b/internal/discord/category/category.go @@ -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 +} diff --git a/internal/discord/channel/actioner.go b/internal/discord/channel/actioner.go new file mode 100644 index 0000000..7d82a58 --- /dev/null +++ b/internal/discord/channel/actioner.go @@ -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) +} diff --git a/internal/discord/channel/backlogger.go b/internal/discord/channel/backlogger.go new file mode 100644 index 0000000..d95e2e3 --- /dev/null +++ b/internal/discord/channel/backlogger.go @@ -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 +} diff --git a/internal/discord/channel/channel.go b/internal/discord/channel/channel.go new file mode 100644 index 0000000..d261c29 --- /dev/null +++ b/internal/discord/channel/channel.go @@ -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} + } +} diff --git a/channel_completion.go b/internal/discord/channel/completer.go similarity index 66% rename from channel_completion.go rename to internal/discord/channel/completer.go index 84e86f4..2e97098 100644 --- a/channel_completion.go +++ b/internal/discord/channel/completer.go @@ -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 } diff --git a/internal/discord/channel/editor.go b/internal/discord/channel/editor.go new file mode 100644 index 0000000..e4e9832 --- /dev/null +++ b/internal/discord/channel/editor.go @@ -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 +} diff --git a/internal/discord/channel/indicators.go b/internal/discord/channel/indicators.go new file mode 100644 index 0000000..8202678 --- /dev/null +++ b/internal/discord/channel/indicators.go @@ -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 +} diff --git a/internal/discord/channel/memberlist/member.go b/internal/discord/channel/memberlist/member.go new file mode 100644 index 0000000..115ced8 --- /dev/null +++ b/internal/discord/channel/memberlist/member.go @@ -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{} + } +} diff --git a/internal/discord/channel/memberlist/memberlist.go b/internal/discord/channel/memberlist/memberlist.go new file mode 100644 index 0000000..2f5c318 --- /dev/null +++ b/internal/discord/channel/memberlist/memberlist.go @@ -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) + }) +} diff --git a/internal/discord/channel/memberlist/section.go b/internal/discord/channel/memberlist/section.go new file mode 100644 index 0000000..5bffd84 --- /dev/null +++ b/internal/discord/channel/memberlist/section.go @@ -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 +} diff --git a/internal/discord/channel/memberlister.go b/internal/discord/channel/memberlister.go new file mode 100644 index 0000000..63fc38e --- /dev/null +++ b/internal/discord/channel/memberlister.go @@ -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)) + } + } + }) +} diff --git a/internal/discord/channel/messenger.go b/internal/discord/channel/messenger.go new file mode 100644 index 0000000..c759891 --- /dev/null +++ b/internal/discord/channel/messenger.go @@ -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 +} diff --git a/internal/discord/channel/nicknamer.go b/internal/discord/channel/nicknamer.go new file mode 100644 index 0000000..89aa29b --- /dev/null +++ b/internal/discord/channel/nicknamer.go @@ -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 +} diff --git a/internal/discord/channel/sender.go b/internal/discord/channel/sender.go new file mode 100644 index 0000000..1f5642e --- /dev/null +++ b/internal/discord/channel/sender.go @@ -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 +} diff --git a/typer.go b/internal/discord/channel/typer/typer.go similarity index 68% rename from typer.go rename to internal/discord/channel/typer/typer.go index 072f735..97e0021 100644 --- a/typer.go +++ b/internal/discord/channel/typer/typer.go @@ -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 } diff --git a/internal/discord/folder/folder.go b/internal/discord/folder/folder.go new file mode 100644 index 0000000..23d250e --- /dev/null +++ b/internal/discord/folder/folder.go @@ -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 +} diff --git a/internal/discord/guild/guild.go b/internal/discord/guild/guild.go new file mode 100644 index 0000000..b4ec334 --- /dev/null +++ b/internal/discord/guild/guild.go @@ -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 +} diff --git a/internal/discord/message/author.go b/internal/discord/message/author.go new file mode 100644 index 0000000..47b803d --- /dev/null +++ b/internal/discord/message/author.go @@ -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 +} diff --git a/message.go b/internal/discord/message/message.go similarity index 62% rename from message.go rename to internal/discord/message/message.go index 1c3e27d..944a965 100644 --- a/message.go +++ b/internal/discord/message/message.go @@ -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 } diff --git a/internal/discord/session/session.go b/internal/discord/session/session.go new file mode 100644 index 0000000..373ce0e --- /dev/null +++ b/internal/discord/session/session.go @@ -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 +} diff --git a/internal/discord/state/state.go b/internal/discord/state/state.go new file mode 100644 index 0000000..d283426 --- /dev/null +++ b/internal/discord/state/state.go @@ -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) +} diff --git a/internal/funcutil/funcutil.go b/internal/funcutil/funcutil.go new file mode 100644 index 0000000..2a0873d --- /dev/null +++ b/internal/funcutil/funcutil.go @@ -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() + } + } +} diff --git a/segments/blockquote.go b/internal/segments/blockquote.go similarity index 100% rename from segments/blockquote.go rename to internal/segments/blockquote.go diff --git a/segments/codeblock.go b/internal/segments/codeblock.go similarity index 100% rename from segments/codeblock.go rename to internal/segments/codeblock.go diff --git a/segments/colored.go b/internal/segments/colored.go similarity index 100% rename from segments/colored.go rename to internal/segments/colored.go diff --git a/segments/embed.go b/internal/segments/embed.go similarity index 98% rename from segments/embed.go rename to internal/segments/embed.go index 4b8c4a2..70ea1ac 100644 --- a/segments/embed.go +++ b/internal/segments/embed.go @@ -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" diff --git a/segments/emoji.go b/internal/segments/emoji.go similarity index 100% rename from segments/emoji.go rename to internal/segments/emoji.go diff --git a/segments/inline_attr.go b/internal/segments/inline_attr.go similarity index 100% rename from segments/inline_attr.go rename to internal/segments/inline_attr.go diff --git a/segments/inline_attr.jpg b/internal/segments/inline_attr.jpg similarity index 100% rename from segments/inline_attr.jpg rename to internal/segments/inline_attr.jpg diff --git a/segments/link.go b/internal/segments/link.go similarity index 100% rename from segments/link.go rename to internal/segments/link.go diff --git a/segments/md.go b/internal/segments/md.go similarity index 100% rename from segments/md.go rename to internal/segments/md.go diff --git a/segments/md_test.go b/internal/segments/md_test.go similarity index 100% rename from segments/md_test.go rename to internal/segments/md_test.go diff --git a/segments/mention.go b/internal/segments/mention.go similarity index 99% rename from segments/mention.go rename to internal/segments/mention.go index c5a2d71..5b7a1a7 100644 --- a/segments/mention.go +++ b/internal/segments/mention.go @@ -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" diff --git a/urlutils/urlutils.go b/internal/urlutils/urlutils.go similarity index 97% rename from urlutils/urlutils.go rename to internal/urlutils/urlutils.go index f55d099..c572dd3 100644 --- a/urlutils/urlutils.go +++ b/internal/urlutils/urlutils.go @@ -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 diff --git a/service.go b/service.go deleted file mode 100644 index 2182f00..0000000 --- a/service.go +++ /dev/null @@ -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 -}