WIP tweaks

This commit is contained in:
diamondburned 2020-12-18 21:46:12 -08:00
parent 1155ccac34
commit 6c1a11706f
20 changed files with 749 additions and 208 deletions

3
go.mod
View File

@ -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

9
go.sum
View File

@ -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=

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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]
})
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 "<Nobody>"
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

View File

@ -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),
)
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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())
}