diff --git a/go.mod b/go.mod index 9d5958c..19cc1ad 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,11 @@ go 1.14 require ( github.com/diamondburned/arikawa v1.3.6 - github.com/diamondburned/cchat v0.3.12 + github.com/diamondburned/cchat v0.3.15 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 + github.com/lithammer/fuzzysearch v1.1.1 github.com/pkg/errors v0.9.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/yuin/goldmark v1.1.30 diff --git a/go.sum b/go.sum index 59c837e..849915d 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,12 @@ github.com/diamondburned/cchat v0.3.11 h1:C1f9Tp7Kz3t+T1SlepL1RS7b/kACAKWAIZXAgJ 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/cchat v0.3.13 h1:qSAo8FJDvEi9o8kLQJ5Mbo4jrfb+sd1Muo1sx8ruBo8= +github.com/diamondburned/cchat v0.3.13/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.14 h1:nDr9DJ1EW3kab4gieE+DLhpvHRws+umWpw5XrOmlNyc= +github.com/diamondburned/cchat v0.3.14/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.15 h1:BJf8ZiRtDWTGMtQ3QqjNU0H+784WSrkJEpFGkKY5gEw= +github.com/diamondburned/cchat v0.3.15/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= @@ -204,6 +210,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lithammer/fuzzysearch v1.1.1 h1:8F9OAV2xPuYblToVohjanztdnPjbtA0MLgMvDKQ0Z08= +github.com/lithammer/fuzzysearch v1.1.1/go.mod h1:H2bng+w5gsR7NlfIJM8ElGZI0sX6C/9uzGqicVXGU6c= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -295,6 +303,7 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/discord/channel/channel.go b/internal/discord/channel/channel.go index e9b1c73..97ee772 100644 --- a/internal/discord/channel/channel.go +++ b/internal/discord/channel/channel.go @@ -53,7 +53,7 @@ func (ch Channel) ID() cchat.ID { func (ch Channel) Name() text.Rich { c, err := ch.Self() if err != nil { - return text.Rich{Content: ch.Channel.ID.String()} + return text.Rich{Content: ch.ID()} } if c.NSFW { diff --git a/internal/discord/channel/commander.go b/internal/discord/channel/commander.go index e9cae45..e594250 100644 --- a/internal/discord/channel/commander.go +++ b/internal/discord/channel/commander.go @@ -12,13 +12,13 @@ import ( type Commander struct { shared.Channel - msgCompl complete.Completer + msgCompl complete.ChannelCompleter } func NewCommander(ch shared.Channel) cchat.Commander { return Commander{ Channel: ch, - msgCompl: complete.Completer{ + msgCompl: complete.ChannelCompleter{ Channel: ch, }, } diff --git a/internal/discord/channel/message/message.go b/internal/discord/channel/message/message.go index ad379a2..66f9f0e 100644 --- a/internal/discord/channel/message/message.go +++ b/internal/discord/channel/message/message.go @@ -28,11 +28,11 @@ type Messenger struct { var _ cchat.Messenger = (*Messenger)(nil) -func New(ch shared.Channel) Messenger { - return Messenger{Channel: ch} +func New(ch shared.Channel) *Messenger { + return &Messenger{Channel: ch} } -func (msgr Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { +func (msgr *Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { state := msgr.State.WithContext(ctx) m, err := state.Messages(msgr.ID) @@ -112,7 +112,7 @@ func (msgr Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer addcancel( msgr.State.AddHandler(func(m *gateway.MessageCreateEvent) { if m.ChannelID == msgr.ID { - ct.CreateMessage(message.NewMessageCreate(m, msgr.State)) + ct.CreateMessage(message.NewGuildMessageCreate(m, msgr.State)) msgr.State.ReadState.MarkRead(msgr.ID, m.ID) } }), @@ -132,7 +132,7 @@ func (msgr Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer return funcutil.JoinCancels(addcancel()...), nil } -func (msgr Messenger) AsSender() cchat.Sender { +func (msgr *Messenger) AsSender() cchat.Sender { if !msgr.HasPermission(discord.PermissionSendMessages) { return nil } @@ -140,7 +140,7 @@ func (msgr Messenger) AsSender() cchat.Sender { return send.New(msgr.Channel) } -func (msgr Messenger) AsEditor() cchat.Editor { +func (msgr *Messenger) AsEditor() cchat.Editor { if !msgr.HasPermission(discord.PermissionSendMessages) { return nil } @@ -148,22 +148,22 @@ func (msgr Messenger) AsEditor() cchat.Editor { return edit.New(msgr.Channel) } -func (msgr Messenger) AsActioner() cchat.Actioner { +func (msgr *Messenger) AsActioner() cchat.Actioner { return action.New(msgr.Channel) } -func (msgr Messenger) AsNicknamer() cchat.Nicknamer { +func (msgr *Messenger) AsNicknamer() cchat.Nicknamer { return nickname.New(msgr.Channel) } -func (msgr Messenger) AsMemberLister() cchat.MemberLister { +func (msgr *Messenger) AsMemberLister() cchat.MemberLister { if !msgr.GuildID.IsValid() { return nil } return memberlist.New(msgr.Channel) } -func (msgr Messenger) AsBacklogger() cchat.Backlogger { +func (msgr *Messenger) AsBacklogger() cchat.Backlogger { if !msgr.HasPermission(discord.PermissionViewChannel, discord.PermissionReadMessageHistory) { return nil } @@ -171,10 +171,10 @@ func (msgr Messenger) AsBacklogger() cchat.Backlogger { return backlog.New(msgr.Channel) } -func (msgr Messenger) AsTypingIndicator() cchat.TypingIndicator { +func (msgr *Messenger) AsTypingIndicator() cchat.TypingIndicator { return indicate.NewTyping(msgr.Channel) } -func (msgr Messenger) AsUnreadIndicator() cchat.UnreadIndicator { +func (msgr *Messenger) AsUnreadIndicator() cchat.UnreadIndicator { return indicate.NewUnread(msgr.Channel) } diff --git a/internal/discord/channel/message/send/complete/channel.go b/internal/discord/channel/message/send/complete/channel.go index 3a4b330..14df9c3 100644 --- a/internal/discord/channel/message/send/complete/channel.go +++ b/internal/discord/channel/message/send/complete/channel.go @@ -1,52 +1,90 @@ package complete import ( - "strings" - + "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat/text" ) -func (ch Completer) CompleteChannels(word string) (entries []cchat.CompletionEntry) { +func (ch ChannelCompleter) CompleteChannels(word string) []cchat.CompletionEntry { // Ignore if empty word. if word == "" { - return + return nil } // Ignore if we're not in a guild. if !ch.GuildID.IsValid() { - return + return nil } c, err := ch.State.Store.Channels(ch.GuildID) if err != nil { - return + return nil } - var match = strings.ToLower(word) + return completeChannels(c, word, ch.State) +} - for _, channel := range c { - if !contains(match, channel.Name) { +func DMChannels(s *state.Instance, word string) []cchat.CompletionEntry { + channels, err := s.Store.PrivateChannels() + if err != nil { + return nil + } + // We only need the state to look for categories, which is never the case + // for private channels. + return completeChannels(channels, word, nil) +} + +func rankChannel(word string, ch discord.Channel) int { + switch ch.Type { + case discord.GroupDM, discord.DirectMessage: + return rankFunc(word, ch.Name+" "+shared.PrivateName(ch)) + default: + return rankFunc(word, ch.Name) + } +} + +func completeChannels( + channels []discord.Channel, word string, s *state.Instance) []cchat.CompletionEntry { + + var entries []cchat.CompletionEntry + var distances map[string]int + + for _, channel := range channels { + rank := rankChannel(word, channel) + if rank == -1 { continue } var category string - if channel.CategoryID.IsValid() { - if c, _ := ch.State.Store.Channel(channel.CategoryID); c != nil { - category = c.Name + if s != nil && channel.CategoryID.IsValid() { + if cat, _ := s.Store.Channel(channel.CategoryID); cat != nil { + category = cat.Name } } + // Defer allocation until we've found something. + ensureEntriesMade(&entries) + ensureDistancesMade(&distances) + + raw := channel.Mention() + entries = append(entries, cchat.CompletionEntry{ - Raw: channel.Mention(), - Text: text.Rich{Content: "#" + channel.Name}, - Secondary: text.Rich{Content: category}, + Raw: raw, + Text: text.Plain("#" + channel.Name), + Secondary: text.Plain(category), }) + distances[raw] = rank + if len(entries) >= MaxCompletion { - return + break } } - return + sortDistances(entries, distances) + return entries + } diff --git a/internal/discord/channel/message/send/complete/completer.go b/internal/discord/channel/message/send/complete/completer.go index 0d83d3a..7e55e46 100644 --- a/internal/discord/channel/message/send/complete/completer.go +++ b/internal/discord/channel/message/send/complete/completer.go @@ -1,20 +1,30 @@ package complete import ( - "strings" + "sort" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/lithammer/fuzzysearch/fuzzy" ) -type Completer struct { +type CompleterFunc func(word string) []cchat.CompletionEntry + +type ChannelCompleter struct { shared.Channel } +type Completer map[byte]CompleterFunc + const MaxCompletion = 15 func New(ch shared.Channel) cchat.Completer { - return Completer{ch} + completer := ChannelCompleter{ch} + return Completer{ + '@': completer.CompleteMentions, + '#': completer.CompleteChannels, + ':': completer.CompleteEmojis, + } } // CompleteMessage implements message input completion capability for Discord. @@ -28,24 +38,40 @@ func (ch Completer) Complete(words []string, i int64) []cchat.CompletionEntry { return nil } - switch word[0] { - case '@': - return ch.CompleteMentions(word[1:]) - case '#': - return ch.CompleteChannels(word[1:]) - case ':': - return ch.CompleteEmojis(word[1:]) + fn, ok := ch[word[0]] + if !ok { + return nil } + fn(word[1:]) return nil } -func contains(contains string, strs ...string) bool { - for _, str := range strs { - if strings.Contains(strings.ToLower(str), contains) { - return true - } - } - - return false +// rankFunc is the default rank function to use. +func rankFunc(source, target string) int { + return fuzzy.RankMatchNormalizedFold(source, target) +} + +func ensureEntriesMade(entries *[]cchat.CompletionEntry) { + if *entries == nil { + *entries = make([]cchat.CompletionEntry, 0, MaxCompletion) + } +} + +func ensureDistancesMade(distances *map[string]int) { + if *distances == nil { + *distances = make(map[string]int, MaxCompletion) + } +} + +// sortDistances sorts according to the given Levenshtein distances from the Raw +// string of the entries from most accurate to least accurate. +func sortDistances(entries []cchat.CompletionEntry, distances map[string]int) { + if len(entries) == 0 { + return + } + // The lower the distance, the more accurate. + sort.SliceStable(entries, func(i, j int) bool { + return distances[entries[i].Raw] < distances[entries[j].Raw] + }) } diff --git a/internal/discord/channel/message/send/complete/emoji.go b/internal/discord/channel/message/send/complete/emoji.go index 936910f..233e75c 100644 --- a/internal/discord/channel/message/send/complete/emoji.go +++ b/internal/discord/channel/message/send/complete/emoji.go @@ -1,8 +1,6 @@ package complete import ( - "strings" - "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/internal/discord/state" @@ -10,43 +8,54 @@ import ( "github.com/diamondburned/cchat/text" ) -func (ch Completer) CompleteEmojis(word string) (entries []cchat.CompletionEntry) { - return CompleteEmojis(ch.State, ch.GuildID, word) +func (ch ChannelCompleter) CompleteEmojis(word string) (entries []cchat.CompletionEntry) { + return Emojis(ch.State, ch.GuildID, word) } -func CompleteEmojis(s *state.Instance, gID discord.GuildID, word string) []cchat.CompletionEntry { +func Emojis(s *state.Instance, gID discord.GuildID, word string) []cchat.CompletionEntry { // Ignore if empty word. if word == "" { return nil } - e, err := s.EmojiState.Get(gID) + guilds, err := s.EmojiState.Get(gID) if err != nil { return nil } - var match = strings.ToLower(word) - var entries = make([]cchat.CompletionEntry, 0, MaxCompletion) + var entries []cchat.CompletionEntry + var distances map[string]int - for _, guild := range e { +GuildSearch: + for _, guild := range guilds { for _, emoji := range guild.Emojis { - if !contains(match, emoji.Name) { + rank := rankFunc(word, emoji.Name) + if rank == -1 { continue } + // Defer allocation until we've found something. + ensureEntriesMade(&entries) + ensureDistancesMade(&distances) + + raw := emoji.String() + entries = append(entries, cchat.CompletionEntry{ - Raw: emoji.String(), + Raw: raw, Text: text.Rich{Content: ":" + emoji.Name + ":"}, Secondary: text.Rich{Content: guild.Name}, IconURL: urlutils.Sized(emoji.EmojiURL(), 32), // small Image: true, }) + distances[raw] = rank + if len(entries) >= MaxCompletion { - return entries + break GuildSearch } } } + sortDistances(entries, distances) return entries } diff --git a/internal/discord/channel/message/send/complete/mention.go b/internal/discord/channel/message/send/complete/mention.go index b6a7b42..cbd6b55 100644 --- a/internal/discord/channel/message/send/complete/mention.go +++ b/internal/discord/channel/message/send/complete/mention.go @@ -10,60 +10,175 @@ import ( "github.com/diamondburned/cchat/text" ) -func (ch Completer) CompleteMentions(word string) (entries []cchat.CompletionEntry) { +// MessageMentions generates a list of user mention completion entries from +// messages. +func MessageMentions(msgs []discord.Message) []cchat.CompletionEntry { + return GuildMessageMentions(msgs, nil, nil) +} + +// GuildMessageMentions generates a list of member mention completion entries +// from guild messages. +func GuildMessageMentions( + msgs []discord.Message, + state *state.Instance, guild *discord.Guild) []cchat.CompletionEntry { + + if len(msgs) == 0 { + return nil + } + + // Keep track of the number of authors. + // TODO: fix excess allocations + + var entries []cchat.CompletionEntry + var authors map[discord.UserID]struct{} + + for _, msg := range msgs { + // If we've already added the author into the list, then skip. + if _, ok := authors[msg.Author.ID]; ok { + continue + } + + ensureAuthorMapMade(&authors) + authors[msg.Author.ID] = struct{}{} + + var rich text.Rich + + if guild != nil && state != nil { + m, err := state.Store.Member(guild.ID, msg.Author.ID) + if err == nil { + rich = message.RenderMemberName(*m, *guild, state) + } + } + + // Fallback to searching the author if member fails. + if rich.IsEmpty() { + rich = text.Plain(msg.Author.Username) + } + + ensureEntriesMade(&entries) + + entries = append(entries, cchat.CompletionEntry{ + Raw: msg.Author.Mention(), + Text: rich, + Secondary: text.Plain(msg.Author.Username + "#" + msg.Author.Discriminator), + IconURL: msg.Author.AvatarURL(), + }) + + if len(entries) >= MaxCompletion { + break + } + } + + return entries +} + +func ensureAuthorMapMade(authors *map[discord.UserID]struct{}) { + if *authors == nil { + *authors = make(map[discord.UserID]struct{}, MaxCompletion) + } +} + +func Presences(s *state.Instance, word string) []cchat.CompletionEntry { + presences, err := s.Presences(0) + if err != nil { + return nil + } + + var entries []cchat.CompletionEntry + var distances map[string]int + + for _, presence := range presences { + rank := rankFunc(word, presence.User.Username) + if rank == -1 { + continue + } + + ensureEntriesMade(&entries) + ensureDistancesMade(&distances) + + raw := presence.User.Mention() + + entries = append(entries, cchat.CompletionEntry{ + Raw: raw, + Text: text.Plain(presence.User.Username + "#" + presence.User.Discriminator), + Secondary: text.Plain(FormatStatus(presence.Status)), + IconURL: presence.User.AvatarURL(), + }) + + distances[raw] = rank + + if len(entries) >= MaxCompletion { + break + } + } + + sortDistances(entries, distances) + return entries +} + +func FormatStatus(status discord.Status) string { + switch status { + case discord.OnlineStatus: + return "Online" + case discord.DoNotDisturbStatus: + return "Busy" + case discord.IdleStatus: + return "Idle" + case discord.InvisibleStatus: + return "Invisible" + case discord.OfflineStatus: + return "Offline" + default: + return strings.Title(string(status)) + } +} + +func (ch ChannelCompleter) CompleteMentions(word string) []cchat.CompletionEntry { // If there is no input, then we should grab the latest messages. if word == "" { msgs, _ := ch.State.Store.Messages(ch.ID) g, _ := ch.State.Store.Guild(ch.GuildID) // nil is fine - // Keep track of the number of authors. - // TODO: fix excess allocations - var authors = make(map[discord.UserID]struct{}, MaxCompletion) - - for _, msg := range msgs { - // If we've already added the author into the list, then skip. - if _, ok := authors[msg.Author.ID]; ok { - continue - } - - // Record the current author and add the entry to the list. - authors[msg.Author.ID] = struct{}{} - entries = append(entries, completionUser(ch.State, msg.Author, g)) - - if len(entries) >= MaxCompletion { - return - } - } - - return + return GuildMessageMentions(msgs, ch.State, g) } - // Lower-case everything for a case-insensitive match. contains() should - // do the rest. - var match = strings.ToLower(word) + var entries []cchat.CompletionEntry + var distances map[string]int // If we're not in a guild, then we can check the list of recipients. if !ch.GuildID.IsValid() { c, err := ch.State.Store.Channel(ch.ID) if err != nil { - return + return nil } for _, u := range c.DMRecipients { - if contains(match, u.Username) { - entries = append(entries, cchat.CompletionEntry{ - Raw: u.Mention(), - Text: text.Rich{Content: u.Username}, - Secondary: text.Rich{Content: u.Username + "#" + u.Discriminator}, - IconURL: u.AvatarURL(), - }) - if len(entries) >= MaxCompletion { - return - } + rank := rankFunc(word, u.Username) + if rank == -1 { + continue + } + + ensureEntriesMade(&entries) + ensureDistancesMade(&distances) + + raw := u.Mention() + + entries = append(entries, cchat.CompletionEntry{ + Raw: raw, + Text: text.Rich{Content: u.Username}, + Secondary: text.Rich{Content: u.Username + "#" + u.Discriminator}, + IconURL: u.AvatarURL(), + }) + + distances[raw] = rank + + if len(entries) >= MaxCompletion { + break } } - return + sortDistances(entries, distances) + return entries } // If we're in a guild, then we should search for (all) members. @@ -71,50 +186,45 @@ func (ch Completer) CompleteMentions(word string) (entries []cchat.CompletionEnt g, gerr := ch.State.Store.Guild(ch.GuildID) if merr != nil || gerr != nil { - return + return nil } // If we couldn't find any members, then we can request Discord to // search for them. if len(m) == 0 { ch.State.MemberState.SearchMember(ch.GuildID, word) - return + return nil } for _, mem := range m { - if contains(match, mem.User.Username, mem.Nick) { - entries = append(entries, cchat.CompletionEntry{ - Raw: mem.User.Mention(), - Text: message.RenderMemberName(mem, *g, ch.State), - Secondary: text.Rich{Content: mem.User.Username + "#" + mem.User.Discriminator}, - IconURL: mem.User.AvatarURL(), - }) - if len(entries) >= MaxCompletion { - return - } + rank := memberMatchString(word, &mem) + if rank == -1 { + continue + } + + ensureEntriesMade(&entries) + ensureDistancesMade(&distances) + + raw := mem.User.Mention() + + entries = append(entries, cchat.CompletionEntry{ + Raw: raw, + Text: message.RenderMemberName(mem, *g, ch.State), + Secondary: text.Plain(mem.User.Username + "#" + mem.User.Discriminator), + IconURL: mem.User.AvatarURL(), + }) + + distances[raw] = rank + + if len(entries) >= MaxCompletion { + break } } - return + sortDistances(entries, distances) + return entries } -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: message.RenderMemberName(*m, *g, s), - Secondary: text.Rich{Content: u.Username + "#" + u.Discriminator}, - IconURL: u.AvatarURL(), - } - } - } - - return cchat.CompletionEntry{ - Raw: u.Mention(), - Text: text.Rich{Content: u.Username}, - Secondary: text.Rich{Content: u.Username + "#" + u.Discriminator}, - IconURL: u.AvatarURL(), - } +func memberMatchString(word string, m *discord.Member) int { + return rankFunc(word, m.User.Username+" "+m.Nick) } diff --git a/internal/discord/channel/message/send/sender.go b/internal/discord/channel/message/send/sender.go index 9c152e7..010aaca 100644 --- a/internal/discord/channel/message/send/sender.go +++ b/internal/discord/channel/message/send/sender.go @@ -9,6 +9,18 @@ import ( "github.com/diamondburned/cchat-discord/internal/discord/state" ) +func WrapMessage(s *state.Instance, msg cchat.SendableMessage) api.SendMessageData { + var send = api.SendMessageData{Content: msg.Content()} + if attacher := msg.AsAttachments(); attacher != nil { + send.Files = addAttachments(attacher.Attachments()) + } + if noncer := msg.AsNoncer(); noncer != nil { + send.Nonce = s.Nonces.Generate(noncer.Nonce()) + } + + return send +} + type Sender struct { shared.Channel } @@ -20,16 +32,7 @@ func New(ch shared.Channel) Sender { } 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 attacher := msg.AsAttachments(); attacher != nil { - send.Files = addAttachments(attacher.Attachments()) - } - - _, err := s.SendMessageComplex(chID, send) + _, err := s.State.SendMessageComplex(s.ID, WrapMessage(s.State, msg)) return err } diff --git a/internal/discord/channel/private.go b/internal/discord/channel/private.go index 9c53476..9d5b15e 100644 --- a/internal/discord/channel/private.go +++ b/internal/discord/channel/private.go @@ -6,8 +6,10 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/urlutils" + "github.com/diamondburned/cchat/text" "github.com/pkg/errors" ) @@ -30,6 +32,15 @@ func NewPrivate(s *state.Instance, ch discord.Channel) (cchat.Server, error) { return Private{Channel: channel}, nil } +func (priv Private) Name() text.Rich { + c, err := priv.Self() + if err != nil { + return text.Rich{Content: priv.ID()} + } + + return text.Plain(shared.PrivateName(*c)) +} + func (priv Private) AsIconer() cchat.Iconer { return NewAvatarIcon(priv.State) } diff --git a/internal/discord/channel/shared/channel.go b/internal/discord/channel/shared/channel.go index dd58f9c..fa33ed8 100644 --- a/internal/discord/channel/shared/channel.go +++ b/internal/discord/channel/shared/channel.go @@ -1,12 +1,44 @@ +// Package shared contains channel utilities. package shared import ( "errors" + "strings" "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat-discord/internal/discord/state" ) +// PrivateName returns the channel name if any, otherwise it formats its own +// name into a list of recipients. +func PrivateName(privCh discord.Channel) string { + if privCh.Name != "" { + return privCh.Name + } + + return FormatRecipients(privCh.DMRecipients) +} + +// FormatRecipients joins the given list of users into a string listing all +// recipients with English punctuation rules. +func FormatRecipients(users []discord.User) string { + switch len(users) { + case 0: + return "" + case 1: + return users[0].Username + case 2: + return users[0].Username + " and " + users[1].Username + } + + var usernames = make([]string, len(users)) + for i, user := range users[:len(users)-1] { + usernames[i] = user.Username + } + + return strings.Join(usernames, ", ") + " and " + users[len(users)-1].Username +} + type Channel struct { ID discord.ChannelID GuildID discord.GuildID diff --git a/internal/discord/message/author.go b/internal/discord/message/author.go index 12d97d3..3675fb6 100644 --- a/internal/discord/message/author.go +++ b/internal/discord/message/author.go @@ -6,6 +6,7 @@ import ( "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/segments/colored" "github.com/diamondburned/cchat-discord/internal/segments/mention" + "github.com/diamondburned/cchat-discord/internal/segments/reference" "github.com/diamondburned/cchat-discord/internal/segments/segutil" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" @@ -20,22 +21,12 @@ type Author struct { var _ cchat.Author = (*Author)(nil) 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, - colored.NewBlurple(segutil.Write(&name, "[BOT]")), - ) - } - - // Append a clickable user popup. - useg := mention.UserSegment(0, len(name.Content), u) - useg.WithState(s.State) - name.Segments = append(name.Segments, useg) + var rich text.Rich + richUser(&rich, u, s) return Author{ id: u.ID, - name: name, + name: rich, avatar: urlutils.AvatarURL(u.AvatarURL()), } } @@ -49,36 +40,64 @@ func NewGuildMember(m discord.Member, g discord.Guild, s *state.Instance) Author } func RenderMemberName(m discord.Member, g discord.Guild, s *state.Instance) text.Rich { - var name = text.Rich{ - Content: m.User.Username, + var rich text.Rich + richMember(&rich, m, g, s) + return rich +} + +// richMember appends the member name directly into rich. +func richMember( + rich *text.Rich, m discord.Member, g discord.Guild, s *state.Instance) (start, end int) { + + var displayName = m.User.Username + if m.Nick != "" { + displayName = m.Nick } - // Update the nickname. - if m.Nick != "" { - name.Content = m.Nick - } + start, end = segutil.Write(rich, displayName) // Update the color. if c := discord.MemberColor(g, m); c > 0 { - name.Segments = append(name.Segments, - colored.New(len(name.Content), c.Uint32()), + rich.Segments = append(rich.Segments, + colored.NewSegment(start, end, c.Uint32()), ) } // Append the bot prefix if the user is a bot. if m.User.Bot { - name.Content += " " - name.Segments = append(name.Segments, - colored.NewBlurple(segutil.Write(&name, "[BOT]")), + rich.Content += " " + rich.Segments = append(rich.Segments, + colored.NewBlurple(segutil.Write(rich, "[BOT]")), ) } // Append a clickable user popup. - useg := mention.MemberSegment(0, len(name.Content), g, m) + useg := mention.MemberSegment(start, end, g, m) useg.WithState(s.State) - name.Segments = append(name.Segments, useg) + rich.Segments = append(rich.Segments, useg) - return name + return +} + +func richUser( + rich *text.Rich, u discord.User, s *state.Instance) (start, end int) { + + start, end = segutil.Write(rich, u.Username) + + // Append the bot prefix if the user is a bot. + if u.Bot { + rich.Content += " " + rich.Segments = append(rich.Segments, + colored.NewBlurple(segutil.Write(rich, "[BOT]")), + ) + } + + // Append a clickable user popup. + useg := mention.UserSegment(start, end, u) + useg.WithState(s.State) + rich.Segments = append(rich.Segments, useg) + + return } func (a Author) ID() cchat.ID { @@ -92,3 +111,53 @@ func (a Author) Name() text.Rich { func (a Author) Avatar() string { return a.avatar } + +const authorReplyingTo = " replying to " + +// AddUserReply modifies Author to make it appear like it's a message reply. +// Specifically, this function is used for direct messages. +func (a *Author) AddUserReply(user discord.User, s *state.Instance) { + a.name.Content += authorReplyingTo + richUser(&a.name, user, s) +} + +func (a *Author) AddReply(name string) { + a.name.Content += authorReplyingTo + name +} + +// // AddMemberReply modifies Author to make it appear like it's a message reply. +// // Specifically, this function is used for guild messages. +// func (a *Author) AddMemberReply(m discord.Member, g discord.Guild, s *state.Instance) { +// a.name.Content += authorReplyingTo +// richMember(&a.name, m, g, s) +// } + +// AddMessageReference adds a message reference to the author. +func (a *Author) AddMessageReference(msgref discord.Message, s *state.Instance) { + if !msgref.GuildID.IsValid() { + a.name.Content += authorReplyingTo + start, end := richUser(&a.name, msgref.Author, s) + + a.name.Segments = append(a.name.Segments, + reference.NewMessageSegment(start, end, msgref.ID), + ) + return + } + + g, err := s.Guild(msgref.GuildID) + if err != nil { + return + } + + m, err := s.Member(g.ID, msgref.Author.ID) + if err != nil { + return + } + + a.name.Content += authorReplyingTo + start, end := richMember(&a.name, *m, *g, s) + + a.name.Segments = append(a.name.Segments, + reference.NewMessageSegment(start, end, msgref.ID), + ) +} diff --git a/internal/discord/message/message.go b/internal/discord/message/message.go index d289b03..e697fcb 100644 --- a/internal/discord/message/message.go +++ b/internal/discord/message/message.go @@ -15,6 +15,7 @@ import ( type messageHeader struct { id discord.MessageID time discord.Timestamp + nonce string channelID discord.ChannelID guildID discord.GuildID } @@ -34,6 +35,12 @@ func newHeader(msg discord.Message) messageHeader { return h } +func newHeaderNonce(msg discord.Message, nonce string) messageHeader { + h := newHeader(msg) + h.nonce = nonce + return h +} + func NewHeaderDelete(d *gateway.MessageDeleteEvent) messageHeader { return messageHeader{ id: d.ID, @@ -47,6 +54,8 @@ func (m messageHeader) ID() cchat.ID { return m.id.String() } +func (m messageHeader) Nonce() string { return m.nonce } + 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 } @@ -69,6 +78,7 @@ 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 { @@ -97,13 +107,18 @@ 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 *state.Instance) Message { +// NewGuildMessageCreate uses the session to create a message. It does not do +// API calls. Member is optional. This is the only call that populates the Nonce +// in the header. +func NewGuildMessageCreate(c *gateway.MessageCreateEvent, s *state.Instance) Message { + // Copy and change the nonce. + message := c.Message + message.Nonce = s.Nonces.Load(c.Nonce) + // This should not error. g, err := s.Store.Guild(c.GuildID) if err != nil { - return NewMessage(c.Message, s, NewUser(c.Author, s)) + return NewMessage(message, s, NewUser(c.Author, s)) } if c.Member == nil { @@ -111,10 +126,10 @@ func NewMessageCreate(c *gateway.MessageCreateEvent, s *state.Instance) Message } if c.Member == nil { s.MemberState.RequestMember(c.GuildID, c.Author.ID) - return NewMessage(c.Message, s, NewUser(c.Author, s)) + return NewMessage(message, s, NewUser(c.Author, s)) } - return NewMessage(c.Message, s, NewGuildMember(*c.Member, *g, s)) + return NewMessage(message, s, NewGuildMember(*c.Member, *g, s)) } // NewBacklogMessage uses the session to create a message fetched from the @@ -167,7 +182,7 @@ func NewMessage(m discord.Message, s *state.Instance, author Author) Message { } return Message{ - messageHeader: newHeader(m), + messageHeader: newHeaderNonce(m, m.Nonce), author: author, content: content, } @@ -184,6 +199,10 @@ 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 index 18bba20..5efdd4a 100644 --- a/internal/discord/private/hub/messages.go +++ b/internal/discord/private/hub/messages.go @@ -7,8 +7,11 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/gateway" "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/message" "github.com/diamondburned/cchat-discord/internal/discord/state" + "github.com/diamondburned/cchat-discord/internal/discord/state/nonce" "github.com/diamondburned/cchat-discord/internal/funcutil" "github.com/diamondburned/cchat/utils/empty" ) @@ -52,8 +55,9 @@ func (list *messageList) delete(id discord.MessageID) { type Messages struct { empty.Messenger - state *state.Instance - acList *activeList + state *state.Instance + acList *activeList + sentMsgs *nonce.Set sender *Sender @@ -64,19 +68,48 @@ type Messages struct { } func NewMessages(s *state.Instance, acList *activeList, adder ChannelAdder) *Messages { + var sentMsgs nonce.Set + hubServer := &Messages{ state: s, acList: acList, - sender: NewSender(s, acList, adder), + sentMsgs: &sentMsgs, + sender: &Sender{ + adder: adder, + acList: acList, + sentMsgs: &sentMsgs, + state: s, + }, messages: make(messageList, 0, 100), } + hubServer.sender.completers = complete.Completer{ + ':': func(word string) []cchat.CompletionEntry { + return complete.Emojis(s, 0, word) + }, + '@': func(word string) []cchat.CompletionEntry { + if word != "" { + return complete.Presences(s, word) + } + + hubServer.msgMutex.Lock() + defer hubServer.msgMutex.Unlock() + return complete.MessageMentions(hubServer.messages) + }, + '#': func(word string) []cchat.CompletionEntry { + return complete.DMChannels(s, word) + }, + } + hubServer.cancel = funcutil.JoinCancels( s.AddHandler(func(msg *gateway.MessageCreateEvent) { if msg.GuildID.IsValid() || acList.isActive(msg.ChannelID) { return } + // We're not adding back messages we sent here, since we already + // have a separate channel for that. + hubServer.msgMutex.Lock() hubServer.messages.append(msg.Message) hubServer.msgMutex.Unlock() @@ -122,11 +155,32 @@ func (msgs *Messages) JoinServer(ctx context.Context, ct cchat.MessagesContainer // Bind the handler. return funcutil.JoinCancels( msgs.state.AddHandler(func(msg *gateway.MessageCreateEvent) { - if msg.GuildID.IsValid() || msgs.acList.isActive(msg.ChannelID) { + if msg.GuildID.IsValid() { return } - ct.CreateMessage(message.NewMessageCreate(msg, msgs.state)) + var isReply = false + if msgs.acList.isActive(msg.ChannelID) { + if !msgs.sentMsgs.HasAndDelete(msg.Nonce) { + return + } + isReply = true + } + + var author = message.NewUser(msg.Author, msgs.state) + if isReply { + c, err := msgs.state.Channel(msg.ChannelID) + if err == nil { + switch c.Type { + case discord.DirectMessage: + author.AddUserReply(c.DMRecipients[0], msgs.state) + case discord.GroupDM: + author.AddReply(shared.PrivateName(*c)) + } + } + } + + ct.CreateMessage(message.NewMessage(msg.Message, msgs.state, author)) msgs.state.ReadState.MarkRead(msg.ChannelID, msg.ID) }), msgs.state.AddHandler(func(update *gateway.MessageUpdateEvent) { diff --git a/internal/discord/private/hub/sender.go b/internal/discord/private/hub/sender.go index 875e730..5b740f2 100644 --- a/internal/discord/private/hub/sender.go +++ b/internal/discord/private/hub/sender.go @@ -7,7 +7,9 @@ import ( "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/channel/message/send/complete" "github.com/diamondburned/cchat-discord/internal/discord/state" + "github.com/diamondburned/cchat-discord/internal/discord/state/nonce" "github.com/diamondburned/cchat/utils/empty" "github.com/pkg/errors" ) @@ -17,18 +19,26 @@ type ChannelAdder interface { AddChannel(state *state.Instance, ch *discord.Channel) } +// TODO: unexport Sender + type Sender struct { empty.Sender - adder ChannelAdder - acList *activeList - state *state.Instance + adder ChannelAdder + acList *activeList + sentMsgs *nonce.Set + state *state.Instance + + completers complete.Completer } -func NewSender(s *state.Instance, acList *activeList, adder ChannelAdder) *Sender { - return &Sender{adder: adder, acList: acList, state: s} -} - -var mentionRegex = regexp.MustCompile(`^<@!?(\d+)> ?`) +// mentionRegex matche the following: +// +// <#123123> +// <#!12312> // This is OK because we're not sending it. +// <@123123> +// <@!12312> +// +var mentionRegex = regexp.MustCompile(`(?m)^<(@|#)!?(\d+)> ?`) // wrappedMessage wraps around a SendableMessage to override its content. type wrappedMessage struct { @@ -48,28 +58,40 @@ func (s *Sender) Send(sendable cchat.SendableMessage) error { // Validate message. matches := mentionRegex.FindStringSubmatch(content) if matches == nil { - return errors.New("messages sent here must start with a mention") + return errors.New("message must start with a user or channel mention") } - targetID, err := discord.ParseSnowflake(matches[1]) + // TODO: account for channel names + + targetID, err := discord.ParseSnowflake(matches[2]) 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") + var channel *discord.Channel + switch matches[1] { + case "@": + channel, _ = s.state.CreatePrivateChannel(discord.UserID(targetID)) + case "#": + channel, _ = s.state.Channel(discord.ChannelID(targetID)) + } + if channel == nil { + return errors.New("unknown channel") } - s.adder.AddChannel(s.state, ch) - s.acList.add(ch.ID) + s.adder.AddChannel(s.state, channel) + s.acList.add(channel.ID) - return send.Send(s.state, ch.ID, wrappedMessage{ - SendableMessage: sendable, - content: strings.TrimPrefix(content, matches[0]), - }) + sendData := send.WrapMessage(s.state, sendable) + sendData.Content = strings.TrimPrefix(content, matches[0]) + + // Store the nonce. + s.sentMsgs.Store(sendData.Nonce) + + _, err = s.state.SendMessageComplex(channel.ID, sendData) + return errors.Wrap(err, "failed to send message") } -// func (msgs *Messages) AsCompleter() cchat.Completer { -// return complete.New(msgs) -// } +func (s *Sender) AsCompleter() cchat.Completer { + return s.completers +} diff --git a/internal/discord/private/hub/server.go b/internal/discord/private/hub/server.go index d434540..53faaf0 100644 --- a/internal/discord/private/hub/server.go +++ b/internal/discord/private/hub/server.go @@ -60,11 +60,16 @@ func (acList *activeList) isActive(channelID discord.ChannelID) bool { return ok } -func (acList *activeList) add(chID discord.ChannelID) { +func (acList *activeList) add(chID discord.ChannelID) (changed bool) { acList.mut.Lock() defer acList.mut.Unlock() + if _, ok := acList.active[chID]; ok { + return false + } + acList.active[chID] = struct{}{} + return true } // Server is the server (channel) that contains all incoming DM messages that diff --git a/internal/discord/state/nonce/nonce.go b/internal/discord/state/nonce/nonce.go new file mode 100644 index 0000000..898c496 --- /dev/null +++ b/internal/discord/state/nonce/nonce.go @@ -0,0 +1,90 @@ +package nonce + +import ( + "encoding/base64" + "encoding/binary" + "fmt" + "strconv" + "sync" + "sync/atomic" + "time" + + cryptorand "crypto/rand" + mathrand "math/rand" +) + +func init() { + mathrand.Seed(time.Now().UnixNano()) +} + +var nonceCounter uint64 + +// generateNonce generates a unique nonce ID. +func generateNonce() string { + return fmt.Sprintf( + "%s-%s-%s", + strconv.FormatInt(time.Now().Unix(), 36), + randomBits(), + strconv.FormatUint(atomic.AddUint64(&nonceCounter, 1), 36), + ) +} + +// randomBits returns a string 6 bytes long with random characters that are safe +// to print. It falls back to math/rand's pseudorandom number generator if it +// cannot read from the system entropy pool. +func randomBits() string { + randBits := make([]byte, 2) + + _, err := cryptorand.Read(randBits) + if err != nil { + binary.LittleEndian.PutUint32(randBits, mathrand.Uint32()) + } + + return base64.RawStdEncoding.EncodeToString(randBits) +} + +// Map is a nonce state that keeps track of known nonces and generates a +// Discord-compatible nonce string. +type Map sync.Map + +// Generate generates a new internal nonce, add a bind from the new nonce to the +// original nonce, then return the new nonce. If the given original nonce is +// empty, then an empty string is returned. +func (nmap *Map) Generate(original string) string { + // Ignore empty nonces. + if original == "" { + return "" + } + + newNonce := generateNonce() + (*sync.Map)(nmap).Store(newNonce, original) + return newNonce +} + +// Load grabs the nonce and permanently deleting it if the given nonce is found. +func (nmap *Map) Load(newNonce string) string { + v, ok := (*sync.Map)(nmap).LoadAndDelete(newNonce) + if ok { + return v.(string) + } + return "" +} + +// Set is a unique set of nonces. +type Set sync.Map + +var nonceSentinel = struct{}{} + +func (nset *Set) Store(nonce string) { + (*sync.Map)(nset).Store(nonce, nonceSentinel) +} + +func (nset *Set) Has(nonce string) bool { + _, ok := (*sync.Map)(nset).Load(nonce) + return ok +} + +func (nset *Set) HasAndDelete(nonce string) bool { + _, ok := (*sync.Map)(nset).LoadAndDelete(nonce) + return ok +} diff --git a/internal/discord/state/state.go b/internal/discord/state/state.go index 533b647..676588b 100644 --- a/internal/discord/state/state.go +++ b/internal/discord/state/state.go @@ -10,18 +10,20 @@ import ( "github.com/diamondburned/arikawa/state" "github.com/diamondburned/arikawa/utils/httputil/httpdriver" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/state/nonce" "github.com/diamondburned/ningen" "github.com/pkg/errors" ) type Instance struct { *ningen.State + Nonces *nonce.Map + + // UserID is a constant user ID. It is guaranteed to be valid. UserID discord.UserID } -var ( - _ cchat.SessionSaver = (*Instance)(nil) -) +var _ cchat.SessionSaver = (*Instance)(nil) // ErrInvalidSession is returned if SessionRestore is given a bad session. var ErrInvalidSession = errors.New("invalid session") @@ -58,12 +60,12 @@ 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") + 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") + return nil, errors.Wrap(err, "failed to create a state wrapper") } n.Client.OnRequest = append(n.Client.OnRequest, func(r httpdriver.Request) error { @@ -78,6 +80,7 @@ func New(s *state.State) (*Instance, error) { return &Instance{ UserID: u.ID, State: n, + Nonces: new(nonce.Map), }, nil } diff --git a/internal/segments/reference/reference.go b/internal/segments/reference/reference.go new file mode 100644 index 0000000..657d563 --- /dev/null +++ b/internal/segments/reference/reference.go @@ -0,0 +1,40 @@ +package reference + +import ( + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" +) + +type MessageID cchat.ID + +var _ text.MessageReferencer = (*MessageID)(nil) + +func (msgID MessageID) MessageID() string { + return string(msgID) +} + +type MessageSegment struct { + empty.TextSegment + start, end int + messageID discord.MessageID +} + +var _ text.Segment = (*MessageSegment)(nil) + +func NewMessageSegment(start, end int, msgID discord.MessageID) MessageSegment { + return MessageSegment{ + start: start, + end: end, + messageID: msgID, + } +} + +func (msgseg MessageSegment) Bounds() (start, end int) { + return msgseg.start, msgseg.end +} + +func (msgseg MessageSegment) AsMessageReferencer() text.MessageReferencer { + return MessageID(msgseg.messageID.String()) +}