diff --git a/discord.go b/discord.go index 4ccb2a3..0c3dd1d 100644 --- a/discord.go +++ b/discord.go @@ -1,79 +1,36 @@ package discord import ( - "context" - "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/authenticate" "github.com/diamondburned/cchat-discord/internal/discord/session" "github.com/diamondburned/cchat/services" "github.com/diamondburned/cchat/text" - "github.com/pkg/errors" + "github.com/diamondburned/cchat/utils/empty" ) +var service cchat.Service = Service{} + func init() { - services.RegisterService(&Service{}) + services.RegisterService(service) } -// ErrInvalidSession is returned if SessionRestore is given a bad session. -var ErrInvalidSession = errors.New("invalid session") - -type Service struct{} - -var ( - _ cchat.Iconer = (*Service)(nil) - _ cchat.Service = (*Service)(nil) -) +type Service struct { + empty.Service +} func (Service) Name() text.Rich { return text.Rich{Content: "Discord"} } -// IsIconer returns true. -func (Service) IsIconer() bool { return true } - -func (Service) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { - iconer.SetIcon("https://raw.githubusercontent.com/" + - "diamondburned/cchat-discord/himearikawa/discord_logo.png") - return func() {}, nil -} - func (Service) Authenticate() cchat.Authenticator { - return &Authenticator{} + return authenticate.New() } -func (s Service) RestoreSession(data map[string]string) (cchat.Session, error) { - tk, ok := data["token"] - if !ok { - return nil, ErrInvalidSession - } - - return session.NewFromToken(tk) +func (Service) AsIconer() cchat.Iconer { + return Logo } -type Authenticator struct{} - -var _ cchat.Authenticator = (*Authenticator)(nil) - -func (*Authenticator) AuthenticateForm() []cchat.AuthenticateEntry { - // TODO: username, password and 2FA - return []cchat.AuthenticateEntry{ - { - Name: "Token", - Secret: true, - }, - { - Name: "(or) Username", - }, - } -} - -func (*Authenticator) Authenticate(form []string) (cchat.Session, error) { - switch { - case form[0] != "": // Token - return session.NewFromToken(form[0]) - case form[1] != "": // Username - return nil, errors.New("username sign-in is not supported yet") - } - - return nil, errors.New("malformed authentication form") +func (Service) AsSessionRestorer() cchat.SessionRestorer { + return session.Restorer } diff --git a/internal/discord/authenticate/authenticator.go b/internal/discord/authenticate/authenticator.go new file mode 100644 index 0000000..94da2cd --- /dev/null +++ b/internal/discord/authenticate/authenticator.go @@ -0,0 +1,117 @@ +package authenticate + +import ( + "errors" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/session" + "github.com/diamondburned/cchat-discord/internal/discord/state" +) + +var ( + ErrMalformed = errors.New("malformed authentication form") + EnterPassword = errors.New("enter your password") +) + +type Authenticator struct { + username string + password string +} + +func New() cchat.Authenticator { + return &Authenticator{} +} + +func (a *Authenticator) stage() int { + switch { + // Stage 1: Prompt for the token OR username. + case a.username == "" && a.password == "": + return 0 + + // Stage 2: Prompt for the password. + case a.password == "": + return 1 + + // Stage 3: Prompt for the TOTP token. + default: + return 2 + } +} + +func (a *Authenticator) AuthenticateForm() []cchat.AuthenticateEntry { + switch a.stage() { + case 0: + return []cchat.AuthenticateEntry{ + {Name: "Token", Secret: true}, + {Name: "Username", Description: "Fill either Token or Username only."}, + } + case 1: + return []cchat.AuthenticateEntry{ + {Name: "Password", Secret: true}, + } + case 2: + return []cchat.AuthenticateEntry{ + {Name: "Auth Code", Description: "6-digit code for Two-factor Authentication."}, + } + default: + return nil + } +} + +func (a *Authenticator) Authenticate(form []string) (cchat.Session, error) { + switch a.stage() { + case 0: + if len(form) != 2 { + return nil, ErrMalformed + } + + switch { + case form[0] != "": // Token + i, err := state.NewFromToken(form[0]) + if err != nil { + return nil, err + } + + return session.NewFromInstance(i) + + case form[1] != "": // Username + // Move to a new stage. + a.username = form[1] + return nil, EnterPassword + } + + case 1: + if len(form) != 1 { + return nil, ErrMalformed + } + + a.password = form[0] + + i, err := state.Login(a.username, a.password, "") + if err != nil { + // If the error is not ErrMFA, then we should reset password to + // empty. + if !errors.Is(err, session.ErrMFA) { + a.password = "" + } + + return nil, err + } + + return session.NewFromInstance(i) + + case 2: + if len(form) != 1 { + return nil, ErrMalformed + } + + i, err := state.Login(a.username, a.password, form[0]) + if err != nil { + return nil, err + } + + return session.NewFromInstance(i) + } + + return nil, ErrMalformed +} diff --git a/internal/discord/category/category.go b/internal/discord/category/category.go index b475373..7ebe1e3 100644 --- a/internal/discord/category/category.go +++ b/internal/discord/category/category.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/cchat-discord/internal/discord/channel" "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" "github.com/pkg/errors" ) @@ -57,17 +58,13 @@ func FilterCategory(chs []discord.Channel, catID discord.ChannelID) []discord.Ch } type Category struct { + empty.Server id discord.ChannelID guildID discord.GuildID state *state.Instance } -var ( - _ cchat.Server = (*Category)(nil) - _ cchat.Lister = (*Category)(nil) -) - -func New(s *state.Instance, ch discord.Channel) *Category { +func New(s *state.Instance, ch discord.Channel) cchat.Server { return &Category{ id: ch.ID, guildID: ch.GuildID, @@ -91,9 +88,7 @@ func (c *Category) Name() text.Rich { } } -func (c *Category) IsLister() bool { - return true -} +func (c *Category) AsLister() cchat.Lister { return c } func (c *Category) Servers(container cchat.ServersContainer) error { t, err := c.state.Channels(c.guildID) diff --git a/internal/discord/channel/backlogger.go b/internal/discord/channel/backlogger.go deleted file mode 100644 index d95e2e3..0000000 --- a/internal/discord/channel/backlogger.go +++ /dev/null @@ -1,52 +0,0 @@ -package channel - -import ( - "context" - - "github.com/diamondburned/arikawa/discord" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/discord/message" - "github.com/pkg/errors" -) - -var _ cchat.Backlogger = (*Channel)(nil) - -// IsBacklogger returns true if the current user can read the channel's message -// history. -func (ch *Channel) IsBacklogger() bool { - p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID) - if err != nil { - return false - } - - return p.Has(discord.PermissionViewChannel) && p.Has(discord.PermissionReadMessageHistory) -} - -func (ch *Channel) MessagesBefore(ctx context.Context, b cchat.ID, c cchat.MessagePrepender) error { - p, err := discord.ParseSnowflake(b) - if err != nil { - return errors.Wrap(err, "Failed to parse snowflake") - } - - s := ch.state.WithContext(ctx) - - m, err := s.MessagesBefore(ch.id, discord.MessageID(p), uint(ch.state.MaxMessages())) - if err != nil { - return errors.Wrap(err, "Failed to get messages") - } - - // Create the backlog without any member information. - g, err := s.Guild(ch.guildID) - if err != nil { - return errors.Wrap(err, "Failed to get guild") - } - - for _, m := range m { - // Discord sucks. - m.GuildID = ch.guildID - - c.PrependMessage(message.NewBacklogMessage(m, ch.state, *g)) - } - - return nil -} diff --git a/internal/discord/channel/channel.go b/internal/discord/channel/channel.go index d261c29..f470333 100644 --- a/internal/discord/channel/channel.go +++ b/internal/discord/channel/channel.go @@ -3,15 +3,17 @@ package channel import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" "github.com/pkg/errors" ) type Channel struct { - id discord.ChannelID - guildID discord.GuildID - state *state.Instance + *empty.Server + *shared.Channel } var _ cchat.Server = (*Channel)(nil) @@ -23,38 +25,40 @@ func New(s *state.Instance, ch discord.Channel) (cchat.Server, error) { return nil, errors.Wrap(err, "Failed to get permission") } - return &Channel{ - id: ch.ID, - guildID: ch.GuildID, - state: s, + return Channel{ + Channel: &shared.Channel{ + ID: ch.ID, + GuildID: ch.GuildID, + State: s, + }, }, nil } // self does not do IO. -func (ch *Channel) self() (*discord.Channel, error) { - return ch.state.Store.Channel(ch.id) +func (ch Channel) self() (*discord.Channel, error) { + return ch.State.Store.Channel(ch.Channel.ID) } // messages does not do IO. -func (ch *Channel) messages() ([]discord.Message, error) { - return ch.state.Store.Messages(ch.id) +func (ch Channel) messages() ([]discord.Message, error) { + return ch.State.Store.Messages(ch.Channel.ID) } -func (ch *Channel) guild() (*discord.Guild, error) { - if ch.guildID.IsValid() { - return ch.state.Store.Guild(ch.guildID) +func (ch Channel) guild() (*discord.Guild, error) { + if ch.GuildID.IsValid() { + return ch.State.Store.Guild(ch.GuildID) } return nil, errors.New("channel not in a guild") } -func (ch *Channel) ID() cchat.ID { - return ch.id.String() +func (ch Channel) ID() cchat.ID { + return ch.Channel.ID.String() } -func (ch *Channel) Name() text.Rich { +func (ch Channel) Name() text.Rich { c, err := ch.self() if err != nil { - return text.Rich{Content: ch.id.String()} + return text.Rich{Content: ch.Channel.ID.String()} } if c.NSFW { @@ -63,3 +67,11 @@ func (ch *Channel) Name() text.Rich { return text.Rich{Content: "#" + c.Name} } } + +func (ch Channel) AsMessenger() cchat.Messenger { + if !ch.HasPermission(discord.PermissionViewChannel) { + return nil + } + + return message.New(ch.Channel) +} diff --git a/internal/discord/channel/indicators.go b/internal/discord/channel/indicators.go deleted file mode 100644 index 8202678..0000000 --- a/internal/discord/channel/indicators.go +++ /dev/null @@ -1,70 +0,0 @@ -package channel - -import ( - "time" - - "github.com/diamondburned/arikawa/gateway" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/discord/channel/typer" - "github.com/diamondburned/ningen/states/read" - "github.com/pkg/errors" -) - -var ( - _ cchat.TypingIndicator = (*Channel)(nil) - _ cchat.UnreadIndicator = (*Channel)(nil) -) - -// IsTypingIndicator returns true. -func (ch *Channel) IsTypingIndicator() bool { return true } - -func (ch *Channel) Typing() error { - return ch.state.Typing(ch.id) -} - -// TypingTimeout returns 10 seconds. -func (ch *Channel) TypingTimeout() time.Duration { - return 10 * time.Second -} - -func (ch *Channel) TypingSubscribe(ti cchat.TypingContainer) (func(), error) { - return ch.state.AddHandler(func(t *gateway.TypingStartEvent) { - // Ignore channel mismatch or if the typing event is ours. - if t.ChannelID != ch.id || t.UserID == ch.state.UserID { - return - } - if typer, err := typer.New(ch.state, t); err == nil { - ti.AddTyper(typer) - } - }), nil -} - -// muted returns if this channel is muted. This includes the channel's category -// and guild. -func (ch *Channel) muted() bool { - return (ch.guildID.IsValid() && ch.state.MutedState.Guild(ch.guildID, false)) || - ch.state.MutedState.Channel(ch.id) || - ch.state.MutedState.Category(ch.id) -} - -// IsUnreadIndicator returns true. -func (ch *Channel) IsUnreadIndicator() bool { return true } - -func (ch *Channel) UnreadIndicate(indicator cchat.UnreadContainer) (func(), error) { - if rs := ch.state.ReadState.FindLast(ch.id); rs != nil { - c, err := ch.self() - if err != nil { - return nil, errors.Wrap(err, "Failed to get self channel") - } - - if c.LastMessageID > rs.LastMessageID && !ch.muted() { - indicator.SetUnread(true, rs.MentionCount > 0) - } - } - - return ch.state.ReadState.OnUpdate(func(ev *read.UpdateEvent) { - if ch.id == ev.ChannelID && !ch.muted() { - indicator.SetUnread(ev.Unread, ev.MentionCount > 0) - } - }), nil -} diff --git a/internal/discord/channel/memberlist/memberlist.go b/internal/discord/channel/memberlist/memberlist.go deleted file mode 100644 index 2f5c318..0000000 --- a/internal/discord/channel/memberlist/memberlist.go +++ /dev/null @@ -1,36 +0,0 @@ -package memberlist - -import ( - "github.com/diamondburned/arikawa/discord" - "github.com/diamondburned/arikawa/gateway" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/discord/state" - "github.com/diamondburned/ningen/states/member" -) - -type Channel struct { - // Keep stateful references to do on-demand loading. - state *state.Instance - // constant states - channelID discord.ChannelID - guildID discord.GuildID -} - -func NewChannel(s *state.Instance, ch discord.ChannelID, g discord.GuildID) Channel { - return Channel{ - state: s, - channelID: ch, - guildID: g, - } -} - -func (ch Channel) FlushMemberGroups(l *member.List, c cchat.MemberListContainer) { - l.ViewGroups(func(groups []gateway.GuildMemberListGroup) { - var sections = make([]cchat.MemberSection, len(groups)) - for i, group := range groups { - sections[i] = ch.NewSection(l.ID(), group) - } - - c.SetSections(sections) - }) -} diff --git a/internal/discord/channel/memberlist/section.go b/internal/discord/channel/memberlist/section.go deleted file mode 100644 index 5bffd84..0000000 --- a/internal/discord/channel/memberlist/section.go +++ /dev/null @@ -1,91 +0,0 @@ -package memberlist - -import ( - "fmt" - - "github.com/diamondburned/arikawa/discord" - "github.com/diamondburned/arikawa/gateway" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat/text" -) - -type Section struct { - Channel - - // constant states - listID string - id string // roleID or online or offline - name string - total int -} - -var ( - _ cchat.MemberSection = (*Section)(nil) - _ cchat.MemberDynamicSection = (*Section)(nil) -) - -func (ch Channel) NewSection(listID string, group gateway.GuildMemberListGroup) *Section { - var name string - - switch group.ID { - case "online": - name = "Online" - case "offline": - name = "Offline" - default: - p, err := discord.ParseSnowflake(group.ID) - if err != nil { - name = group.ID - } else { - r, err := ch.state.Role(ch.guildID, discord.RoleID(p)) - if err != nil { - name = fmt.Sprintf("<@#%s>", p.String()) - } else { - name = r.Name - } - } - } - - return &Section{ - Channel: ch, - listID: listID, - id: group.ID, - name: name, - total: int(group.Count), - } -} - -func (s *Section) ID() cchat.ID { - return s.id -} - -func (s *Section) Name() text.Rich { - return text.Rich{Content: s.name} -} - -func (s *Section) Total() int { - return s.total -} - -func (s *Section) IsMemberDynamicSection() bool { return true } - -// TODO: document that Load{More,Less} works more like a shifting window. - -func (s *Section) LoadMore() bool { - chunk := s.state.MemberState.GetMemberListChunk(s.guildID, s.channelID) - if chunk < 0 { - chunk = 0 - } - - return s.state.MemberState.RequestMemberList(s.guildID, s.channelID, chunk) != nil -} - -func (s *Section) LoadLess() bool { - chunk := s.state.MemberState.GetMemberListChunk(s.guildID, s.channelID) - if chunk <= 0 { - return false - } - - s.state.MemberState.RequestMemberList(s.guildID, s.channelID, chunk-1) - return true -} diff --git a/internal/discord/channel/actioner.go b/internal/discord/channel/message/action/actioner.go similarity index 66% rename from internal/discord/channel/actioner.go rename to internal/discord/channel/message/action/actioner.go index 7d82a58..11ffa6a 100644 --- a/internal/discord/channel/actioner.go +++ b/internal/discord/channel/message/action/actioner.go @@ -1,15 +1,21 @@ -package channel +package action import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" "github.com/pkg/errors" ) -var _ cchat.Actioner = (*Channel)(nil) +type Actioner struct { + *shared.Channel +} -// IsActioner returns true. -func (ch *Channel) IsActioner() bool { return true } +var _ cchat.Actioner = (*Actioner)(nil) + +func New(ch *shared.Channel) Actioner { + return Actioner{ch} +} const ( ActionDelete = "Delete" @@ -17,7 +23,7 @@ const ( var ErrUnknownAction = errors.New("unknown message action") -func (ch *Channel) DoMessageAction(action, id string) error { +func (ac Actioner) DoAction(action, id string) error { s, err := discord.ParseSnowflake(id) if err != nil { return errors.Wrap(err, "Failed to parse ID") @@ -25,25 +31,25 @@ func (ch *Channel) DoMessageAction(action, id string) error { switch action { case ActionDelete: - return ch.state.DeleteMessage(ch.id, discord.MessageID(s)) + return ac.State.DeleteMessage(ac.ID, discord.MessageID(s)) default: return ErrUnknownAction } } -func (ch *Channel) MessageActions(id string) []string { +func (ac Actioner) Actions(id string) []string { s, err := discord.ParseSnowflake(id) if err != nil { return nil } - m, err := ch.state.Store.Message(ch.id, discord.MessageID(s)) + m, err := ac.State.Store.Message(ac.ID, discord.MessageID(s)) if err != nil { return nil } // Get the current user. - u, err := ch.state.Store.Me() + u, err := ac.State.Store.Me() if err != nil { return nil } @@ -54,7 +60,7 @@ func (ch *Channel) MessageActions(id string) []string { // We also can if we have the Manage Messages permission, which would allow // us to delete others' messages. if !canDelete { - canDelete = ch.canManageMessages(u.ID) + canDelete = ac.canManageMessages(u.ID) } if canDelete { @@ -66,26 +72,26 @@ func (ch *Channel) MessageActions(id string) []string { // canManageMessages returns whether or not the user is allowed to manage // messages. -func (ch *Channel) canManageMessages(userID discord.UserID) bool { +func (ac Actioner) canManageMessages(userID discord.UserID) bool { // If we're not in a guild, then clearly we cannot. - if !ch.guildID.IsValid() { + if !ac.GuildID.IsValid() { return false } // We need the guild, member and channel to calculate the permission // overrides. - g, err := ch.guild() + g, err := ac.Guild() if err != nil { return false } - c, err := ch.self() + c, err := ac.Self() if err != nil { return false } - m, err := ch.state.Store.Member(ch.guildID, userID) + m, err := ac.State.Store.Member(ac.GuildID, userID) if err != nil { return false } diff --git a/internal/discord/channel/message/backlog/backlogger.go b/internal/discord/channel/message/backlog/backlogger.go new file mode 100644 index 0000000..b61c9df --- /dev/null +++ b/internal/discord/channel/message/backlog/backlogger.go @@ -0,0 +1,52 @@ +package backlog + +import ( + "context" + + "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/message" + "github.com/pkg/errors" +) + +type Backlogger struct { + *shared.Channel +} + +func New(ch *shared.Channel) cchat.Backlogger { + return Backlogger{ch} +} + +func (bl Backlogger) MessagesBefore( + ctx context.Context, + b cchat.ID, + c cchat.MessagesContainer) error { + + p, err := discord.ParseSnowflake(b) + if err != nil { + return errors.Wrap(err, "Failed to parse snowflake") + } + + s := bl.State.WithContext(ctx) + + m, err := s.MessagesBefore(bl.ID, discord.MessageID(p), uint(bl.State.MaxMessages())) + if err != nil { + return errors.Wrap(err, "Failed to get messages") + } + + // Create the backlog without any member information. + g, err := s.Guild(bl.GuildID) + if err != nil { + return errors.Wrap(err, "Failed to get guild") + } + + for _, m := range m { + // Discord sucks. + m.GuildID = bl.GuildID + + c.CreateMessage(message.NewBacklogMessage(m, bl.State, *g)) + } + + return nil +} diff --git a/internal/discord/channel/editor.go b/internal/discord/channel/message/edit/editor.go similarity index 52% rename from internal/discord/channel/editor.go rename to internal/discord/channel/message/edit/editor.go index e4e9832..ba70e02 100644 --- a/internal/discord/channel/editor.go +++ b/internal/discord/channel/message/edit/editor.go @@ -1,47 +1,44 @@ -package channel +package edit import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" "github.com/pkg/errors" ) -var _ cchat.Editor = (*Channel)(nil) +type Editor struct { + *shared.Channel +} -// IsEditor returns true if the user can send messages in this channel. -func (ch *Channel) IsEditor() bool { - p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID) - if err != nil { - return false - } - - return p.Has(discord.PermissionSendMessages) +func New(ch *shared.Channel) cchat.Editor { + return Editor{ch} } // MessageEditable returns true if the given message ID belongs to the current // user. -func (ch *Channel) MessageEditable(id string) bool { +func (ed Editor) MessageEditable(id string) bool { s, err := discord.ParseSnowflake(id) if err != nil { return false } - m, err := ch.state.Store.Message(ch.id, discord.MessageID(s)) + m, err := ed.State.Store.Message(ed.ID, discord.MessageID(s)) if err != nil { return false } - return m.Author.ID == ch.state.UserID + return m.Author.ID == ed.State.UserID } // RawMessageContent returns the raw message content from Discord. -func (ch *Channel) RawMessageContent(id string) (string, error) { +func (ed Editor) RawMessageContent(id string) (string, error) { s, err := discord.ParseSnowflake(id) if err != nil { return "", errors.Wrap(err, "Failed to parse ID") } - m, err := ch.state.Store.Message(ch.id, discord.MessageID(s)) + m, err := ed.State.Store.Message(ed.ID, discord.MessageID(s)) if err != nil { return "", errors.Wrap(err, "Failed to get the message") } @@ -50,12 +47,12 @@ func (ch *Channel) RawMessageContent(id string) (string, error) { } // EditMessage edits the message to the given content string. -func (ch *Channel) EditMessage(id, content string) error { +func (ed Editor) EditMessage(id, content string) error { s, err := discord.ParseSnowflake(id) if err != nil { return errors.Wrap(err, "Failed to parse ID") } - _, err = ch.state.EditText(ch.id, discord.MessageID(s), content) + _, err = ed.State.EditText(ed.ID, discord.MessageID(s), content) return err } diff --git a/internal/discord/channel/message/indicate/typing.go b/internal/discord/channel/message/indicate/typing.go new file mode 100644 index 0000000..45bd06d --- /dev/null +++ b/internal/discord/channel/message/indicate/typing.go @@ -0,0 +1,39 @@ +package indicate + +import ( + "time" + + "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/channel/typer" +) + +type TypingIndicator struct { + *shared.Channel +} + +func NewTyping(ch *shared.Channel) cchat.TypingIndicator { + return TypingIndicator{ch} +} + +func (ti TypingIndicator) Typing() error { + return ti.State.Typing(ti.ID) +} + +// TypingTimeout returns 10 seconds. +func (ti TypingIndicator) TypingTimeout() time.Duration { + return 10 * time.Second +} + +func (ti TypingIndicator) TypingSubscribe(tc cchat.TypingContainer) (func(), error) { + return ti.State.AddHandler(func(t *gateway.TypingStartEvent) { + // Ignore channel mismatch or if the typing event is ours. + if t.ChannelID != ti.ID || t.UserID == ti.State.UserID { + return + } + if typer, err := typer.New(ti.State, t); err == nil { + tc.AddTyper(typer) + } + }), nil +} diff --git a/internal/discord/channel/message/indicate/unread.go b/internal/discord/channel/message/indicate/unread.go new file mode 100644 index 0000000..d2dceec --- /dev/null +++ b/internal/discord/channel/message/indicate/unread.go @@ -0,0 +1,43 @@ +package indicate + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/diamondburned/ningen/states/read" + "github.com/pkg/errors" +) + +type UnreadIndicator struct { + *shared.Channel +} + +func NewUnread(ch *shared.Channel) cchat.UnreadIndicator { + return UnreadIndicator{ch} +} + +// Muted returns if this channel is muted. This includes the channel's category +// and guild. +func (ui UnreadIndicator) Muted() bool { + return (ui.GuildID.IsValid() && ui.State.MutedState.Guild(ui.GuildID, false)) || + ui.State.MutedState.Channel(ui.ID) || + ui.State.MutedState.Category(ui.ID) +} + +func (ui UnreadIndicator) UnreadIndicate(indicator cchat.UnreadContainer) (func(), error) { + if rs := ui.State.ReadState.FindLast(ui.ID); rs != nil { + c, err := ui.Self() + if err != nil { + return nil, errors.Wrap(err, "Failed to get self channel") + } + + if c.LastMessageID > rs.LastMessageID && !ui.Muted() { + indicator.SetUnread(true, rs.MentionCount > 0) + } + } + + return ui.State.ReadState.OnUpdate(func(ev *read.UpdateEvent) { + if ui.ID == ev.ChannelID && !ui.Muted() { + indicator.SetUnread(ev.Unread, ev.MentionCount > 0) + } + }), nil +} diff --git a/internal/discord/channel/memberlist/member.go b/internal/discord/channel/message/memberlist/member.go similarity index 64% rename from internal/discord/channel/memberlist/member.go rename to internal/discord/channel/message/memberlist/member.go index 115ced8..539e5cb 100644 --- a/internal/discord/channel/memberlist/member.go +++ b/internal/discord/channel/message/memberlist/member.go @@ -8,29 +8,24 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/discord/state" - "github.com/diamondburned/cchat-discord/internal/segments" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/diamondburned/cchat-discord/internal/segments/colored" + "github.com/diamondburned/cchat-discord/internal/segments/emoji" + "github.com/diamondburned/cchat-discord/internal/segments/mention" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" ) type Member struct { - Channel - state *state.Instance - + channel *shared.Channel userID discord.UserID origName string // use if cache is stale } -var ( - _ cchat.ListMember = (*Member)(nil) - _ cchat.Iconer = (*Member)(nil) -) - // New creates a new list member. it.Member must not be nil. -func (c Channel) NewMember(opItem gateway.GuildMemberListOpItem) *Member { +func NewMember(ch *shared.Channel, opItem gateway.GuildMemberListOpItem) cchat.ListMember { return &Member{ - Channel: c, + channel: ch, userID: opItem.Member.User.ID, origName: opItem.Member.User.Username, } @@ -41,12 +36,12 @@ func (l *Member) ID() cchat.ID { } func (l *Member) Name() text.Rich { - g, err := l.state.Store.Guild(l.guildID) + g, err := l.channel.State.Store.Guild(l.channel.GuildID) if err != nil { return text.Plain(l.origName) } - m, err := l.state.Store.Member(l.guildID, l.userID) + m, err := l.channel.State.Store.Member(l.channel.GuildID, l.userID) if err != nil { return text.Plain(l.origName) } @@ -56,8 +51,8 @@ func (l *Member) Name() text.Rich { name = m.Nick } - mention := segments.MemberSegment(0, len(name), *g, *m) - mention.WithState(l.state.State) + mention := mention.MemberSegment(0, len(name), *g, *m) + mention.WithState(l.channel.State.State) var txt = text.Rich{ Content: name, @@ -65,17 +60,16 @@ func (l *Member) Name() text.Rich { } if c := discord.MemberColor(*g, *m); c != discord.DefaultMemberColor { - txt.Segments = append(txt.Segments, segments.NewColored(len(name), uint32(c))) + txt.Segments = append(txt.Segments, colored.New(len(name), uint32(c))) } return txt } -// IsIconer returns true. -func (l *Member) IsIconer() bool { return true } +func (l *Member) AsIconer() cchat.Iconer { return l } func (l *Member) Icon(ctx context.Context, c cchat.IconContainer) (func(), error) { - m, err := l.state.Member(l.guildID, l.userID) + m, err := l.channel.State.Member(l.channel.GuildID, l.userID) if err != nil { return nil, err } @@ -85,28 +79,28 @@ func (l *Member) Icon(ctx context.Context, c cchat.IconContainer) (func(), error return func() {}, nil } -func (l *Member) Status() cchat.UserStatus { - p, err := l.state.Store.Presence(l.guildID, l.userID) +func (l *Member) Status() cchat.Status { + p, err := l.channel.State.Store.Presence(l.channel.GuildID, l.userID) if err != nil { - return cchat.UnknownStatus + return cchat.StatusUnknown } switch p.Status { case discord.OnlineStatus: - return cchat.OnlineStatus + return cchat.StatusOnline case discord.DoNotDisturbStatus: - return cchat.BusyStatus + return cchat.StatusBusy case discord.IdleStatus: - return cchat.AwayStatus + return cchat.StatusAway case discord.OfflineStatus, discord.InvisibleStatus: - return cchat.OfflineStatus + return cchat.StatusOffline default: - return cchat.UnknownStatus + return cchat.StatusUnknown } } func (l *Member) Secondary() text.Rich { - p, err := l.state.Store.Presence(l.guildID, l.userID) + p, err := l.channel.State.Store.Presence(l.channel.GuildID, l.userID) if err != nil { return text.Plain("") } @@ -142,11 +136,9 @@ func formatSmallActivity(ac discord.Activity) text.Rich { status.WriteString(ac.Emoji.Name) status.WriteByte(' ') } else { - segmts = append(segmts, segments.EmojiSegment{ - Start: status.Len(), - Name: ac.Emoji.Name, - EmojiURL: ac.Emoji.EmojiURL() + "?size=64", - Large: ac.State == "", + segmts = append(segmts, emoji.Segment{ + Start: status.Len(), + Emoji: emoji.EmojiFromDiscord(*ac.Emoji, ac.State == ""), }) } } diff --git a/internal/discord/channel/memberlister.go b/internal/discord/channel/message/memberlist/memberlist.go similarity index 54% rename from internal/discord/channel/memberlister.go rename to internal/discord/channel/message/memberlist/memberlist.go index 63fc38e..96a4073 100644 --- a/internal/discord/channel/memberlister.go +++ b/internal/discord/channel/message/memberlist/memberlist.go @@ -1,11 +1,11 @@ -package channel +package memberlist import ( "context" "github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/discord/channel/memberlist" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" "github.com/diamondburned/ningen/states/member" ) @@ -30,24 +30,21 @@ func seekPrevGroup(l *member.List, ix int) (item, group gateway.GuildMemberListO return } -var _ cchat.MemberLister = (*Channel)(nil) - -// IsMemberLister returns true if the channel is a guild channel. -func (ch *Channel) IsMemberLister() bool { - return ch.guildID.IsValid() +type MemberLister struct { + *shared.Channel } -func (ch *Channel) memberListCh() memberlist.Channel { - return memberlist.NewChannel(ch.state, ch.id, ch.guildID) +func New(ch *shared.Channel) cchat.MemberLister { + return MemberLister{ch} } -func (ch *Channel) ListMembers(ctx context.Context, c cchat.MemberListContainer) (func(), error) { - if !ch.guildID.IsValid() { +func (ml MemberLister) ListMembers(ctx context.Context, c cchat.MemberListContainer) (func(), error) { + if !ml.GuildID.IsValid() { return func() {}, nil } - cancel := ch.state.AddHandler(func(u *gateway.GuildMemberListUpdate) { - l, err := ch.state.MemberState.GetMemberList(ch.guildID, ch.id) + cancel := ml.State.AddHandler(func(u *gateway.GuildMemberListUpdate) { + l, err := ml.State.MemberState.GetMemberList(ml.GuildID, ml.ID) if err != nil { return // wat } @@ -56,44 +53,41 @@ func (ch *Channel) ListMembers(ctx context.Context, c cchat.MemberListContainer) return } - var listCh = ch.memberListCh() - for _, ev := range u.Ops { switch ev.Op { case "SYNC": - ch.checkSync(c) + ml.checkSync(c) case "INSERT", "UPDATE": item, group := seekPrevGroup(l, ev.Index) if item.Member != nil && group.Group != nil { - c.SetMember(group.Group.ID, listCh.NewMember(item)) - listCh.FlushMemberGroups(l, c) + c.SetMember(group.Group.ID, NewMember(ml.Channel, item)) + ml.FlushMemberGroups(l, c) } case "DELETE": _, group := seekPrevGroup(l, ev.Index-1) if group.Group != nil && ev.Item.Member != nil { c.RemoveMember(group.Group.ID, ev.Item.Member.User.ID.String()) - listCh.FlushMemberGroups(l, c) + ml.FlushMemberGroups(l, c) } } } }) - ch.checkSync(c) + ml.checkSync(c) return cancel, nil } -func (ch *Channel) checkSync(c cchat.MemberListContainer) { - l, err := ch.state.MemberState.GetMemberList(ch.guildID, ch.id) +func (ml MemberLister) checkSync(c cchat.MemberListContainer) { + l, err := ml.State.MemberState.GetMemberList(ml.GuildID, ml.ID) if err != nil { - ch.state.MemberState.RequestMemberList(ch.guildID, ch.id, 0) + ml.State.MemberState.RequestMemberList(ml.GuildID, ml.ID, 0) return } - listCh := ch.memberListCh() - listCh.FlushMemberGroups(l, c) + ml.FlushMemberGroups(l, c) l.ViewItems(func(items []gateway.GuildMemberListOpItem) { var group gateway.GuildMemberListGroup @@ -104,8 +98,19 @@ func (ch *Channel) checkSync(c cchat.MemberListContainer) { group = *item.Group case item.Member != nil: - c.SetMember(group.ID, listCh.NewMember(item)) + c.SetMember(group.ID, NewMember(ml.Channel, item)) } } }) } + +func (ml MemberLister) FlushMemberGroups(l *member.List, c cchat.MemberListContainer) { + l.ViewGroups(func(groups []gateway.GuildMemberListGroup) { + var sections = make([]cchat.MemberSection, len(groups)) + for i, group := range groups { + sections[i] = NewSection(ml.Channel, l.ID(), group) + } + + c.SetSections(sections) + }) +} diff --git a/internal/discord/channel/message/memberlist/section.go b/internal/discord/channel/message/memberlist/section.go new file mode 100644 index 0000000..419a17b --- /dev/null +++ b/internal/discord/channel/message/memberlist/section.go @@ -0,0 +1,105 @@ +package memberlist + +import ( + "fmt" + + "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/text" + "github.com/diamondburned/cchat/utils/empty" +) + +type Section struct { + empty.Namer + + // constant states + listID string + id string // roleID or online or offline + name string + total int + dynsec DynamicSection +} + +func NewSection( + ch *shared.Channel, + listID string, + group gateway.GuildMemberListGroup) cchat.MemberSection { + + var name string + + switch group.ID { + case "online": + name = "Online" + case "offline": + name = "Offline" + default: + p, err := discord.ParseSnowflake(group.ID) + if err != nil { + name = group.ID + } else { + r, err := ch.State.Role(ch.GuildID, discord.RoleID(p)) + if err != nil { + name = fmt.Sprintf("<@#%s>", p.String()) + } else { + name = r.Name + } + } + } + + return Section{ + listID: listID, + id: group.ID, + name: name, + total: int(group.Count), + dynsec: DynamicSection{ + Channel: ch, + }, + } +} + +func (s Section) ID() cchat.ID { + return s.id +} + +func (s Section) Name() text.Rich { + return text.Rich{Content: s.name} +} + +func (s Section) Total() int { + return s.total +} + +func (s Section) AsMemberDynamicSection() cchat.MemberDynamicSection { + return s.dynsec +} + +func (s Section) IsMemberDynamicSection() bool { return true } + +type DynamicSection struct { + *shared.Channel +} + +var _ cchat.MemberDynamicSection = (*DynamicSection)(nil) + +// TODO: document that Load{More,Less} works more like a shifting window. + +func (s DynamicSection) LoadMore() bool { + chunk := s.State.MemberState.GetMemberListChunk(s.GuildID, s.Channel.ID) + if chunk < 0 { + chunk = 0 + } + + return s.State.MemberState.RequestMemberList(s.GuildID, s.Channel.ID, chunk) != nil +} + +func (s DynamicSection) LoadLess() bool { + chunk := s.State.MemberState.GetMemberListChunk(s.GuildID, s.Channel.ID) + if chunk <= 0 { + return false + } + + s.State.MemberState.RequestMemberList(s.GuildID, s.Channel.ID, chunk-1) + return true +} diff --git a/internal/discord/channel/message/message.go b/internal/discord/channel/message/message.go new file mode 100644 index 0000000..f5e5f5f --- /dev/null +++ b/internal/discord/channel/message/message.go @@ -0,0 +1,180 @@ +package message + +import ( + "context" + "sort" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/arikawa/gateway" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/action" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/backlog" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/edit" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/indicate" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/memberlist" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/nickname" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/send" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/diamondburned/cchat-discord/internal/discord/message" + "github.com/diamondburned/cchat-discord/internal/funcutil" + "github.com/diamondburned/cchat/utils/empty" + "github.com/pkg/errors" +) + +type Messenger struct { + *empty.Messenger + *shared.Channel +} + +var _ cchat.Messenger = (*Messenger)(nil) + +func New(ch *shared.Channel) Messenger { + return Messenger{Channel: ch} +} + +func (msgr Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { + state := msgr.State.WithContext(ctx) + + m, err := state.Messages(msgr.ID) + if err != nil { + return nil, err + } + + var addcancel = funcutil.NewCancels() + + var constructor func(discord.Message) cchat.MessageCreate + + if msgr.GuildID.IsValid() { + // Create the backlog without any member information. + g, err := state.Guild(msgr.GuildID) + if err != nil { + return nil, errors.Wrap(err, "Failed to get guild") + } + + constructor = func(m discord.Message) cchat.MessageCreate { + return message.NewBacklogMessage(m, msgr.State, *g) + } + + // Subscribe to typing events. + msgr.State.MemberState.Subscribe(msgr.GuildID) + + // Listen to new members before creating the backlog and requesting members. + addcancel(msgr.State.AddHandler(func(c *gateway.GuildMembersChunkEvent) { + if c.GuildID != msgr.GuildID { + return + } + + m, err := msgr.Messages() + if err != nil { + // TODO: log + return + } + + g, err := msgr.Guild() + if err != nil { + return + } + + // Loop over all messages and replace the author. The latest + // messages are in front. + for _, msg := range m { + for _, member := range c.Members { + if msg.Author.ID != member.User.ID { + continue + } + + ct.UpdateMessage(message.NewMessageUpdateAuthor(msg, member, *g, msgr.State)) + } + } + })) + } else { + constructor = func(m discord.Message) cchat.MessageCreate { + return message.NewDirectMessage(m, msgr.State) + } + } + + // Only do all this if we even have any messages. + if len(m) > 0 { + // Sort messages chronologically using the ID so that the oldest messages + // (ones with the smallest snowflake) is in front. + sort.Slice(m, func(i, j int) bool { return m[i].ID < m[j].ID }) + + // Iterate from the earliest messages to the latest messages. + for _, m := range m { + ct.CreateMessage(constructor(m)) + } + + // Mark this channel as read. + msgr.State.ReadState.MarkRead(msgr.ID, m[len(m)-1].ID) + } + + // Bind the handler. + addcancel( + msgr.State.AddHandler(func(m *gateway.MessageCreateEvent) { + if m.ChannelID == msgr.ID { + ct.CreateMessage(message.NewMessageCreate(m, msgr.State)) + msgr.State.ReadState.MarkRead(msgr.ID, m.ID) + } + }), + msgr.State.AddHandler(func(m *gateway.MessageUpdateEvent) { + // If the updated content is empty. TODO: add embed support. + if m.ChannelID == msgr.ID { + ct.UpdateMessage(message.NewMessageUpdateContent(m.Message, msgr.State)) + } + }), + msgr.State.AddHandler(func(m *gateway.MessageDeleteEvent) { + if m.ChannelID == msgr.ID { + ct.DeleteMessage(message.NewHeaderDelete(m)) + } + }), + ) + + return funcutil.JoinCancels(addcancel()), nil +} + +func (msgr Messenger) AsSender() cchat.Sender { + if !msgr.HasPermission(discord.PermissionSendMessages) { + return nil + } + + return send.New(msgr.Channel) +} + +func (msgr Messenger) AsEditor() cchat.Editor { + if !msgr.HasPermission(discord.PermissionSendMessages) { + return nil + } + + return edit.New(msgr.Channel) +} + +func (msgr Messenger) AsActioner() cchat.Actioner { + return action.New(msgr.Channel) +} + +func (msgr Messenger) AsNicknamer() cchat.Nicknamer { + return nickname.New(msgr.Channel) +} + +func (msgr Messenger) AsMemberLister() cchat.MemberLister { + if !msgr.GuildID.IsValid() { + return nil + } + return memberlist.New(msgr.Channel) +} + +func (msgr Messenger) AsBacklogger() cchat.Backlogger { + if !msgr.HasPermission(discord.PermissionViewChannel, discord.PermissionReadMessageHistory) { + return nil + } + + return backlog.New(msgr.Channel) +} + +func (msgr Messenger) AsTypingIndicator() cchat.TypingIndicator { + return indicate.NewTyping(msgr.Channel) +} + +func (msgr Messenger) AsUnreadIndicator() cchat.UnreadIndicator { + return indicate.NewUnread(msgr.Channel) +} diff --git a/internal/discord/channel/nicknamer.go b/internal/discord/channel/message/nickname/nicknamer.go similarity index 53% rename from internal/discord/channel/nicknamer.go rename to internal/discord/channel/message/nickname/nicknamer.go index 89aa29b..dd78374 100644 --- a/internal/discord/channel/nicknamer.go +++ b/internal/discord/channel/message/nickname/nicknamer.go @@ -1,37 +1,39 @@ -package channel +package nickname import ( "context" "github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/segments" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" + "github.com/diamondburned/cchat-discord/internal/segments/colored" "github.com/diamondburned/cchat/text" "github.com/pkg/errors" ) -var _ cchat.Nicknamer = (*Channel)(nil) - -// IsNicknamer returns true if the current channel is in a guild. -func (ch *Channel) IsNicknamer() bool { - return ch.guildID.IsValid() +type Nicknamer struct { + *shared.Channel } -func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) (func(), error) { +func New(ch *shared.Channel) cchat.Nicknamer { + return Nicknamer{ch} +} + +func (nn Nicknamer) Nickname(ctx context.Context, labeler cchat.LabelContainer) (func(), error) { // We don't have a nickname if we're not in a guild. - if !ch.guildID.IsValid() { + if !nn.GuildID.IsValid() { return func() {}, nil } - state := ch.state.WithContext(ctx) + state := nn.State.WithContext(ctx) // MemberColor should fill up the state cache. - c, err := state.MemberColor(ch.guildID, ch.state.UserID) + c, err := state.MemberColor(nn.GuildID, nn.State.UserID) if err != nil { return nil, errors.Wrap(err, "Failed to get self member color") } - m, err := state.Member(ch.guildID, ch.state.UserID) + m, err := state.Member(nn.GuildID, nn.State.UserID) if err != nil { return nil, errors.Wrap(err, "Failed to get self member") } @@ -42,7 +44,7 @@ func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) ( } if c > 0 { rich.Segments = []text.Segment{ - segments.NewColored(len(rich.Content), c.Uint32()), + colored.New(len(rich.Content), c.Uint32()), } } @@ -51,8 +53,8 @@ func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) ( // Copy the user ID to use. var selfID = m.User.ID - return ch.state.AddHandler(func(g *gateway.GuildMemberUpdateEvent) { - if g.GuildID != ch.guildID || g.User.ID != selfID { + return nn.State.AddHandler(func(g *gateway.GuildMemberUpdateEvent) { + if g.GuildID != nn.GuildID || g.User.ID != selfID { return } @@ -61,10 +63,10 @@ func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) ( rich.Content = m.Nick } - c, err := ch.state.MemberColor(g.GuildID, selfID) + c, err := nn.State.MemberColor(g.GuildID, selfID) if err == nil { rich.Segments = []text.Segment{ - segments.NewColored(len(rich.Content), c.Uint32()), + colored.New(len(rich.Content), c.Uint32()), } } diff --git a/internal/discord/channel/completer.go b/internal/discord/channel/message/send/complete/completer.go similarity index 78% rename from internal/discord/channel/completer.go rename to internal/discord/channel/message/send/complete/completer.go index 2e97098..9a2fc3b 100644 --- a/internal/discord/channel/completer.go +++ b/internal/discord/channel/message/send/complete/completer.go @@ -1,36 +1,32 @@ -package channel +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/message" "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" ) +type Completer struct { + *shared.Channel +} + const MaxCompletion = 15 -var _ cchat.MessageCompleter = (*Channel)(nil) - -// IsMessageCompleter returns true if the user can send messages in this -// channel. -func (ch *Channel) IsMessageCompleter() bool { - p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID) - if err != nil { - return false - } - - return p.Has(discord.PermissionSendMessages) +func New(ch *shared.Channel) cchat.Completer { + return Completer{ch} } // CompleteMessage implements message input completion capability for Discord. // This method supports user mentions, channel mentions and emojis. // // For the individual implementations, refer to channel_completion.go. -func (ch *Channel) CompleteMessage(words []string, i int) (entries []cchat.CompletionEntry) { +func (ch Completer) Complete(words []string, i int64) (entries []cchat.CompletionEntry) { var word = words[i] // Word should have at least a character for the char check. if len(word) < 1 { @@ -70,11 +66,11 @@ func completionUser(s *state.Instance, u discord.User, g *discord.Guild) cchat.C } } -func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntry) { +func (ch Completer) completeMentions(word string) (entries []cchat.CompletionEntry) { // If there is no input, then we should grab the latest messages. if word == "" { - msgs, _ := ch.messages() - g, _ := ch.guild() // nil is fine + 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 @@ -88,7 +84,7 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr // 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)) + entries = append(entries, completionUser(ch.State, msg.Author, g)) if len(entries) >= MaxCompletion { return @@ -103,8 +99,8 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr var match = strings.ToLower(word) // If we're not in a guild, then we can check the list of recipients. - if !ch.guildID.IsValid() { - c, err := ch.self() + if !ch.GuildID.IsValid() { + c, err := ch.State.Store.Channel(ch.ID) if err != nil { return } @@ -127,8 +123,8 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr } // If we're in a guild, then we should search for (all) members. - m, merr := ch.state.Store.Members(ch.guildID) - g, gerr := ch.guild() + m, merr := ch.State.Store.Members(ch.GuildID) + g, gerr := ch.State.Store.Guild(ch.GuildID) if merr != nil || gerr != nil { return @@ -137,7 +133,7 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr // 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) + ch.State.MemberState.SearchMember(ch.GuildID, word) return } @@ -145,7 +141,7 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr if contains(match, mem.User.Username, mem.Nick) { entries = append(entries, cchat.CompletionEntry{ Raw: mem.User.Mention(), - Text: message.RenderMemberName(mem, *g, ch.state), + Text: message.RenderMemberName(mem, *g, ch.State), Secondary: text.Rich{Content: mem.User.Username + "#" + mem.User.Discriminator}, IconURL: mem.User.AvatarURL(), }) @@ -158,18 +154,18 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr return } -func (ch *Channel) completeChannels(word string) (entries []cchat.CompletionEntry) { +func (ch Completer) completeChannels(word string) (entries []cchat.CompletionEntry) { // Ignore if empty word. if word == "" { return } // Ignore if we're not in a guild. - if !ch.guildID.IsValid() { + if !ch.GuildID.IsValid() { return } - c, err := ch.state.State.Channels(ch.guildID) + c, err := ch.State.Store.Channels(ch.GuildID) if err != nil { return } @@ -183,7 +179,7 @@ func (ch *Channel) completeChannels(word string) (entries []cchat.CompletionEntr var category string if channel.CategoryID.IsValid() { - if c, _ := ch.state.Store.Channel(channel.CategoryID); c != nil { + if c, _ := ch.State.Store.Channel(channel.CategoryID); c != nil { category = c.Name } } @@ -202,13 +198,13 @@ func (ch *Channel) completeChannels(word string) (entries []cchat.CompletionEntr return } -func (ch *Channel) completeEmojis(word string) (entries []cchat.CompletionEntry) { +func (ch Completer) completeEmojis(word string) (entries []cchat.CompletionEntry) { // Ignore if empty word. if word == "" { return } - e, err := ch.state.EmojiState.Get(ch.guildID) + e, err := ch.State.EmojiState.Get(ch.GuildID) if err != nil { return } diff --git a/internal/discord/channel/message/send/sender.go b/internal/discord/channel/message/send/sender.go new file mode 100644 index 0000000..6a66048 --- /dev/null +++ b/internal/discord/channel/message/send/sender.go @@ -0,0 +1,57 @@ +package send + +import ( + "github.com/diamondburned/arikawa/api" + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/channel/message/send/complete" + "github.com/diamondburned/cchat-discord/internal/discord/channel/shared" +) + +type Sender struct { + *shared.Channel +} + +var _ cchat.Sender = (*Sender)(nil) + +func New(ch *shared.Channel) Sender { + return Sender{ch} +} + +func (s Sender) Send(msg cchat.SendableMessage) error { + var send = api.SendMessageData{Content: msg.Content()} + if noncer := msg.AsNoncer(); noncer != nil { + send.Nonce = noncer.Nonce() + } + if attacher := msg.AsAttachments(); attacher != nil { + send.Files = addAttachments(attacher.Attachments()) + } + + _, err := s.State.SendMessageComplex(s.ID, send) + return err +} + +// CanAttach returns true if the channel can attach files. +func (s Sender) CanAttach() bool { + p, err := s.State.StateOnly().Permissions(s.ID, s.State.UserID) + if err != nil { + return false + } + + return p.Has(discord.PermissionAttachFiles) +} + +func (s Sender) AsCompleter() cchat.Completer { + return complete.New(s.Channel) +} + +func addAttachments(atts []cchat.MessageAttachment) []api.SendMessageFile { + var files = make([]api.SendMessageFile, len(atts)) + for i, a := range atts { + files[i] = api.SendMessageFile{ + Name: a.Name, + Reader: a, + } + } + return files +} diff --git a/internal/discord/channel/messenger.go b/internal/discord/channel/messenger.go deleted file mode 100644 index c759891..0000000 --- a/internal/discord/channel/messenger.go +++ /dev/null @@ -1,125 +0,0 @@ -package channel - -import ( - "context" - "sort" - - "github.com/diamondburned/arikawa/discord" - "github.com/diamondburned/arikawa/gateway" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-discord/internal/discord/message" - "github.com/diamondburned/cchat-discord/internal/funcutil" - "github.com/pkg/errors" -) - -var _ cchat.Messenger = (*Channel)(nil) - -// IsMessenger returns true if the current user is allowed to see the channel. -func (ch *Channel) IsMessenger() bool { - p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID) - if err != nil { - return false - } - - return p.Has(discord.PermissionViewChannel) -} - -func (ch *Channel) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { - state := ch.state.WithContext(ctx) - - m, err := state.Messages(ch.id) - if err != nil { - return nil, err - } - - var addcancel = funcutil.NewCancels() - - var constructor func(discord.Message) cchat.MessageCreate - - if ch.guildID.IsValid() { - // Create the backlog without any member information. - g, err := state.Guild(ch.guildID) - if err != nil { - return nil, errors.Wrap(err, "Failed to get guild") - } - - constructor = func(m discord.Message) cchat.MessageCreate { - return message.NewBacklogMessage(m, ch.state, *g) - } - - // Subscribe to typing events. - ch.state.MemberState.Subscribe(ch.guildID) - - // Listen to new members before creating the backlog and requesting members. - addcancel(ch.state.AddHandler(func(c *gateway.GuildMembersChunkEvent) { - if c.GuildID != ch.guildID { - return - } - - m, err := ch.messages() - if err != nil { - // TODO: log - return - } - - g, err := ch.guild() - if err != nil { - return - } - - // Loop over all messages and replace the author. The latest - // messages are in front. - for _, msg := range m { - for _, member := range c.Members { - if msg.Author.ID != member.User.ID { - continue - } - - ct.UpdateMessage(message.NewMessageUpdateAuthor(msg, member, *g, ch.state)) - } - } - })) - } else { - constructor = func(m discord.Message) cchat.MessageCreate { - return message.NewDirectMessage(m, ch.state) - } - } - - // Only do all this if we even have any messages. - if len(m) > 0 { - // Sort messages chronologically using the ID so that the oldest messages - // (ones with the smallest snowflake) is in front. - sort.Slice(m, func(i, j int) bool { return m[i].ID < m[j].ID }) - - // Iterate from the earliest messages to the latest messages. - for _, m := range m { - ct.CreateMessage(constructor(m)) - } - - // Mark this channel as read. - ch.state.ReadState.MarkRead(ch.id, m[len(m)-1].ID) - } - - // Bind the handler. - addcancel( - ch.state.AddHandler(func(m *gateway.MessageCreateEvent) { - if m.ChannelID == ch.id { - ct.CreateMessage(message.NewMessageCreate(m, ch.state)) - ch.state.ReadState.MarkRead(ch.id, m.ID) - } - }), - ch.state.AddHandler(func(m *gateway.MessageUpdateEvent) { - // If the updated content is empty. TODO: add embed support. - if m.ChannelID == ch.id { - ct.UpdateMessage(message.NewMessageUpdateContent(m.Message, ch.state)) - } - }), - ch.state.AddHandler(func(m *gateway.MessageDeleteEvent) { - if m.ChannelID == ch.id { - ct.DeleteMessage(message.NewHeaderDelete(m)) - } - }), - ) - - return funcutil.JoinCancels(addcancel()), nil -} diff --git a/internal/discord/channel/sender.go b/internal/discord/channel/sender.go deleted file mode 100644 index 1f5642e..0000000 --- a/internal/discord/channel/sender.go +++ /dev/null @@ -1,62 +0,0 @@ -package channel - -import ( - "github.com/diamondburned/arikawa/api" - "github.com/diamondburned/arikawa/discord" - "github.com/diamondburned/cchat" -) - -var ( - _ cchat.MessageSender = (*Channel)(nil) - _ cchat.AttachmentSender = (*Channel)(nil) -) - -func (ch *Channel) IsMessageSender() bool { - p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID) - if err != nil { - return false - } - - return p.Has(discord.PermissionSendMessages) -} - -func (ch *Channel) SendMessage(msg cchat.SendableMessage) error { - var send = api.SendMessageData{Content: msg.Content()} - if noncer, ok := msg.(cchat.MessageNonce); ok { - send.Nonce = noncer.Nonce() - } - if attcher, ok := msg.(cchat.Attachments); ok { - send.Files = addAttachments(attcher.Attachments()) - } - - _, err := ch.state.SendMessageComplex(ch.id, send) - return err -} - -// IsAttachmentSender returns true if the channel can attach files. -func (ch *Channel) IsAttachmentSender() bool { - p, err := ch.state.StateOnly().Permissions(ch.id, ch.state.UserID) - if err != nil { - return false - } - - return p.Has(discord.PermissionAttachFiles) -} - -func (ch *Channel) SendAttachments(atts []cchat.MessageAttachment) error { - _, err := ch.state.SendMessageComplex(ch.id, api.SendMessageData{ - Files: addAttachments(atts), - }) - return err -} - -func addAttachments(atts []cchat.MessageAttachment) []api.SendMessageFile { - var files = make([]api.SendMessageFile, len(atts)) - for i, a := range atts { - files[i] = api.SendMessageFile{ - Name: a.Name, - Reader: a, - } - } - return files -} diff --git a/internal/discord/channel/shared/channel.go b/internal/discord/channel/shared/channel.go new file mode 100644 index 0000000..2580256 --- /dev/null +++ b/internal/discord/channel/shared/channel.go @@ -0,0 +1,41 @@ +package shared + +import ( + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat-discord/internal/discord/state" +) + +type Channel struct { + ID discord.ChannelID + GuildID discord.GuildID + State *state.Instance +} + +// HasPermission returns true if the current user has the given permissions in +// the channel. +func (ch Channel) HasPermission(perms ...discord.Permissions) bool { + p, err := ch.State.StateOnly().Permissions(ch.ID, ch.State.UserID) + if err != nil { + return false + } + + for _, perm := range perms { + if !p.Has(perm) { + return false + } + } + + return true +} + +func (ch Channel) Messages() ([]discord.Message, error) { + return ch.State.Store.Messages(ch.ID) +} + +func (ch Channel) Guild() (*discord.Guild, error) { + return ch.State.Store.Guild(ch.GuildID) +} + +func (ch Channel) Self() (*discord.Channel, error) { + return ch.State.Store.Channel(ch.ID) +} diff --git a/internal/discord/folder/folder.go b/internal/discord/folder/folder.go index 23d250e..ad848b8 100644 --- a/internal/discord/folder/folder.go +++ b/internal/discord/folder/folder.go @@ -8,21 +8,18 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/internal/discord/guild" "github.com/diamondburned/cchat-discord/internal/discord/state" - "github.com/diamondburned/cchat-discord/internal/segments" + "github.com/diamondburned/cchat-discord/internal/segments/colored" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" ) type GuildFolder struct { + empty.Server gateway.GuildFolder state *state.Instance } -var ( - _ cchat.Server = (*GuildFolder)(nil) - _ cchat.Lister = (*GuildFolder)(nil) -) - -func New(s *state.Instance, gf gateway.GuildFolder) *GuildFolder { +func New(s *state.Instance, gf gateway.GuildFolder) cchat.Server { // Name should never be empty. if gf.Name == "" { var names = make([]string, 0, len(gf.GuildIDs)) @@ -55,7 +52,7 @@ func (gf *GuildFolder) Name() text.Rich { if gf.GuildFolder.Color > 0 { name.Segments = []text.Segment{ // The length of this black box is actually 3. Mind == blown. - segments.NewColored(len(name.Content), gf.GuildFolder.Color.Uint32()), + colored.New(len(name.Content), gf.GuildFolder.Color.Uint32()), } } @@ -63,7 +60,7 @@ func (gf *GuildFolder) Name() text.Rich { } // IsLister returns true. -func (gf *GuildFolder) IsLister() bool { return true } +func (gf *GuildFolder) AsLister() cchat.Lister { return gf } func (gf *GuildFolder) Servers(container cchat.ServersContainer) error { var servers = make([]cchat.Server, 0, len(gf.GuildIDs)) diff --git a/internal/discord/guild/guild.go b/internal/discord/guild/guild.go index b4ec334..c0ec9f8 100644 --- a/internal/discord/guild/guild.go +++ b/internal/discord/guild/guild.go @@ -12,28 +12,24 @@ import ( "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" "github.com/pkg/errors" ) type Guild struct { + empty.Server id discord.GuildID state *state.Instance } -var ( - _ cchat.Iconer = (*Guild)(nil) - _ cchat.Server = (*Guild)(nil) - _ cchat.Lister = (*Guild)(nil) -) - -func New(s *state.Instance, g *discord.Guild) *Guild { +func New(s *state.Instance, g *discord.Guild) cchat.Server { return &Guild{ id: g.ID, state: s, } } -func NewFromID(s *state.Instance, gID discord.GuildID) (*Guild, error) { +func NewFromID(s *state.Instance, gID discord.GuildID) (cchat.Server, error) { g, err := s.Guild(gID) if err != nil { return nil, err @@ -64,11 +60,7 @@ func (g *Guild) Name() text.Rich { return text.Rich{Content: s.Name} } -// IsIconer returns true if the guild has an icon. -func (g *Guild) IsIconer() bool { - s, err := g.selfState() - return err == nil && s.Icon != "" -} +func (g *Guild) AsIconer() cchat.Iconer { return g } func (g *Guild) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { s, err := g.self(ctx) @@ -89,8 +81,7 @@ func (g *Guild) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), e }), nil } -// IsLister returns true. -func (g *Guild) IsLister() bool { return true } +func (g *Guild) AsLister() cchat.Lister { return g } func (g *Guild) Servers(container cchat.ServersContainer) error { c, err := g.state.Channels(g.id) diff --git a/internal/discord/message/author.go b/internal/discord/message/author.go index 8a1d091..12d97d3 100644 --- a/internal/discord/message/author.go +++ b/internal/discord/message/author.go @@ -4,7 +4,9 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/internal/discord/state" - "github.com/diamondburned/cchat-discord/internal/segments" + "github.com/diamondburned/cchat-discord/internal/segments/colored" + "github.com/diamondburned/cchat-discord/internal/segments/mention" + "github.com/diamondburned/cchat-discord/internal/segments/segutil" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" ) @@ -22,12 +24,12 @@ func NewUser(u discord.User, s *state.Instance) Author { if u.Bot { name.Content += " " name.Segments = append(name.Segments, - segments.NewBlurpleSegment(segments.Write(&name, "[BOT]")), + colored.NewBlurple(segutil.Write(&name, "[BOT]")), ) } // Append a clickable user popup. - useg := segments.UserSegment(0, len(name.Content), u) + useg := mention.UserSegment(0, len(name.Content), u) useg.WithState(s.State) name.Segments = append(name.Segments, useg) @@ -59,7 +61,7 @@ func RenderMemberName(m discord.Member, g discord.Guild, s *state.Instance) text // Update the color. if c := discord.MemberColor(g, m); c > 0 { name.Segments = append(name.Segments, - segments.NewColored(len(name.Content), c.Uint32()), + colored.New(len(name.Content), c.Uint32()), ) } @@ -67,12 +69,12 @@ func RenderMemberName(m discord.Member, g discord.Guild, s *state.Instance) text if m.User.Bot { name.Content += " " name.Segments = append(name.Segments, - segments.NewBlurpleSegment(segments.Write(&name, "[BOT]")), + colored.NewBlurple(segutil.Write(&name, "[BOT]")), ) } // Append a clickable user popup. - useg := segments.MemberSegment(0, len(name.Content), g, m) + useg := mention.MemberSegment(0, len(name.Content), g, m) useg.WithState(s.State) name.Segments = append(name.Segments, useg) diff --git a/internal/discord/message/message.go b/internal/discord/message/message.go index bc6edc9..5de2bf2 100644 --- a/internal/discord/message/message.go +++ b/internal/discord/message/message.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/segments" + "github.com/diamondburned/cchat-discord/internal/segments/mention" "github.com/diamondburned/cchat/text" ) @@ -144,21 +145,21 @@ func NewMessage(m discord.Message, s *state.Instance, author Author) Message { // Request members in mentions if we're in a guild. if m.GuildID.IsValid() { for _, segment := range content.Segments { - if mention, ok := segment.(*segments.MentionSegment); ok { + if mention, ok := segment.(*mention.Segment); ok { // If this is not a user mention, then skip. - if mention.GuildUser == nil { + if mention.User == nil { continue } // If we already have a member, then skip. We could check this // using the timestamp, as we might have a user set into the // member field - if m := mention.GuildUser.Member; m != nil && m.Joined.IsValid() { + if mention.User.Member.Joined.IsValid() { continue } // Request the member. - s.MemberState.RequestMember(m.GuildID, mention.GuildUser.ID) + s.MemberState.RequestMember(m.GuildID, mention.User.Member.User.ID) } } } diff --git a/internal/discord/session/restorer.go b/internal/discord/session/restorer.go new file mode 100644 index 0000000..ea1fc22 --- /dev/null +++ b/internal/discord/session/restorer.go @@ -0,0 +1,19 @@ +package session + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/internal/discord/state" +) + +var Restorer cchat.SessionRestorer = restorer{} + +type restorer struct{} + +func (restorer) RestoreSession(data map[string]string) (cchat.Session, error) { + i, err := state.NewFromData(data) + if err != nil { + return nil, err + } + + return NewFromInstance(i) +} diff --git a/internal/discord/session/session.go b/internal/discord/session/session.go index 373ce0e..34bdb96 100644 --- a/internal/discord/session/session.go +++ b/internal/discord/session/session.go @@ -11,27 +11,20 @@ import ( "github.com/diamondburned/cchat-discord/internal/discord/state" "github.com/diamondburned/cchat-discord/internal/urlutils" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" "github.com/diamondburned/ningen" "github.com/pkg/errors" ) +var ErrMFA = session.ErrMFA + type Session struct { + *empty.Session *state.Instance } -var ( - _ cchat.Iconer = (*Session)(nil) - _ cchat.Session = (*Session)(nil) - _ cchat.SessionSaver = (*Session)(nil) -) - -func NewFromToken(token string) (*Session, error) { - i, err := state.NewFromToken(token) - if err != nil { - return nil, err - } - - return &Session{i}, nil +func NewFromInstance(i *state.Instance) (cchat.Session, error) { + return &Session{Instance: i}, nil } func (s *Session) ID() cchat.ID { @@ -48,8 +41,7 @@ func (s *Session) Name() text.Rich { return text.Rich{Content: u.Username + "#" + u.Discriminator} } -// IsIconer returns true. -func (s *Session) IsIconer() bool { return true } +func (s *Session) AsIconer() cchat.Iconer { return s } func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { u, err := s.Me() @@ -72,14 +64,7 @@ func (s *Session) Disconnect() error { return s.Close() } -// IsSessionSaver returns true. -func (s *Session) IsSessionSaver() bool { return true } - -func (s *Session) SaveSession() map[string]string { - return map[string]string{ - "token": s.Token, - } -} +func (s *Session) AsSessionSaver() cchat.SessionSaver { return s.Instance } func (s *Session) Servers(container cchat.ServersContainer) error { // Reset the entire container when the session is closed. diff --git a/internal/discord/state/state.go b/internal/discord/state/state.go index d283426..533b647 100644 --- a/internal/discord/state/state.go +++ b/internal/discord/state/state.go @@ -6,8 +6,10 @@ import ( "log" "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/arikawa/session" "github.com/diamondburned/arikawa/state" "github.com/diamondburned/arikawa/utils/httputil/httpdriver" + "github.com/diamondburned/cchat" "github.com/diamondburned/ningen" "github.com/pkg/errors" ) @@ -17,6 +19,22 @@ type Instance struct { UserID discord.UserID } +var ( + _ cchat.SessionSaver = (*Instance)(nil) +) + +// ErrInvalidSession is returned if SessionRestore is given a bad session. +var ErrInvalidSession = errors.New("invalid session") + +func NewFromData(data map[string]string) (*Instance, error) { + tk, ok := data["token"] + if !ok { + return nil, ErrInvalidSession + } + + return NewFromToken(tk) +} + func NewFromToken(token string) (*Instance, error) { s, err := state.New(token) if err != nil { @@ -26,6 +44,16 @@ func NewFromToken(token string) (*Instance, error) { return New(s) } +func Login(email, password, mfa string) (*Instance, error) { + session, err := session.Login(email, password, mfa) + if err != nil { + return nil, err + } + + state, _ := state.NewFromSession(session, state.NewDefaultStore(nil)) + return New(state) +} + func New(s *state.State) (*Instance, error) { // Prefetch user. u, err := s.Me() @@ -60,3 +88,9 @@ func (s *Instance) StateOnly() *state.State { return s.WithContext(ctx) } + +func (s *Instance) SaveSession() map[string]string { + return map[string]string{ + "token": s.Token, + } +} diff --git a/internal/segments/mention/user.go b/internal/segments/mention/user.go index 3791c7f..b4d45f0 100644 --- a/internal/segments/mention/user.go +++ b/internal/segments/mention/user.go @@ -29,7 +29,7 @@ func UserSegment(start, end int, u discord.User) NameSegment { start: start, end: end, um: User{ - member: discord.Member{User: u}, + Member: discord.Member{User: u}, }, } } @@ -39,8 +39,8 @@ func MemberSegment(start, end int, guild discord.Guild, m discord.Member) NameSe start: start, end: end, um: User{ - guild: guild, - member: m, + Guild: guild, + Member: m, }, } } @@ -56,17 +56,17 @@ func (m NameSegment) Bounds() (start, end int) { } func (m NameSegment) AsMentioner() text.Mentioner { - return m.um + return &m.um } func (m NameSegment) AsAvatarer() text.Avatarer { - return m.um + return &m.um } type User struct { state state.Store - guild discord.Guild - member discord.Member + Guild discord.Guild + Member discord.Member } var ( @@ -97,18 +97,18 @@ func NewUser(state state.Store, guild discord.GuildID, guser discord.GuildUser) return &User{ state: state, - guild: *g, - member: *guser.Member, + Guild: *g, + Member: *guser.Member, } } func (um *User) Color() uint32 { - g, err := um.state.Guild(um.guild.ID) + g, err := um.state.Guild(um.Guild.ID) if err != nil { return colored.Blurple } - return text.SolidColor(discord.MemberColor(*g, um.member).Uint32()) + return text.SolidColor(discord.MemberColor(*g, um.Member).Uint32()) } func (um *User) AvatarSize() int { @@ -116,14 +116,14 @@ func (um *User) AvatarSize() int { } func (um *User) AvatarText() string { - if um.member.Nick != "" { - return um.member.Nick + if um.Member.Nick != "" { + return um.Member.Nick } - return um.member.User.Username + return um.Member.User.Username } func (um *User) Avatar() (url string) { - return um.member.User.AvatarURL() + return um.Member.User.AvatarURL() } func (um *User) MentionInfo() text.Rich { @@ -131,22 +131,22 @@ func (um *User) MentionInfo() text.Rich { var segment text.Rich // Write the username if the user has a nickname. - if um.member.Nick != "" { + if um.Member.Nick != "" { content.WriteString("Username: ") - content.WriteString(um.member.User.Username) + content.WriteString(um.Member.User.Username) content.WriteByte('#') - content.WriteString(um.member.User.Discriminator) + content.WriteString(um.Member.User.Discriminator) content.WriteString("\n\n") } // Write extra information if any, but only if we have the guild state. - if len(um.member.RoleIDs) > 0 && um.guild.ID.IsValid() { + if len(um.Member.RoleIDs) > 0 && um.Guild.ID.IsValid() { // Write a prepended new line, as role writes will always prepend a new // line. This is to prevent a trailing new line. formatSectionf(&segment, &content, "Roles") - for _, id := range um.member.RoleIDs { - rl, ok := findRole(um.guild.Roles, id) + for _, id := range um.Member.RoleIDs { + rl, ok := findRole(um.Guild.Roles, id) if !ok { continue } @@ -169,19 +169,19 @@ func (um *User) MentionInfo() text.Rich { // if the state is given. if ningenState, ok := um.state.(*ningen.State); ok { // Does the user have rich presence? If so, write. - if p, err := um.state.Presence(um.guild.ID, um.member.User.ID); err == nil { + if p, err := um.state.Presence(um.Guild.ID, um.Member.User.ID); err == nil { for _, ac := range p.Activities { formatActivity(&segment, &content, ac) content.WriteString("\n\n") } - } else if um.guild.ID.IsValid() { + } else if um.Guild.ID.IsValid() { // If we're still in a guild, then we can ask Discord for that // member with their presence attached. - ningenState.MemberState.RequestMember(um.guild.ID, um.member.User.ID) + ningenState.MemberState.RequestMember(um.Guild.ID, um.Member.User.ID) } // Write the user's note if any. - if note := ningenState.NoteState.Note(um.member.User.ID); note != "" { + if note := ningenState.NoteState.Note(um.Member.User.ID); note != "" { formatSectionf(&segment, &content, "Note") content.WriteRune('\n') diff --git a/internal/segments/segutil/segutil.go b/internal/segments/segutil/segutil.go index 13dd70e..3b359ad 100644 --- a/internal/segments/segutil/segutil.go +++ b/internal/segments/segutil/segutil.go @@ -8,6 +8,13 @@ import ( // helper global functions +func Write(rich *text.Rich, content string, segs ...text.Segment) (start, end int) { + start = len(rich.Content) + end = len(rich.Content) + len(content) + rich.Content += content + return +} + func WriteBuf(w *bytes.Buffer, b []byte) (start, end int) { start = w.Len() w.Write(b) diff --git a/logo.go b/logo.go new file mode 100644 index 0000000..d813318 --- /dev/null +++ b/logo.go @@ -0,0 +1,20 @@ +package discord + +import ( + "context" + + "github.com/diamondburned/cchat" +) + +const LogoURL = "https://raw.githubusercontent.com/" + + "diamondburned/cchat-discord/himearikawa/discord_logo.png" + +// Logo implements cchat.Iconer for the Discord logo. +var Logo cchat.Iconer = logo{} + +type logo struct{} + +func (logo) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { + iconer.SetIcon(LogoURL) + return func() {}, nil +}