diff --git a/src/irc/identifiers.go b/src/irc/identifiers.go deleted file mode 100644 index d8005d0..0000000 --- a/src/irc/identifiers.go +++ /dev/null @@ -1,61 +0,0 @@ -package irc - -import ( - "fmt" - "regexp" - "strings" -) - -var ErrNotANick = fmt.Errorf("does not look like a nickname") - -var regexpNick = regexp.MustCompile("^[a-zA-Z0-9]+$") // NOTE: more constrained than real character set - -type Nick string -type CanonicalNick string - -func ValidateNick(s string) (Nick, error) { - // TODO: Fail if the string doesn't look like a nick - if !regexpNick.MatchString(s) { - return "", fmt.Errorf("%w: %s", ErrNotANick, s) - } - return Nick(s), nil -} - -func (n Nick) Canonize() CanonicalNick { - return CanonicalNick(strings.ToLower(string(n))) -} - -func (n *Nick) CanonizeNullable() *CanonicalNick { - if n == nil { - return nil - } - result := n.Canonize() - return &result -} - -var ErrNotAChannel = fmt.Errorf("does not look like a channel name") - -var regexpChannel = regexp.MustCompile("^#[a-zA-Z0-9]+$") // NOTE: more constrained than real character set - -type Channel string -type CanonicalChannel string - -func ValidateChannel(s string) (Channel, error) { - // TODO: Fail if the string doesn't look like a channel name - if !regexpChannel.MatchString(s) { - return "", fmt.Errorf("%w: %s", ErrNotAChannel, s) - } - return Channel(s), nil -} - -func (c Channel) Canonize() CanonicalChannel { - return CanonicalChannel(strings.ToLower(string(c))) -} - -func (c *Channel) CanonizeNullable() *CanonicalChannel { - if c == nil { - return nil - } - result := c.Canonize() - return &result -} diff --git a/src/irc/users/system.go b/src/irc/users/system.go index 07fec43..82abcb9 100644 --- a/src/irc/users/system.go +++ b/src/irc/users/system.go @@ -1,183 +1 @@ package users - -import ( - "fmt" - "slices" - - "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/errors" - "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" -) - -type UserId uint64 - -type UsersSystem struct { - clientIdIndex map[transport.ClientId]*User - nickIndex map[canonicalNick]*User - channelNameIndex map[canonicalChannelName](map[*User]struct{}) -} - -type User struct { - users *UsersSystem - - clientId transport.ClientId - nick *Nick - username *string - realName *string - - hasReceivedAuthHandshakeReply bool - - channels []ChannelName -} - -func NewUsersSystem() *UsersSystem { - return &UsersSystem{ - clientIdIndex: make(map[transport.ClientId]*User), - nickIndex: make(map[canonicalNick]*User), - channelNameIndex: make(map[canonicalChannelName]map[*User]struct{}), - } -} - -func (users *UsersSystem) ByClientIdOrCreate(clientId transport.ClientId) *User { - existing, ok := users.clientIdIndex[clientId] - - if ok { - return existing - } - - user := &User{ - users: users, - - clientId: clientId, - nick: nil, - username: nil, - realName: nil, - - hasReceivedAuthHandshakeReply: false, - - channels: nil, - } - users.clientIdIndex[clientId] = user - return user -} - -func (users *UsersSystem) ByNick(nick Nick) *User { - return users.nickIndex[nick.canonical] -} - -func (users *UsersSystem) ByChannel(channelName ChannelName) map[*User]struct{} { - return users.channelNameIndex[channelName.canonical] -} - -func (user *User) GetClientId() transport.ClientId { - return user.clientId -} - -func (user *User) GetNick() *Nick { - return user.nick -} - -func (user *User) SetNick(newNick *Nick) error { - users := user.users - oldNick := user.nick - - // check if already in use -- if so, refuse - _, alreadyInUse := users.nickIndex[newNick.canonical] - if alreadyInUse { - if oldNick != nil && newNick.canonical == oldNick.canonical { - // it's fine, this is the user who held that nick - // so continue as before - } else { - return fmt.Errorf("%w: %s", errors.ErrNickAlreadyInUse, newNick.Value) - } - } - - // update indexes - if oldNick != nil { - delete(users.nickIndex, oldNick.canonical) - } - if newNick != nil { - users.nickIndex[newNick.canonical] = user - } - - // update me - user.nick = newNick - return nil -} - -func (user *User) GetUsername() *string { - return user.username -} - -func (user *User) SetUsername(username *string) { - user.username = username -} - -func (user *User) GetRealName() *string { - return user.realName -} - -func (user *User) SetRealName(realName *string) { - user.realName = realName -} - -func (user *User) GetHasReceivedAuthHandshakeReply() bool { - return user.hasReceivedAuthHandshakeReply -} - -func (user *User) SetHasReceivedAuthHandshakeReply(value bool) { - user.hasReceivedAuthHandshakeReply = value -} - -func (user *User) IsInChannel(channelName ChannelName) bool { - return slices.ContainsFunc(user.channels, func(existingChannel ChannelName) bool { - return channelName.canonical == existingChannel.canonical - }) -} - -func (user *User) Join(channelName ChannelName) error { - users := user.users - - // if I'm already in this channel, don't join - if user.IsInChannel(channelName) { - return fmt.Errorf("%w: %s", errors.ErrAlreadyInChannel, channelName.Value) - } - - // update indexes - existing, ok := users.channelNameIndex[channelName.canonical] - if !ok { - existing = make(map[*User]struct{}) - users.channelNameIndex[channelName.canonical] = existing - } - _, wasInChannel := existing[user] - if wasInChannel { - panic("tried to join a channel, but I was mysteriously already in it") - } - existing[user] = struct{}{} - - // update me - user.channels = append(user.channels, channelName) - - return nil -} - -func (user *User) Part(channelName ChannelName) error { - users := user.users - - // if i'm not in this channel, don't part - if !user.IsInChannel(channelName) { - return fmt.Errorf("%w: %s", errors.ErrNotInChannel, channelName.Value) - } - - // update indexes - existing, ok := users.channelNameIndex[channelName.canonical] - if ok { - delete(existing, user) - if len(existing) == 0 { - delete(users.channelNameIndex, channelName.canonical) - } - } else { - panic("tried to part from a channel, but was mysteriously absent from it") - } - - return nil -} diff --git a/src/irc/v2/broadcast.go b/src/irc/v2/broadcast.go new file mode 100644 index 0000000..d43925d --- /dev/null +++ b/src/irc/v2/broadcast.go @@ -0,0 +1,24 @@ +package v2 + +import "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" + +type BroadcastGroup struct { + channels []canonicalChannelName + users []*User +} + +func NewBroadcastGroup() *BroadcastGroup { + return &BroadcastGroup{} +} + +func (bcg *BroadcastGroup) AddChannels(names ...canonicalChannelName) { + bcg.channels = append(bcg.channels, names...) +} + +func (bcg *BroadcastGroup) AddUsers(users ...*User) { + bcg.users = append(bcg.users, users...) +} + +func Broadcast(bcg *BroadcastGroup, content transport.Content) { + panic("TODO") +} diff --git a/src/irc/errors/errors.go b/src/irc/v2/errors.go similarity index 71% rename from src/irc/errors/errors.go rename to src/irc/v2/errors.go index ebf0f0e..107d7f9 100644 --- a/src/irc/errors/errors.go +++ b/src/irc/v2/errors.go @@ -1,7 +1,8 @@ -package errors +package v2 import "fmt" var ErrAlreadyInChannel = fmt.Errorf("already in channel") +var ErrNotANick = fmt.Errorf("does not look like a nickname") var ErrNickAlreadyInUse = fmt.Errorf("nick already in use") var ErrNotInChannel = fmt.Errorf("not in channel") diff --git a/src/irc/v2/globals.go b/src/irc/v2/globals.go new file mode 100644 index 0000000..3f3ad98 --- /dev/null +++ b/src/irc/v2/globals.go @@ -0,0 +1,30 @@ +package v2 + +type Dispatcher interface { + Salient() []interface{} +} + +type uninitializedDispatcher struct{} + +func (u uninitializedDispatcher) Salient() []interface{} { + panic("dispatcher should have been published") +} + +var GlobalDispatcher Dispatcher = uninitializedDispatcher{} + +func Dispatch[T any](askPermission func(T) error, act func(T)) error { + for _, handler := range GlobalDispatcher.Salient() { + if h, ok := handler.(T); ok { + err := askPermission(h) + if err != nil { + return err + } + } + } + for _, handler := range GlobalDispatcher.Salient() { + if h, ok := handler.(T); ok { + act(h) + } + } + return nil +} diff --git a/src/irc/v2/handlerTypes.go b/src/irc/v2/handlerTypes.go new file mode 100644 index 0000000..9732425 --- /dev/null +++ b/src/irc/v2/handlerTypes.go @@ -0,0 +1,38 @@ +package v2 + +type Handler interface { + AssertTypes() +} + +type NickChangeHandler interface { + AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error + HandleNickChange(user *User, oldNick *Nick, newNick *Nick) +} + +type PartJoinHandler interface { + AskPermissionForPart(user *User, channelName ChannelName, reason *string) error + HandlePart(user *User, channelName ChannelName, reason *string) + AskPermissionForJoin(user *User, channelName ChannelName) error + HandleJoin(user *User, channelName ChannelName) +} + +type ChatMode string + +const ( + ChatModePrivmsg ChatMode = "PRIVMSG" + ChatModeNotice = "NOTICE" +) + +type ChatHandler interface { + AskPermissionForUserMessage(user *User, mode ChatMode, destination *User, message string) error + HandleUserMessage(user *User, mode ChatMode, destination *User, message string) + + AskPermissionForChannelMessage(user *User, mode ChatMode, destination ChannelName, message string) error + HandleChannelMessage(user *User, mode ChatMode, destination ChannelName, message string) +} + +/* +type BroadcastHandler interface { + HandleBroadcastMessage(channel string) error +} +*/ diff --git a/src/irc/v2/notificationsSystem.go b/src/irc/v2/notificationsSystem.go new file mode 100644 index 0000000..8bd18e5 --- /dev/null +++ b/src/irc/v2/notificationsSystem.go @@ -0,0 +1,109 @@ +package v2 + +import "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" + +type NotificationsSystem struct { +} + +func (notifications *NotificationsSystem) AssertTypes() { + var _ NickChangeHandler = notifications + var _ PartJoinHandler = notifications + var _ ChatHandler = notifications +} + +func (notifications *NotificationsSystem) AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error { + return nil +} + +func (notifications *NotificationsSystem) HandleNickChange(user *User, oldNick *Nick, newNick *Nick) { + if newNick == nil { + return // not expressible in the IRC protocol; probably shouldn't be allowed? + } + + src := user.GetSourceString() + content := transport.Content{ + Source: &src, + Command: "NICK", + Arguments: []string{newNick.Value}, + } + group := NewBroadcastGroup() + group.AddChannels(user.GetChannels()...) + group.AddUsers(user) + Broadcast(group, content) +} + +// TODO: Always present the channel name in its _established_ notation +func (notifications *NotificationsSystem) AskPermissionForPart(user *User, channelName ChannelName, reason *string) error { + return nil +} + +func (notifications *NotificationsSystem) HandlePart(user *User, channelName ChannelName, reason *string) { + src := user.GetSourceString() + + var args []string + args = append(args, string(channelName.Value)) + if reason != nil { + args = append(args, *reason) + } + content := transport.Content{ + Source: &src, + Command: "PART", + Arguments: args, + } + group := NewBroadcastGroup() + group.AddChannels(user.GetChannels()...) + group.AddUsers(user) + Broadcast(group, content) +} + +func (notifications *NotificationsSystem) AskPermissionForJoin(user *User, channelName ChannelName) error { + return nil +} + +func (notifications *NotificationsSystem) HandleJoin(user *User, channelName ChannelName) { + src := user.GetSourceString() + + content := transport.Content{ + Source: &src, + Command: "JOIN", + Arguments: []string{string(channelName.Value)}, + } + group := NewBroadcastGroup() + group.AddChannels(user.GetChannels()...) + group.AddUsers(user) + Broadcast(group, content) +} + +func (notifications *NotificationsSystem) AskPermissionForUserMessage(user *User, mode ChatMode, destination *User, message string) error { + return nil +} + +func (notifications *NotificationsSystem) HandleUserMessage(user *User, mode ChatMode, destination *User, message string) { + src := user.GetSourceString() + + content := transport.Content{ + Source: &src, + Command: string(mode), + Arguments: []string{destination.GetNick().Value, message}, + } + group := NewBroadcastGroup() + group.AddUsers(destination) + Broadcast(group, content) +} + +func (notifications *NotificationsSystem) AskPermissionForChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) error { + return nil +} + +func (notifications *NotificationsSystem) HandleChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) { + src := user.GetSourceString() + + content := transport.Content{ + Source: &src, + Command: string(mode), + Arguments: []string{string(destination), message}, + } + group := NewBroadcastGroup() + group.AddChannels(destination) + Broadcast(group, content) +} diff --git a/src/irc/users/types.go b/src/irc/v2/types.go similarity index 93% rename from src/irc/users/types.go rename to src/irc/v2/types.go index a551173..61f3a1d 100644 --- a/src/irc/users/types.go +++ b/src/irc/v2/types.go @@ -1,4 +1,4 @@ -package users +package v2 import ( "fmt" @@ -12,7 +12,6 @@ type Nick struct { } type canonicalNick string -var ErrNotANick = fmt.Errorf("does not look like a nickname") var regexpNick = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) // NOTE: more constrained than real character set func ValidateNick(input string) (Nick, error) { diff --git a/src/irc/v2/user.go b/src/irc/v2/user.go new file mode 100644 index 0000000..ed4acf3 --- /dev/null +++ b/src/irc/v2/user.go @@ -0,0 +1,108 @@ +package v2 + +import ( + "slices" + + "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" +) + +type User struct { + clientId transport.ClientId + sourceString string + nick *Nick + username *string + realName *string + + hasReceivedAuthHandshakeReply bool + + channels []canonicalChannelName +} + +func NewUsersSystem() *UsersSystem { + return &UsersSystem{ + clientIdIndex: make(map[transport.ClientId]*User), + nickIndex: make(map[canonicalNick]*User), + channelNameIndex: make(map[canonicalChannelName]map[*User]struct{}), + } +} + +func (user *User) GetClientId() transport.ClientId { + return user.clientId +} + +func (user *User) GetSourceString() string { + return user.sourceString +} + +func (user *User) GetNick() *Nick { + return user.nick +} + +func (user *User) SetNick(newNick *Nick) error { + oldNick := user.nick + return Dispatch( + func(nch NickChangeHandler) error { + return nch.AskPermissionForNickChange(user, oldNick, newNick) + }, + func(nch NickChangeHandler) { + nch.HandleNickChange(user, oldNick, newNick) + }, + ) +} + +func (user *User) GetUsername() *string { + return user.username +} + +func (user *User) SetUsername(username *string) { + user.username = username +} + +func (user *User) GetRealName() *string { + return user.realName +} + +func (user *User) SetRealName(realName *string) { + user.realName = realName +} + +func (user *User) GetHasReceivedAuthHandshakeReply() bool { + return user.hasReceivedAuthHandshakeReply +} + +func (user *User) SetHasReceivedAuthHandshakeReply(value bool) { + user.hasReceivedAuthHandshakeReply = value +} + +func (user *User) IsInChannel(channelName ChannelName) bool { + return slices.ContainsFunc(user.channels, func(existingChannel canonicalChannelName) bool { + return channelName.canonical == existingChannel + }) +} + +func (user *User) GetChannels() []canonicalChannelName { + return user.channels + +} + +func (user *User) Join(channelName ChannelName) error { + return Dispatch( + func(pjh PartJoinHandler) error { + return pjh.AskPermissionForJoin(user, channelName) + }, + func(pjh PartJoinHandler) { + pjh.HandleJoin(user, channelName) + }, + ) +} + +func (user *User) Part(channelName ChannelName, reason *string) error { + return Dispatch( + func(pjh PartJoinHandler) error { + return pjh.AskPermissionForPart(user, channelName, reason) + }, + func(pjh PartJoinHandler) { + pjh.HandlePart(user, channelName, reason) + }, + ) +} diff --git a/src/irc/v2/usersSystem.go b/src/irc/v2/usersSystem.go new file mode 100644 index 0000000..7c5a04f --- /dev/null +++ b/src/irc/v2/usersSystem.go @@ -0,0 +1,113 @@ +package v2 + +import ( + "fmt" + "slices" + + "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" +) + +type UsersSystem struct { + clientIdIndex map[transport.ClientId]*User + nickIndex map[canonicalNick]*User + channelNameIndex map[canonicalChannelName](map[*User]struct{}) +} + +func (users *UsersSystem) ByClientIdOrCreate(clientId transport.ClientId) *User { + existing, ok := users.clientIdIndex[clientId] + + if ok { + return existing + } + + user := &User{ + clientId: clientId, + nick: nil, + username: nil, + realName: nil, + + hasReceivedAuthHandshakeReply: false, + + channels: nil, + } + users.clientIdIndex[clientId] = user + return user +} + +func (users *UsersSystem) ByNick(nick Nick) *User { + return users.nickIndex[nick.canonical] +} + +func (users *UsersSystem) ByChannel(channelName ChannelName) map[*User]struct{} { + return users.channelNameIndex[channelName.canonical] +} + +func (users *UsersSystem) AssertTypes() { + // statically assert that we implement the types we believe we do + var _ NickChangeHandler = users + var _ PartJoinHandler = users + +} + +func (users *UsersSystem) AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error { + if oldNick != nil && newNick != nil && oldNick.canonical == newNick.canonical { + // you're _always_ allowed to change your nick to the nick you currently have + return nil + } + + // is this someone else's nick? + _, ok := users.nickIndex[newNick.canonical] + if ok { + return fmt.Errorf("%w: %s", ErrNickAlreadyInUse, newNick.Value) + } + + return nil +} + +func (users *UsersSystem) HandleNickChange(user *User, oldNick *Nick, newNick *Nick) { + if oldNick != nil { + delete(users.nickIndex, oldNick.canonical) + } + user.nick = newNick + + if newNick != nil { + users.nickIndex[newNick.canonical] = user + } +} + +func (users *UsersSystem) AskPermissionForPart(user *User, channelName ChannelName, reason *string) error { + if !slices.Contains(user.channels, channelName.canonical) { + return fmt.Errorf("%w: %s", ErrNotInChannel, channelName) + } + return nil +} + +func (users *UsersSystem) HandlePart(user *User, channelName ChannelName, reason *string) { + name := channelName.canonical + user.channels = slices.DeleteFunc(user.channels, func(ccn canonicalChannelName) bool { + return ccn == name + }) + + channelUsers := users.channelNameIndex[name] + delete(channelUsers, user) + if len(channelUsers) == 0 { + delete(users.channelNameIndex, name) + } +} + +func (users *UsersSystem) AskPermissionForJoin(user *User, channelName ChannelName) error { + if slices.Contains(user.channels, channelName.canonical) { + return fmt.Errorf("%w: %s", ErrAlreadyInChannel, channelName) + } + return nil +} + +func (users *UsersSystem) HandleJoin(user *User, channelName ChannelName) { + name := channelName.canonical + existing, ok := users.channelNameIndex[name] + if !ok { + existing = make(map[*User]struct{}) + users.channelNameIndex[name] = existing + } + existing[user] = struct{}{} +}