From 1155ccac3402ede70edc73ede1e46cef03f1a5e5 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Thu, 17 Dec 2020 00:01:58 -0800 Subject: [PATCH] Added WIP private messages, removed nonce --- go.mod | 2 +- go.sum | 2 + internal/discord/channel/channel.go | 17 +- internal/discord/channel/commander.go | 4 +- internal/discord/channel/commands/command.go | 2 +- internal/discord/channel/commands/commands.go | 12 +- .../channel/message/action/actioner.go | 4 +- .../channel/message/backlog/backlogger.go | 4 +- .../discord/channel/message/edit/editor.go | 4 +- .../channel/message/indicate/typing.go | 4 +- .../channel/message/indicate/unread.go | 4 +- .../channel/message/memberlist/member.go | 4 +- .../channel/message/memberlist/memberlist.go | 4 +- .../channel/message/memberlist/section.go | 4 +- internal/discord/channel/message/message.go | 6 +- .../channel/message/nickname/nicknamer.go | 4 +- .../message/send/complete/completer.go | 4 +- .../channel/message/send/complete/emoji.go | 40 +++-- .../discord/channel/message/send/sender.go | 14 +- internal/discord/channel/private.go | 64 ++++++++ internal/discord/channel/shared/channel.go | 5 + internal/discord/message/message.go | 11 +- internal/discord/private/hub/messages.go | 149 ++++++++++++++++++ internal/discord/private/hub/sender.go | 75 +++++++++ internal/discord/private/hub/server.go | 103 ++++++++++++ internal/discord/private/private.go | 127 +++++++++++++++ internal/discord/session/session.go | 65 +++++--- internal/funcutil/funcutil.go | 2 +- internal/segments/renderer/renderer.go | 2 - 29 files changed, 649 insertions(+), 93 deletions(-) create mode 100644 internal/discord/channel/private.go create mode 100644 internal/discord/private/hub/messages.go create mode 100644 internal/discord/private/hub/sender.go create mode 100644 internal/discord/private/hub/server.go create mode 100644 internal/discord/private/private.go diff --git a/go.mod b/go.mod index 4581f68..9d5958c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.14 require ( github.com/diamondburned/arikawa v1.3.6 - github.com/diamondburned/cchat v0.3.11 + github.com/diamondburned/cchat v0.3.12 github.com/diamondburned/ningen v0.2.1-0.20201023061015-ce64ffb0bb12 github.com/dustin/go-humanize v1.0.0 github.com/go-test/deep v1.0.7 diff --git a/go.sum b/go.sum index aa0011c..59c837e 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/diamondburned/cchat v0.3.8 h1:vgFe8giVfwsAO+WpTYsTDIXvRUN48osVPNu0pZN github.com/diamondburned/cchat v0.3.8/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= github.com/diamondburned/cchat v0.3.11 h1:C1f9Tp7Kz3t+T1SlepL1RS7b/kACAKWAIZXAgJEpCHg= github.com/diamondburned/cchat v0.3.11/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.12 h1:mew54lsDrwrJs4U2FtdbNFl/wAZcueIgZCsImHQzVL4= +github.com/diamondburned/cchat v0.3.12/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= 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/internal/discord/channel/channel.go b/internal/discord/channel/channel.go index 033a38d..e9b1c73 100644 --- a/internal/discord/channel/channel.go +++ b/internal/discord/channel/channel.go @@ -13,21 +13,28 @@ import ( type Channel struct { empty.Server - - *shared.Channel + shared.Channel commander cchat.Commander } var _ cchat.Server = (*Channel)(nil) func New(s *state.Instance, ch discord.Channel) (cchat.Server, error) { + channel, err := NewChannel(s, ch) + if err != nil { + return nil, err + } + return channel, nil +} + +func NewChannel(s *state.Instance, ch discord.Channel) (Channel, 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{}, errors.Wrap(err, "Failed to get permission") } - sharedCh := &shared.Channel{ + sharedCh := shared.Channel{ ID: ch.ID, GuildID: ch.GuildID, State: s, @@ -50,7 +57,7 @@ func (ch Channel) Name() text.Rich { } if c.NSFW { - return text.Rich{Content: "#!" + c.Name} + return text.Rich{Content: "#" + c.Name + " (nsfw)"} } else { return text.Rich{Content: "#" + c.Name} } diff --git a/internal/discord/channel/commander.go b/internal/discord/channel/commander.go index 1d94c6d..e9cae45 100644 --- a/internal/discord/channel/commander.go +++ b/internal/discord/channel/commander.go @@ -11,11 +11,11 @@ import ( ) type Commander struct { - *shared.Channel + shared.Channel msgCompl complete.Completer } -func NewCommander(ch *shared.Channel) cchat.Commander { +func NewCommander(ch shared.Channel) cchat.Commander { return Commander{ Channel: ch, msgCompl: complete.Completer{ diff --git a/internal/discord/channel/commands/command.go b/internal/discord/channel/commands/command.go index 8c8304c..295b844 100644 --- a/internal/discord/channel/commands/command.go +++ b/internal/discord/channel/commands/command.go @@ -10,7 +10,7 @@ type Command struct { Name string Args Arguments Desc string - RunFunc func(*shared.Channel, []string) ([]byte, error) // words[1:] + RunFunc func(shared.Channel, []string) ([]byte, error) // words[1:] } func (cmd Command) writeHelp(builder *bytes.Buffer) { diff --git a/internal/discord/channel/commands/commands.go b/internal/discord/channel/commands/commands.go index a1f1ecc..6ff9cbd 100644 --- a/internal/discord/channel/commands/commands.go +++ b/internal/discord/channel/commands/commands.go @@ -29,7 +29,7 @@ func (cmds Commands) Help() []byte { // Run runs a command with the given words. It errors out if the command is not // found. -func (cmds Commands) Run(ch *shared.Channel, words []string) ([]byte, error) { +func (cmds Commands) Run(ch shared.Channel, words []string) ([]byte, error) { if words[0] == "help" { return cmds.Help(), nil } @@ -80,7 +80,7 @@ var World = Commands{ Name: "send-embed", Args: Arguments{"-t title", "-c color", "description"}, Desc: "Send a basic embed to the current channel", - RunFunc: func(ch *shared.Channel, argv []string) ([]byte, error) { + RunFunc: func(ch shared.Channel, argv []string) ([]byte, error) { var embed discord.Embed var color uint // no Uint32Var @@ -107,7 +107,7 @@ var World = Commands{ { Name: "info", Desc: "Print information as JSON", - RunFunc: func(ch *shared.Channel, argv []string) ([]byte, error) { + RunFunc: func(ch shared.Channel, argv []string) ([]byte, error) { channel, err := ch.State.Channel(ch.ID) if err != nil { return nil, errors.Wrap(err, "failed to get channel") @@ -124,7 +124,7 @@ var World = Commands{ { Name: "list-channels", Desc: "Print all channels of this guild and their topics", - RunFunc: func(ch *shared.Channel, argv []string) ([]byte, error) { + RunFunc: func(ch shared.Channel, argv []string) ([]byte, error) { channels, err := ch.State.Channels(ch.GuildID) if err != nil { return nil, errors.Wrap(err, "failed to get channels") @@ -142,7 +142,7 @@ var World = Commands{ Name: "presence", Args: Arguments{"mention:user"}, Desc: "Print JSON of a member/user's presence state", - RunFunc: func(ch *shared.Channel, argv []string) ([]byte, error) { + RunFunc: func(ch shared.Channel, argv []string) ([]byte, error) { if err := assertArgc(argv, 1); err != nil { return nil, err } @@ -164,7 +164,7 @@ var World = Commands{ Name: "member", Args: Arguments{"mention:user"}, Desc: "Print JSON of a member/user's member state", - RunFunc: func(ch *shared.Channel, argv []string) ([]byte, error) { + RunFunc: func(ch shared.Channel, argv []string) ([]byte, error) { if err := assertArgc(argv, 1); err != nil { return nil, err } diff --git a/internal/discord/channel/message/action/actioner.go b/internal/discord/channel/message/action/actioner.go index bdef208..b8a2be9 100644 --- a/internal/discord/channel/message/action/actioner.go +++ b/internal/discord/channel/message/action/actioner.go @@ -8,12 +8,12 @@ import ( ) type Actioner struct { - *shared.Channel + shared.Channel } var _ cchat.Actioner = (*Actioner)(nil) -func New(ch *shared.Channel) Actioner { +func New(ch shared.Channel) Actioner { return Actioner{ch} } diff --git a/internal/discord/channel/message/backlog/backlogger.go b/internal/discord/channel/message/backlog/backlogger.go index 2a3dea2..96225a2 100644 --- a/internal/discord/channel/message/backlog/backlogger.go +++ b/internal/discord/channel/message/backlog/backlogger.go @@ -11,10 +11,10 @@ import ( ) type Backlogger struct { - *shared.Channel + shared.Channel } -func New(ch *shared.Channel) cchat.Backlogger { +func New(ch shared.Channel) cchat.Backlogger { return Backlogger{ch} } diff --git a/internal/discord/channel/message/edit/editor.go b/internal/discord/channel/message/edit/editor.go index 34a87e5..d12e60c 100644 --- a/internal/discord/channel/message/edit/editor.go +++ b/internal/discord/channel/message/edit/editor.go @@ -8,10 +8,10 @@ import ( ) type Editor struct { - *shared.Channel + shared.Channel } -func New(ch *shared.Channel) cchat.Editor { +func New(ch shared.Channel) cchat.Editor { return Editor{ch} } diff --git a/internal/discord/channel/message/indicate/typing.go b/internal/discord/channel/message/indicate/typing.go index 45bd06d..e293799 100644 --- a/internal/discord/channel/message/indicate/typing.go +++ b/internal/discord/channel/message/indicate/typing.go @@ -10,10 +10,10 @@ import ( ) type TypingIndicator struct { - *shared.Channel + shared.Channel } -func NewTyping(ch *shared.Channel) cchat.TypingIndicator { +func NewTyping(ch shared.Channel) cchat.TypingIndicator { return TypingIndicator{ch} } diff --git a/internal/discord/channel/message/indicate/unread.go b/internal/discord/channel/message/indicate/unread.go index d2dceec..745f353 100644 --- a/internal/discord/channel/message/indicate/unread.go +++ b/internal/discord/channel/message/indicate/unread.go @@ -8,10 +8,10 @@ import ( ) type UnreadIndicator struct { - *shared.Channel + shared.Channel } -func NewUnread(ch *shared.Channel) cchat.UnreadIndicator { +func NewUnread(ch shared.Channel) cchat.UnreadIndicator { return UnreadIndicator{ch} } diff --git a/internal/discord/channel/message/memberlist/member.go b/internal/discord/channel/message/memberlist/member.go index 539e5cb..361479a 100644 --- a/internal/discord/channel/message/memberlist/member.go +++ b/internal/discord/channel/message/memberlist/member.go @@ -17,13 +17,13 @@ import ( ) type Member struct { - channel *shared.Channel + channel shared.Channel userID discord.UserID origName string // use if cache is stale } // New creates a new list member. it.Member must not be nil. -func NewMember(ch *shared.Channel, opItem gateway.GuildMemberListOpItem) cchat.ListMember { +func NewMember(ch shared.Channel, opItem gateway.GuildMemberListOpItem) cchat.ListMember { return &Member{ channel: ch, userID: opItem.Member.User.ID, diff --git a/internal/discord/channel/message/memberlist/memberlist.go b/internal/discord/channel/message/memberlist/memberlist.go index 96a4073..cae13b3 100644 --- a/internal/discord/channel/message/memberlist/memberlist.go +++ b/internal/discord/channel/message/memberlist/memberlist.go @@ -31,10 +31,10 @@ func seekPrevGroup(l *member.List, ix int) (item, group gateway.GuildMemberListO } type MemberLister struct { - *shared.Channel + shared.Channel } -func New(ch *shared.Channel) cchat.MemberLister { +func New(ch shared.Channel) cchat.MemberLister { return MemberLister{ch} } diff --git a/internal/discord/channel/message/memberlist/section.go b/internal/discord/channel/message/memberlist/section.go index 419a17b..0dc144b 100644 --- a/internal/discord/channel/message/memberlist/section.go +++ b/internal/discord/channel/message/memberlist/section.go @@ -23,7 +23,7 @@ type Section struct { } func NewSection( - ch *shared.Channel, + ch shared.Channel, listID string, group gateway.GuildMemberListGroup) cchat.MemberSection { @@ -78,7 +78,7 @@ func (s Section) AsMemberDynamicSection() cchat.MemberDynamicSection { func (s Section) IsMemberDynamicSection() bool { return true } type DynamicSection struct { - *shared.Channel + shared.Channel } var _ cchat.MemberDynamicSection = (*DynamicSection)(nil) diff --git a/internal/discord/channel/message/message.go b/internal/discord/channel/message/message.go index 0b4d009..ad379a2 100644 --- a/internal/discord/channel/message/message.go +++ b/internal/discord/channel/message/message.go @@ -23,12 +23,12 @@ import ( type Messenger struct { empty.Messenger - *shared.Channel + shared.Channel } var _ cchat.Messenger = (*Messenger)(nil) -func New(ch *shared.Channel) Messenger { +func New(ch shared.Channel) Messenger { return Messenger{Channel: ch} } @@ -129,7 +129,7 @@ func (msgr Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer }), ) - return funcutil.JoinCancels(addcancel()), nil + return funcutil.JoinCancels(addcancel()...), nil } func (msgr Messenger) AsSender() cchat.Sender { diff --git a/internal/discord/channel/message/nickname/nicknamer.go b/internal/discord/channel/message/nickname/nicknamer.go index dd78374..9c2acba 100644 --- a/internal/discord/channel/message/nickname/nicknamer.go +++ b/internal/discord/channel/message/nickname/nicknamer.go @@ -12,10 +12,10 @@ import ( ) type Nicknamer struct { - *shared.Channel + shared.Channel } -func New(ch *shared.Channel) cchat.Nicknamer { +func New(ch shared.Channel) cchat.Nicknamer { return Nicknamer{ch} } diff --git a/internal/discord/channel/message/send/complete/completer.go b/internal/discord/channel/message/send/complete/completer.go index 1c127d3..0d83d3a 100644 --- a/internal/discord/channel/message/send/complete/completer.go +++ b/internal/discord/channel/message/send/complete/completer.go @@ -8,12 +8,12 @@ import ( ) type Completer struct { - *shared.Channel + shared.Channel } const MaxCompletion = 15 -func New(ch *shared.Channel) cchat.Completer { +func New(ch shared.Channel) cchat.Completer { return Completer{ch} } diff --git a/internal/discord/channel/message/send/complete/emoji.go b/internal/discord/channel/message/send/complete/emoji.go index 76bcf79..936910f 100644 --- a/internal/discord/channel/message/send/complete/emoji.go +++ b/internal/discord/channel/message/send/complete/emoji.go @@ -3,40 +3,50 @@ package complete import ( "strings" + "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" ) func (ch Completer) CompleteEmojis(word string) (entries []cchat.CompletionEntry) { + return CompleteEmojis(ch.State, ch.GuildID, word) +} + +func CompleteEmojis(s *state.Instance, gID discord.GuildID, word string) []cchat.CompletionEntry { // Ignore if empty word. if word == "" { - return + return nil } - e, err := ch.State.EmojiState.Get(ch.GuildID) + e, err := s.EmojiState.Get(gID) if err != nil { - return + return nil } var match = strings.ToLower(word) + var entries = make([]cchat.CompletionEntry, 0, MaxCompletion) for _, guild := range e { for _, emoji := range guild.Emojis { - if contains(match, emoji.Name) { - entries = append(entries, cchat.CompletionEntry{ - Raw: emoji.String(), - Text: text.Rich{Content: ":" + emoji.Name + ":"}, - Secondary: text.Rich{Content: guild.Name}, - IconURL: urlutils.Sized(emoji.EmojiURL(), 32), // small - Image: true, - }) - if len(entries) >= MaxCompletion { - return - } + if !contains(match, emoji.Name) { + continue + } + + entries = append(entries, cchat.CompletionEntry{ + Raw: emoji.String(), + Text: text.Rich{Content: ":" + emoji.Name + ":"}, + Secondary: text.Rich{Content: guild.Name}, + IconURL: urlutils.Sized(emoji.EmojiURL(), 32), // small + Image: true, + }) + + if len(entries) >= MaxCompletion { + return entries } } } - return + return entries } diff --git a/internal/discord/channel/message/send/sender.go b/internal/discord/channel/message/send/sender.go index 6a66048..9c152e7 100644 --- a/internal/discord/channel/message/send/sender.go +++ b/internal/discord/channel/message/send/sender.go @@ -6,28 +6,30 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/internal/discord/channel/message/send/complete" "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/diamondburned/cchat-discord/internal/discord/state" ) type Sender struct { - *shared.Channel + shared.Channel } var _ cchat.Sender = (*Sender)(nil) -func New(ch *shared.Channel) Sender { +func New(ch shared.Channel) Sender { return Sender{ch} } func (s Sender) Send(msg cchat.SendableMessage) error { + return Send(s.State, s.ID, msg) +} + +func Send(s *state.Instance, chID discord.ChannelID, msg cchat.SendableMessage) error { var send = api.SendMessageData{Content: msg.Content()} - if noncer := msg.AsNoncer(); noncer != nil { - send.Nonce = noncer.Nonce() - } if attacher := msg.AsAttachments(); attacher != nil { send.Files = addAttachments(attacher.Attachments()) } - _, err := s.State.SendMessageComplex(s.ID, send) + _, err := s.SendMessageComplex(chID, send) return err } diff --git a/internal/discord/channel/private.go b/internal/discord/channel/private.go new file mode 100644 index 0000000..9c53476 --- /dev/null +++ b/internal/discord/channel/private.go @@ -0,0 +1,64 @@ +package channel + +import ( + "context" + + "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/urlutils" + "github.com/pkg/errors" +) + +type Private struct { + Channel +} + +var _ cchat.Server = (*Private)(nil) + +func NewPrivate(s *state.Instance, ch discord.Channel) (cchat.Server, error) { + if ch.GuildID.IsValid() { + return nil, errors.New("channel has valid guild ID: not a DM") + } + + channel, err := NewChannel(s, ch) + if err != nil { + return nil, err + } + + return Private{Channel: channel}, nil +} + +func (priv Private) AsIconer() cchat.Iconer { + return NewAvatarIcon(priv.State) +} + +type AvatarIcon struct { + State *state.Instance +} + +func NewAvatarIcon(state *state.Instance) cchat.Iconer { + return AvatarIcon{state} +} + +func (avy AvatarIcon) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { + u, err := avy.State.WithContext(ctx).Me() + if err != nil { + // This shouldn't happen. + return nil, errors.Wrap(err, "Failed to get guild") + } + + // Used for comparison. + if u.Avatar != "" { + iconer.SetIcon(urlutils.AvatarURL(u.AvatarURL())) + } + + selfID := u.ID + + return avy.State.AddHandler(func(update *gateway.UserUpdateEvent) { + if selfID == update.ID { + iconer.SetIcon(urlutils.AvatarURL(update.AvatarURL())) + } + }), nil +} diff --git a/internal/discord/channel/shared/channel.go b/internal/discord/channel/shared/channel.go index 2580256..dd58f9c 100644 --- a/internal/discord/channel/shared/channel.go +++ b/internal/discord/channel/shared/channel.go @@ -1,6 +1,8 @@ package shared import ( + "errors" + "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat-discord/internal/discord/state" ) @@ -33,6 +35,9 @@ func (ch Channel) Messages() ([]discord.Message, error) { } func (ch Channel) Guild() (*discord.Guild, error) { + if !ch.GuildID.IsValid() { + return nil, errors.New("channel not in guild") + } return ch.State.Store.Guild(ch.GuildID) } diff --git a/internal/discord/message/message.go b/internal/discord/message/message.go index a65179e..d289b03 100644 --- a/internal/discord/message/message.go +++ b/internal/discord/message/message.go @@ -17,7 +17,6 @@ type messageHeader struct { time discord.Timestamp channelID discord.ChannelID guildID discord.GuildID - nonce string } var _ cchat.MessageHeader = (*messageHeader)(nil) @@ -28,7 +27,6 @@ func newHeader(msg discord.Message) messageHeader { time: msg.Timestamp, channelID: msg.ChannelID, guildID: msg.GuildID, - nonce: msg.Nonce, } if msg.EditedTimestamp.IsValid() { h.time = msg.EditedTimestamp @@ -49,6 +47,10 @@ func (m messageHeader) ID() cchat.ID { return m.id.String() } +func (m messageHeader) MessageID() discord.MessageID { return m.id } +func (m messageHeader) ChannelID() discord.ChannelID { return m.channelID } +func (m messageHeader) GuildID() discord.GuildID { return m.guildID } + func (m messageHeader) Time() time.Time { return m.time.Time() } @@ -67,7 +69,6 @@ var ( _ cchat.MessageCreate = (*Message)(nil) _ cchat.MessageUpdate = (*Message)(nil) _ cchat.MessageDelete = (*Message)(nil) - _ cchat.Noncer = (*Message)(nil) ) func NewMessageUpdateContent(msg discord.Message, s *state.Instance) Message { @@ -183,10 +184,6 @@ func (m Message) Content() text.Rich { return m.content } -func (m Message) Nonce() string { - return m.nonce -} - func (m Message) Mentioned() bool { return m.mentioned } diff --git a/internal/discord/private/hub/messages.go b/internal/discord/private/hub/messages.go new file mode 100644 index 0000000..18bba20 --- /dev/null +++ b/internal/discord/private/hub/messages.go @@ -0,0 +1,149 @@ +package hub + +import ( + "context" + "sync" + + "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/discord/state" + "github.com/diamondburned/cchat-discord/internal/funcutil" + "github.com/diamondburned/cchat/utils/empty" +) + +const maxMessages = 100 + +type messageList []discord.Message + +func (list messageList) idx(id discord.MessageID) int { + for i, msg := range list { + if msg.ID == id { + return i + } + } + return -1 +} + +func (list *messageList) append(msg discord.Message) { + *list = append(*list, msg) + + // cap the length + if len(*list) > maxMessages { + copy(*list, (*list)[1:]) // shift left once + (*list)[len(*list)-1] = discord.Message{} // nil out last to not memory leak + *list = (*list)[:len(*list)-1] // slice it away + } +} + +func (list *messageList) swap(newMsg discord.Message) { + if idx := list.idx(newMsg.ID); idx > -1 { + (*list)[idx] = newMsg + } +} + +func (list *messageList) delete(id discord.MessageID) { + if idx := list.idx(id); idx > -1 { + *list = append((*list)[:idx], (*list)[idx+1:]...) + } +} + +type Messages struct { + empty.Messenger + + state *state.Instance + acList *activeList + + sender *Sender + + msgMutex sync.Mutex + messages messageList + + cancel func() +} + +func NewMessages(s *state.Instance, acList *activeList, adder ChannelAdder) *Messages { + hubServer := &Messages{ + state: s, + acList: acList, + sender: NewSender(s, acList, adder), + messages: make(messageList, 0, 100), + } + + hubServer.cancel = funcutil.JoinCancels( + s.AddHandler(func(msg *gateway.MessageCreateEvent) { + if msg.GuildID.IsValid() || acList.isActive(msg.ChannelID) { + return + } + + hubServer.msgMutex.Lock() + hubServer.messages.append(msg.Message) + hubServer.msgMutex.Unlock() + }), + s.AddHandler(func(update *gateway.MessageUpdateEvent) { + if update.GuildID.IsValid() || acList.isActive(update.ChannelID) { + return + } + + // The event itself is unreliable, so we must rely on the state. + m, err := hubServer.state.Message(update.ChannelID, update.ID) + if err != nil { + return + } + + hubServer.msgMutex.Lock() + hubServer.messages.swap(*m) + hubServer.msgMutex.Unlock() + }), + s.AddHandler(func(del *gateway.MessageDeleteEvent) { + if del.GuildID.IsValid() || acList.isActive(del.ChannelID) { + return + } + + hubServer.msgMutex.Lock() + hubServer.messages.delete(del.ID) + hubServer.msgMutex.Unlock() + }), + ) + + return hubServer +} + +func (msgs *Messages) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { + msgs.msgMutex.Lock() + + for _, msg := range msgs.messages { + ct.CreateMessage(message.NewDirectMessage(msg, msgs.state)) + } + + msgs.msgMutex.Unlock() + + // Bind the handler. + return funcutil.JoinCancels( + msgs.state.AddHandler(func(msg *gateway.MessageCreateEvent) { + if msg.GuildID.IsValid() || msgs.acList.isActive(msg.ChannelID) { + return + } + + ct.CreateMessage(message.NewMessageCreate(msg, msgs.state)) + msgs.state.ReadState.MarkRead(msg.ChannelID, msg.ID) + }), + msgs.state.AddHandler(func(update *gateway.MessageUpdateEvent) { + if update.GuildID.IsValid() || msgs.acList.isActive(update.ChannelID) { + return + } + + ct.UpdateMessage(message.NewMessageUpdateContent(update.Message, msgs.state)) + }), + msgs.state.AddHandler(func(del *gateway.MessageDeleteEvent) { + if del.GuildID.IsValid() || msgs.acList.isActive(del.ChannelID) { + return + } + + ct.DeleteMessage(message.NewHeaderDelete(del)) + }), + ), nil +} + +func (msgs *Messages) AsSender() cchat.Sender { return msgs.sender } diff --git a/internal/discord/private/hub/sender.go b/internal/discord/private/hub/sender.go new file mode 100644 index 0000000..875e730 --- /dev/null +++ b/internal/discord/private/hub/sender.go @@ -0,0 +1,75 @@ +package hub + +import ( + "regexp" + "strings" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/send" + "github.com/diamondburned/cchat-discord/internal/discord/state" + "github.com/diamondburned/cchat/utils/empty" + "github.com/pkg/errors" +) + +// ChannelAdder is used to add a new direct message channel into a container. +type ChannelAdder interface { + AddChannel(state *state.Instance, ch *discord.Channel) +} + +type Sender struct { + empty.Sender + adder ChannelAdder + acList *activeList + state *state.Instance +} + +func NewSender(s *state.Instance, acList *activeList, adder ChannelAdder) *Sender { + return &Sender{adder: adder, acList: acList, state: s} +} + +var mentionRegex = regexp.MustCompile(`^<@!?(\d+)> ?`) + +// wrappedMessage wraps around a SendableMessage to override its content. +type wrappedMessage struct { + cchat.SendableMessage + content string +} + +func (wrMsg wrappedMessage) Content() string { + return wrMsg.content +} + +func (s *Sender) CanAttach() bool { return true } + +func (s *Sender) Send(sendable cchat.SendableMessage) error { + content := sendable.Content() + + // Validate message. + matches := mentionRegex.FindStringSubmatch(content) + if matches == nil { + return errors.New("messages sent here must start with a mention") + } + + targetID, err := discord.ParseSnowflake(matches[1]) + if err != nil { + return errors.Wrap(err, "failed to parse recipient ID") + } + + ch, err := s.state.CreatePrivateChannel(discord.UserID(targetID)) + if err != nil { + return errors.Wrap(err, "failed to find DM channel") + } + + s.adder.AddChannel(s.state, ch) + s.acList.add(ch.ID) + + return send.Send(s.state, ch.ID, wrappedMessage{ + SendableMessage: sendable, + content: strings.TrimPrefix(content, matches[0]), + }) +} + +// func (msgs *Messages) AsCompleter() cchat.Completer { +// return complete.New(msgs) +// } diff --git a/internal/discord/private/hub/server.go b/internal/discord/private/hub/server.go new file mode 100644 index 0000000..d434540 --- /dev/null +++ b/internal/discord/private/hub/server.go @@ -0,0 +1,103 @@ +package hub + +import ( + "sync" + "time" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/state" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" + "github.com/pkg/errors" +) + +// automatically add all channels with active messages within the past 48 hours. +const autoAddActive = 24 * time.Hour + +// activeList contains a list of channel IDs that should be put into its own +// channels. +type activeList struct { + mut sync.Mutex + active map[discord.ChannelID]struct{} +} + +func makeActiveList(s *state.Instance) (*activeList, error) { + channels, err := s.PrivateChannels() + if err != nil { + return nil, errors.Wrap(err, "failed to get private channels") + } + + ids := make(map[discord.ChannelID]struct{}, len(channels)) + now := time.Now() + + for _, channel := range channels { + if channel.LastMessageID.Time().Add(autoAddActive).After(now) { + ids[channel.ID] = struct{}{} + } + } + + return &activeList{active: ids}, nil +} + +func (acList *activeList) list() []discord.ChannelID { + acList.mut.Lock() + defer acList.mut.Unlock() + + var channelIDs = make([]discord.ChannelID, 0, len(acList.active)) + for channelID := range acList.active { + channelIDs = append(channelIDs, channelID) + } + + return channelIDs +} + +func (acList *activeList) isActive(channelID discord.ChannelID) bool { + acList.mut.Lock() + defer acList.mut.Unlock() + + _, ok := acList.active[channelID] + return ok +} + +func (acList *activeList) add(chID discord.ChannelID) { + acList.mut.Lock() + defer acList.mut.Unlock() + + acList.active[chID] = struct{}{} +} + +// Server is the server (channel) that contains all incoming DM messages that +// are not being listened. +type Server struct { + empty.Server + acList *activeList + msgs *Messages +} + +func New(s *state.Instance, adder ChannelAdder) (*Server, error) { + acList, err := makeActiveList(s) + if err != nil { + return nil, errors.Wrap(err, "failed to make active guild list") + } + + return &Server{ + acList: acList, + msgs: NewMessages(s, acList, adder), + }, nil +} + +func (hub *Server) ID() cchat.ID { return "!!!hub-server!!!" } + +func (hub *Server) Name() text.Rich { return text.Plain("Incoming Messages") } + +// ActiveChannelIDs returns the list of active channel IDs, that is, the channel +// IDs that should be displayed separately. +func (hub *Server) ActiveChannelIDs() []discord.ChannelID { + return hub.acList.list() +} + +// Close unbinds the message handlers from the hub, invalidating it forever. +func (hub *Server) Close() { hub.msgs.cancel() } + +func (hub *Server) AsMessenger() cchat.Messenger { return hub.msgs } diff --git a/internal/discord/private/private.go b/internal/discord/private/private.go new file mode 100644 index 0000000..83aaa6e --- /dev/null +++ b/internal/discord/private/private.go @@ -0,0 +1,127 @@ +package private + +import ( + "sort" + "sync" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel" + "github.com/diamondburned/cchat-discord/internal/discord/private/hub" + "github.com/diamondburned/cchat-discord/internal/discord/state" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" + "github.com/pkg/errors" +) + +// I don't think the cchat specs said anything about sharing a cchat.Server, so +// we might need to do this. Nevertheless, it seems overkill. +type containerSet struct { + mut sync.Mutex + set map[cchat.ServersContainer]struct{} +} + +func newContainerSet() *containerSet { + return &containerSet{ + set: map[cchat.ServersContainer]struct{}{}, + } +} + +func (cset *containerSet) Register(container cchat.ServersContainer) { + cset.mut.Lock() + cset.set[container] = struct{}{} + cset.mut.Unlock() +} + +// prependServer wraps around Server to always prepend this wrapped server on +// top of the servers container. +type prependServer struct{ cchat.Server } + +// PreviousID returns the appropriate parameters to prepend this server. +func (ps prependServer) PreviousID() (cchat.ID, bool) { return "", false } + +func (cset *containerSet) AddChannel(s *state.Instance, ch *discord.Channel) { + c, err := channel.New(s, *ch) + if err != nil { + return + } + + replace := prependServer{Server: c} + + cset.mut.Lock() + + for container := range cset.set { + container.UpdateServer(replace) + } + + cset.mut.Unlock() +} + +type Private struct { + empty.Server + state *state.Instance + hub *hub.Server + containers *containerSet +} + +func New(s *state.Instance) (cchat.Server, error) { + containers := newContainerSet() + + hubServer, err := hub.New(s, containers) + if err != nil { + return nil, errors.Wrap(err, "failed to make hub server") + } + + return Private{ + state: s, + hub: hubServer, + containers: containers, + }, nil +} + +func (priv Private) ID() cchat.ID { + // Not even a number, so no chance of colliding with snowflakes. + return "!!!private-container!!!" +} + +func (priv Private) Name() text.Rich { + return text.Plain("Private Channels") +} + +func (priv Private) AsLister() cchat.Lister { return priv } + +func (priv Private) Servers(container cchat.ServersContainer) error { + activeIDs := priv.hub.ActiveChannelIDs() + channels := make([]*discord.Channel, 0, len(activeIDs)) + + for _, id := range activeIDs { + c, err := priv.state.Channel(id) + if err != nil { + return errors.Wrap(err, "failed to get private channel") + } + + channels = append(channels, c) + } + + // Sort so that channels with the largest last message ID (and therefore the + // latest message) will be on top. + sort.Slice(channels, func(i, j int) bool { + return channels[i].LastMessageID > channels[j].LastMessageID + }) + + servers := make([]cchat.Server, len(channels)+1) + servers[0] = priv.hub + + for i, ch := range channels { + c, err := channel.New(priv.state, *ch) + if err != nil { + return errors.Wrap(err, "failed to create server for private channel") + } + + servers[i] = c + } + + container.SetServers(servers) + priv.containers.Register(container) + return nil +} diff --git a/internal/discord/session/session.go b/internal/discord/session/session.go index 49d00e7..8b4ced8 100644 --- a/internal/discord/session/session.go +++ b/internal/discord/session/session.go @@ -8,6 +8,7 @@ import ( "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/private" "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" @@ -20,22 +21,31 @@ var ErrMFA = session.ErrMFA type Session struct { empty.Session - *state.Instance + private cchat.Server + state *state.Instance } func NewFromInstance(i *state.Instance) (cchat.Session, error) { - return &Session{Instance: i}, nil + priv, err := private.New(i) + if err != nil { + return nil, errors.Wrap(err, "failed to make main private server") + } + + return &Session{ + private: priv, + state: i, + }, nil } func (s *Session) ID() cchat.ID { - return s.UserID.String() + return s.state.UserID.String() } func (s *Session) Name() text.Rich { - u, err := s.Store.Me() + u, err := s.state.Store.Me() if err != nil { // This shouldn't happen, ever. - return text.Rich{Content: "<@" + s.UserID.String() + ">"} + return text.Rich{Content: "<@" + s.state.UserID.String() + ">"} } return text.Rich{Content: u.Username + "#" + u.Discriminator} @@ -44,7 +54,7 @@ func (s *Session) Name() text.Rich { func (s *Session) AsIconer() cchat.Iconer { return s } func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { - u, err := s.Me() + u, err := s.state.Me() if err != nil { return nil, errors.Wrap(err, "Failed to get the current user") } @@ -52,28 +62,28 @@ func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), // Thanks to arikawa, AvatarURL is never empty. iconer.SetIcon(urlutils.AvatarURL(u.AvatarURL())) - return s.AddHandler(func(*gateway.UserUpdateEvent) { + return s.state.AddHandler(func(*gateway.UserUpdateEvent) { // Bypass the event and use the state cache. - if u, err := s.Store.Me(); err == nil { + if u, err := s.state.Store.Me(); err == nil { iconer.SetIcon(urlutils.AvatarURL(u.AvatarURL())) } }), nil } func (s *Session) Disconnect() error { - return s.Close() + return s.state.Close() } -func (s *Session) AsSessionSaver() cchat.SessionSaver { return s.Instance } +func (s *Session) AsSessionSaver() cchat.SessionSaver { return s.state } func (s *Session) Servers(container cchat.ServersContainer) error { // Reset the entire container when the session is closed. - s.AddHandler(func(*session.Closed) { + s.state.AddHandler(func(*session.Closed) { container.SetServers(nil) }) // Set the entire container again once reconnected. - s.AddHandler(func(*ningen.Connected) { + s.state.AddHandler(func(*ningen.Connected) { s.servers(container) }) @@ -81,22 +91,26 @@ func (s *Session) Servers(container cchat.ServersContainer) error { } func (s *Session) servers(container cchat.ServersContainer) error { + // TODO: remove this once v2 is used, so we could swap it with a getter. + ready := s.state.Ready + switch { // If the user has guild folders: - case len(s.Ready.Settings.GuildFolders) > 0: + case len(ready.Settings.GuildFolders) > 0: // TODO: account for missing guilds. - var toplevels = make([]cchat.Server, 0, len(s.Ready.Settings.GuildFolders)) + toplevels := make([]cchat.Server, 1, len(ready.Settings.GuildFolders)+1) + toplevels[0] = s.private - for _, guildFolder := range s.Ready.Settings.GuildFolders { + for _, guildFolder := range ready.Settings.GuildFolders { // TODO: correct. switch { case guildFolder.ID != 0: fallthrough case len(guildFolder.GuildIDs) > 1: - toplevels = append(toplevels, folder.New(s.Instance, guildFolder)) + toplevels = append(toplevels, folder.New(s.state, guildFolder)) case len(guildFolder.GuildIDs) == 1: - g, err := guild.NewFromID(s.Instance, guildFolder.GuildIDs[0]) + g, err := guild.NewFromID(s.state, guildFolder.GuildIDs[0]) if err != nil { continue } @@ -108,11 +122,12 @@ func (s *Session) servers(container cchat.ServersContainer) error { // 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)) + case len(ready.Settings.GuildPositions) > 0: + guilds := make([]cchat.Server, 1, len(ready.Settings.GuildPositions)+1) + guilds[0] = s.private - for _, id := range s.Ready.Settings.GuildPositions { - g, err := guild.NewFromID(s.Instance, id) + for _, id := range ready.Settings.GuildPositions { + g, err := guild.NewFromID(s.state, id) if err != nil { continue } @@ -123,14 +138,16 @@ func (s *Session) servers(container cchat.ServersContainer) error { // None of the above: default: - g, err := s.Guilds() + g, err := s.state.Guilds() if err != nil { return err } - var servers = make([]cchat.Server, len(g)) + servers := make([]cchat.Server, len(g)+1) + servers[0] = s.private + for i := range g { - servers[i] = guild.New(s.Instance, &g[i]) + servers[i+1] = guild.New(s.state, &g[i]) } container.SetServers(servers) diff --git a/internal/funcutil/funcutil.go b/internal/funcutil/funcutil.go index 2a0873d..ea4d507 100644 --- a/internal/funcutil/funcutil.go +++ b/internal/funcutil/funcutil.go @@ -10,7 +10,7 @@ func NewCancels() func(...func()) []func() { } // JoinCancels joins multiple cancel callbacks into one. -func JoinCancels(cancellers []func()) func() { +func JoinCancels(cancellers ...func()) func() { return func() { for _, c := range cancellers { c() diff --git a/internal/segments/renderer/renderer.go b/internal/segments/renderer/renderer.go index 9d85241..0aec966 100644 --- a/internal/segments/renderer/renderer.go +++ b/internal/segments/renderer/renderer.go @@ -187,8 +187,6 @@ func (r *Text) RenderNode(n ast.Node, enter bool) (ast.WalkStatus, error) { return f(r, n, enter), nil } - log.Println("unknown kind:", n.Kind()) - switch n := n.(type) { case *ast.Document: case *ast.Paragraph: