Upgraded to cchat v2

This commit is contained in:
diamondburned 2020-10-06 18:53:15 -07:00
parent e9796170f8
commit da520786d7
33 changed files with 955 additions and 739 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
logo.go Normal file
View File

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