diff --git a/channel.go b/channel.go index 74680b8..8a083c3 100644 --- a/channel.go +++ b/channel.go @@ -1,9 +1,14 @@ package discord import ( + "context" + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/segments" "github.com/diamondburned/cchat/text" + "github.com/pkg/errors" ) type Channel struct { @@ -13,7 +18,17 @@ type Channel struct { session *Session } -func NewChannel(s *Session, ch *discord.Channel) *Channel { +var ( + _ cchat.Server = (*Channel)(nil) + _ cchat.ServerMessage = (*Channel)(nil) + _ cchat.ServerMessageSender = (*Channel)(nil) + // _ cchat.ServerMessageSendCompleter = (*Channel)(nil) + _ cchat.ServerNickname = (*Channel)(nil) + // _ cchat.ServerMessageEditor = (*Channel)(nil) + // _ cchat.ServerMessageActioner = (*Channel)(nil) +) + +func NewChannel(s *Session, ch discord.Channel) *Channel { return &Channel{ id: ch.ID, guildID: ch.GuildID, @@ -30,6 +45,128 @@ func (ch *Channel) Name() text.Rich { return text.Rich{Content: "#" + ch.name} } -func (ch *Channel) Nickname(labeler cchat.LabelContainer) error { +func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) error { + // We don't have a nickname if we're not in a guild. + if !ch.guildID.Valid() { + return nil + } + state := ch.session.WithContext(ctx) + + // MemberColor should fill up the state cache. + c, err := state.MemberColor(ch.guildID, ch.session.userID) + if err != nil { + return errors.Wrap(err, "Failed to get self member color") + } + + m, err := state.Member(ch.guildID, ch.session.userID) + if err != nil { + return errors.Wrap(err, "Failed to get self member") + } + + var rich = text.Rich{Content: m.User.Username} + if m.Nick != "" { + rich.Content = m.Nick + } + if c > 0 { + rich.Segments = []text.Segment{ + segments.NewColored(len(rich.Content), c.Uint32()), + } + } + + labeler.SetLabel(rich) + return nil +} + +func (ch *Channel) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { + state := ch.session.WithContext(ctx) + + m, err := state.Messages(ch.id) + if err != nil { + return nil, err + } + + var addcancel = newCancels() + + if ch.guildID.Valid() { + // Create the backlog without any member information. + g, err := state.Guild(ch.guildID) + if err != nil { + return nil, errors.Wrap(err, "Failed to get guild") + } + + // Listen to new members before creating the backlog and requesting members. + addcancel(ch.session.AddHandler(func(c *gateway.GuildMembersChunkEvent) { + m, err := ch.session.Store.Messages(ch.id) + if err != nil { + // TODO: log + return + } + + g, err := ch.session.Store.Guild(c.GuildID) + if err != nil { + return + } + + for _, member := range c.Members { + // Loop over all messages and replace the author. + for _, msg := range m { + if msg.Author.ID != member.User.ID { + continue + } + + ct.UpdateMessage(NewMessageUpdateAuthor(msg, member, *g)) + } + } + })) + + for _, m := range m { + ct.CreateMessage(NewBacklogMessage(m, ch.session, *g)) + } + + } else { + for _, m := range m { + ct.CreateMessage(NewDirectMessage(m)) + } + } + + // Bind the handler. + addcancel( + ch.session.AddHandler(func(m *gateway.MessageCreateEvent) { + ct.CreateMessage(NewMessageWithMember(m.Message, ch.session, m.Member)) + }), + ch.session.AddHandler(func(m *gateway.MessageUpdateEvent) { + // If the updated content is empty. TODO: add embed support. + if m.Content == "" { + return + } + ct.UpdateMessage(NewMessageUpdateContent(m.Message)) + }), + ch.session.AddHandler(func(m *gateway.MessageDeleteEvent) { + ct.DeleteMessage(NewHeaderDelete(m)) + }), + ) + + return joinCancels(addcancel()), nil +} + +func (ch *Channel) SendMessage(msg cchat.SendableMessage) error { + _, err := ch.session.SendText(ch.id, msg.Content()) + return err +} + +func newCancels() func(...func()) []func() { + var cancels []func() + return func(appended ...func()) []func() { + cancels = append(cancels, appended...) + return cancels + } +} + +func joinCancels(cancellers []func()) func() { + return func() { + for _, c := range cancellers { + c() + } + } } diff --git a/go.mod b/go.mod index c2b95d2..c12ca70 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.14 require ( github.com/diamondburned/arikawa v0.9.4 - github.com/diamondburned/cchat v0.0.26 + github.com/diamondburned/cchat v0.0.28 + github.com/diamondburned/ningen v0.0.0-20200610212436-159f7105a2be github.com/pkg/errors v0.9.1 ) diff --git a/go.sum b/go.sum index 579e98e..d929253 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,21 @@ +github.com/diamondburned/arikawa v0.8.7-0.20200522214036-530bff74a2c6/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/arikawa v0.9.4 h1:Mrp0Vz9R2afbvhWS6m/oLIQy22/uxXb459LUv7qrZPA= github.com/diamondburned/arikawa v0.9.4/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/cchat v0.0.26 h1:QBt4d65uzUPJz3jF8b2pJ09Jz8LeBRyG2ol47FOy0g0= github.com/diamondburned/cchat v0.0.26/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.28 h1:+1VnltW0rl8/NZTUP+x89jVhi3YTTR+e6iLprZ7HcwM= +github.com/diamondburned/cchat v0.0.28/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/ningen v0.0.0-20200610212436-159f7105a2be h1:mUw8X/YzJGFSdL8y3Q/XqyzqPyIMNVSDyZGOP3JXgJA= +github.com/diamondburned/ningen v0.0.0-20200610212436-159f7105a2be/go.mod h1:B2hq2B4va1MlnMmXuv9vXmyu9gscxJLmwrmcSB1Les8= github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/twmb/murmur3 v1.1.3 h1:D83U0XYKcHRYwYIpBKf3Pks91Z0Byda/9SJ8B6EMRcA= +github.com/twmb/murmur3 v1.1.3/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/guild.go b/guild.go index d4b370b..5053719 100644 --- a/guild.go +++ b/guild.go @@ -1,37 +1,78 @@ package discord import ( + "context" + "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat/text" + "github.com/pkg/errors" ) type Guild struct { id discord.Snowflake - name string session *Session } var ( - _ cchat.Server = (*Guild)(nil) + _ cchat.Icon = (*Guild)(nil) + _ cchat.Server = (*Guild)(nil) + _ cchat.ServerList = (*Guild)(nil) ) func NewGuild(s *Session, g *discord.Guild) *Guild { return &Guild{ id: g.ID, - name: g.Name, session: s, } } +func (g *Guild) self(ctx context.Context) (*discord.Guild, error) { + return g.session.WithContext(ctx).Guild(g.id) +} + +func (g *Guild) selfState() (*discord.Guild, error) { + return g.session.Store.Guild(g.id) +} + func (g *Guild) ID() string { return g.id.String() } func (g *Guild) Name() text.Rich { - return text.Rich{Content: g.name} + s, err := g.selfState() + if err != nil { + // This shouldn't happen. + return text.Rich{Content: g.id.String()} + } + + return text.Rich{Content: s.Name} } -func (g *Guild) Guilds(container cchat.ServersContainer) error { +func (g *Guild) Icon(ctx context.Context, iconer cchat.IconContainer) error { + s, err := g.self(ctx) + if err != nil { + // This shouldn't happen. + return errors.Wrap(err, "Failed to get guild") + } + + if s.Icon != "" { + iconer.SetIcon(s.IconURL() + "?size=64") + } + return nil +} + +func (g *Guild) Servers(container cchat.ServersContainer) error { + c, err := g.session.Channels(g.id) + if err != nil { + return errors.Wrap(err, "Failed to get channels") + } + + var channels = make([]cchat.Server, len(c)) + for i := range c { + channels[i] = NewChannel(g.session, c[i]) + } + + container.SetServers(channels) return nil } diff --git a/message.go b/message.go new file mode 100644 index 0000000..08ff19f --- /dev/null +++ b/message.go @@ -0,0 +1,193 @@ +package discord + +import ( + "time" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/arikawa/gateway" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/segments" + "github.com/diamondburned/cchat/text" +) + +type messageHeader struct { + id discord.Snowflake + time discord.Timestamp + channelID discord.Snowflake + guildID discord.Snowflake + nonce string +} + +var _ cchat.MessageHeader = (*messageHeader)(nil) + +func newHeader(msg discord.Message) messageHeader { + var h = messageHeader{ + id: msg.ID, + time: msg.Timestamp, + channelID: msg.ChannelID, + guildID: msg.GuildID, + nonce: msg.Nonce, + } + if msg.EditedTimestamp.Valid() { + h.time = msg.EditedTimestamp + } + return h +} + +func NewHeaderDelete(d *gateway.MessageDeleteEvent) messageHeader { + return messageHeader{ + id: d.ID, + time: discord.Timestamp(time.Now()), + channelID: d.ChannelID, + guildID: d.GuildID, + } +} + +func (m messageHeader) ID() string { + return m.id.String() +} + +func (m messageHeader) Time() time.Time { + return m.time.Time() +} + +type Author struct { + id discord.Snowflake + name text.Rich + avatar string +} + +func NewUser(u discord.User) Author { + return Author{ + id: u.ID, + name: text.Rich{Content: u.Username}, + avatar: u.AvatarURL() + "?size=128", + } +} + +func NewGuildMember(m discord.Member, g discord.Guild) Author { + var name = text.Rich{ + Content: m.User.Username, + } + + // Update the nickname. + if m.Nick != "" { + name.Content = m.Nick + } + + // Update the color. + if c := discord.MemberColor(g, m); c > 0 { + name.Segments = []text.Segment{ + segments.NewColored(len(name.Content), c.Uint32()), + } + } + + return Author{ + id: m.User.ID, + name: name, + avatar: m.User.AvatarURL() + "?size=128", + } +} + +func (a Author) ID() string { + return a.id.String() +} + +func (a Author) Name() text.Rich { + return a.name +} + +func (a Author) Avatar() string { + return a.avatar +} + +type Message struct { + messageHeader + + author Author + content text.Rich + + // TODO + mentioned bool +} + +func NewMessageUpdateContent(msg discord.Message) Message { + return Message{ + messageHeader: newHeader(msg), + content: text.Rich{Content: msg.Content}, + } +} + +func NewMessageUpdateAuthor(msg discord.Message, member discord.Member, g discord.Guild) Message { + return Message{ + messageHeader: newHeader(msg), + author: NewGuildMember(member, g), + } +} + +// NewMessageWithSession uses the session to create a message. It does not do +// API calls. Member is optional. +func NewMessageWithMember(m discord.Message, s *Session, mem *discord.Member) Message { + // This should not error. + g, err := s.Store.Guild(m.GuildID) + if err != nil { + return NewMessage(m, NewUser(m.Author)) + } + + if mem == nil { + mem, _ = s.Store.Member(m.GuildID, m.Author.ID) + } + if mem == nil { + s.Members.RequestMember(m.GuildID, m.Author.ID) + return NewMessage(m, NewUser(m.Author)) + } + + return NewMessage(m, NewGuildMember(*mem, *g)) +} + +// NewBacklogMessage uses the session to create a message fetched from the +// backlog. It takes in an existing guild and tries to fetch a new member, if +// it's nil. +func NewBacklogMessage(m discord.Message, s *Session, g discord.Guild) Message { + // If the message doesn't have a guild, then we don't need all the + // complicated member fetching process. + if !m.GuildID.Valid() { + return NewMessage(m, NewUser(m.Author)) + } + + mem, err := s.Store.Member(m.GuildID, m.Author.ID) + if err != nil { + s.Members.RequestMember(m.GuildID, m.Author.ID) + return NewMessage(m, NewUser(m.Author)) + } + + return NewMessage(m, NewGuildMember(*mem, g)) +} + +func NewDirectMessage(m discord.Message) Message { + return NewMessage(m, NewUser(m.Author)) +} + +func NewMessage(m discord.Message, author Author) Message { + return Message{ + messageHeader: newHeader(m), + author: author, + content: text.Rich{Content: m.Content}, + } +} + +func (m Message) Author() cchat.MessageAuthor { + return m.author +} + +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/segments/segments.go b/segments/segments.go new file mode 100644 index 0000000..712ca29 --- /dev/null +++ b/segments/segments.go @@ -0,0 +1,25 @@ +package segments + +import "github.com/diamondburned/cchat/text" + +type Colored struct { + strlen int + color uint32 +} + +var ( + _ text.Colorer = (*Colored)(nil) + _ text.Segment = (*Colored)(nil) +) + +func NewColored(strlen int, color uint32) Colored { + return Colored{strlen, color} +} + +func (color Colored) Bounds() (start, end int) { + return 0, color.strlen +} + +func (color Colored) Color() uint32 { + return color.color +} diff --git a/service.go b/service.go index 232d5c7..4d36b83 100644 --- a/service.go +++ b/service.go @@ -1,24 +1,28 @@ package discord import ( + "context" + + "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/state" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/ningen" "github.com/pkg/errors" ) type Service struct{} var ( - _ cchat.Service = (*Service)(nil) _ cchat.Icon = (*Service)(nil) + _ cchat.Service = (*Service)(nil) ) func (Service) Name() text.Rich { return text.Rich{Content: "Discord"} } -func (Service) Icon(iconer cchat.IconContainer) error { +func (Service) Icon(ctx context.Context, iconer cchat.IconContainer) error { iconer.SetIcon("https://discord.com/assets/2c21aeda16de354ba5334551a883b481.png") return nil } @@ -51,33 +55,60 @@ func (Authenticator) Authenticate(form []string) (cchat.Session, error) { return nil, err } + return NewSession(s) +} + +type Session struct { + *ningen.State + userID discord.Snowflake +} + +var ( + _ cchat.Icon = (*Session)(nil) + _ cchat.Session = (*Session)(nil) + _ cchat.ServerList = (*Session)(nil) + _ cchat.SessionSaver = (*Session)(nil) +) + +func NewSession(s *state.State) (*Session, error) { // Prefetch user. - _, err = s.Me() + u, err := s.Me() if err != nil { return nil, errors.Wrap(err, "Failed to get current user") } + n, err := ningen.FromState(s) + if err != nil { + return nil, errors.Wrap(err, "Failed to create a state wrapper") + } + return &Session{ - State: s, + userID: u.ID, + State: n, }, nil } -type Session struct { - *state.State -} - func (s *Session) ID() string { - u, _ := s.Store.Me() - return u.ID.String() + return s.userID.String() } func (s *Session) Name() text.Rich { - u, _ := s.Store.Me() + u, err := s.Store.Me() + if err != nil { + // This shouldn't happen, ever. + return text.Rich{Content: "<@" + s.userID.String() + ">"} + } + return text.Rich{Content: u.Username + "#" + u.Discriminator} } -func (s *Session) Icon(iconer cchat.IconContainer) error { - u, _ := s.Store.Me() +func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) error { + u, err := s.Store.Me() + if err != nil { + return errors.Wrap(err, "Failed to get the current user") + } + + // Thanks to arikawa, AvatarURL is never empty. iconer.SetIcon(u.AvatarURL()) return nil }