Compare commits

3 Commits

Author SHA1 Message Date
c3a7f7487c Rename file, remove unused type 2025-10-05 14:33:15 -07:00
1b099c400a Institute Inform-style rulebooks 2025-10-05 14:31:44 -07:00
5b0b6a48a9 Complete rewrite 2025-10-01 21:24:29 -07:00
22 changed files with 725 additions and 655 deletions

39
src/irc/actionTypes.go Normal file
View File

@@ -0,0 +1,39 @@
package irc
type NickChangeAction struct {
User *User
OldNick *Nick
NewNick *Nick
}
type PartAction struct {
User *User
ChannelName ChannelName
Reason *string
}
type JoinAction struct {
User *User
ChannelName ChannelName
}
type ChatMode string
const (
ChatModePrivmsg ChatMode = "PRIVMSG"
ChatModeNotice ChatMode = "NOTICE"
)
type SendMessageToUserAction struct {
User *User
Mode ChatMode
Destination *User
Message string
}
type SendMessageToChannelAction struct {
User *User
Mode ChatMode
Destination canonicalChannelName
Message string
}

46
src/irc/broadcast.go Normal file
View File

@@ -0,0 +1,46 @@
package irc
import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
type BroadcastGroup struct {
channels []canonicalChannelName
users []*User
specificallyExcludedUsers []*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 (bcg *BroadcastGroup) AddSpecificallyExcludedUsers(users ...*User) {
bcg.specificallyExcludedUsers = append(bcg.users, users...)
}
func Broadcast(bcg *BroadcastGroup, content transport.Content) {
g := GetGlobals()
allUsers := make(map[*User]struct{})
for _, c := range bcg.channels {
for u := range g.Users.ByCanonicalChannel(c) {
allUsers[u] = struct{}{}
}
}
for _, u := range bcg.users {
allUsers[u] = struct{}{}
}
for _, u := range bcg.specificallyExcludedUsers {
delete(allUsers, u)
}
for u := range allUsers {
g.Server.SendMessage(u.clientId, content)
}
}

View File

@@ -1,11 +0,0 @@
package commands
import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
)
func HandleCommands(msg world.WrappedMessage) {
handleAuthCommands(msg)
handleJoinPartCommands(msg)
handlePrivmsgNotifyCommands(msg)
}

View File

@@ -1,84 +0,0 @@
package commands
import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/users"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
func handleAuthCommands(msg world.WrappedMessage) {
handleNickAndUser(msg)
completeHandshakeIfPossible(msg)
}
func handleNickAndUser(msg world.WrappedMessage) {
if msg.Sender.GetHasReceivedAuthHandshakeReply() {
// TODO: Send an error reply
return
}
if msg.Content.Command == "NICK" {
args := msg.Content.Arguments
if len(args) != 1 {
// TODO: Send an error reply
return
}
nick := args[0]
validNick, err := users.ValidateNick(nick)
if err != nil {
// TODO: Send an error reply
return
}
err = msg.Sender.SetNick(&validNick)
if err != nil {
msg.World.Server.TerminateClient(msg.Sender.GetClientId(), err)
}
}
if msg.Content.Command == "USER" {
args := msg.Content.Arguments
if len(args) != 4 {
// TODO: Send an error reply
return
}
username := args[0]
zero := args[1]
star := args[2]
realName := args[3]
if zero != "0" || star != "*" {
// TODO: Send an error reply
return
}
msg.Sender.SetUsername(&username)
msg.Sender.SetRealName(&realName)
// TODO: Validation? I wonder if it matters.
}
}
func completeHandshakeIfPossible(msg world.WrappedMessage) {
sender := msg.Sender
if msg.Sender.GetHasReceivedAuthHandshakeReply() {
return
}
isReady := sender.GetNick() != nil && sender.GetUsername() != nil && sender.GetRealName() != nil
if !isReady {
return
}
sender.SetHasReceivedAuthHandshakeReply(true)
msg.World.Server.SendMessage(sender.GetClientId(), transport.Content{
Command: "NICK",
Arguments: []string{sender.GetNick().Value},
})
msg.World.Server.SendMessage(msg.Sender.GetClientId(), transport.Content{
Command: "USER",
Arguments: []string{*sender.GetUsername(), "0", "*", *sender.GetRealName()},
})
}

View File

@@ -1,59 +0,0 @@
package commands
import (
"strings"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/users"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
)
func handleJoinPartCommands(msg world.WrappedMessage) {
if msg.Content.Command == "JOIN" {
if len(msg.Content.Arguments) != 1 {
// TODO: Wrong number of arguments
return
}
channelsToJoin := parseChannelList(msg.Content.Arguments[0])
for _, channel := range channelsToJoin {
err := msg.Sender.Join(channel)
if err != nil {
msg.World.Server.TerminateClient(msg.Sender.GetClientId(), err)
return
}
msg.World.RelayToChannel(msg, channel, nil)
}
}
if msg.Content.Command == "PART" {
n := len(msg.Content.Arguments)
if !(n == 1 || n == 2) {
return
}
channelsToPart := parseChannelList(msg.Content.Arguments[0])
for _, channel := range channelsToPart {
err := msg.Sender.Part(channel)
if err != nil {
msg.World.Server.TerminateClient(msg.Sender.GetClientId(), err)
return
}
msg.World.RelayToChannel(msg, channel, nil)
// the user won't see their own #part because they left, so send it
msg.World.RelayToClient(msg, msg.Sender.GetClientId(), nil)
}
}
}
func parseChannelList(arg string) []users.ChannelName {
var channels []users.ChannelName
for _, channelName := range strings.Split(arg, ",") {
validChannel, err := users.ValidateChannelName(channelName)
if err != nil { // can't join, not a channel
continue
}
channels = append(channels, validChannel)
}
return channels
}

View File

@@ -1,21 +0,0 @@
package commands
import (
"log"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
func handlePrivmsgNotifyCommands(msg world.WrappedMessage) {
if msg.Content.Command == "PRIVMSG" || msg.Content.Command == "NOTIFY" || msg.Content.Command == "CTCP" {
log.Printf("message-like command")
if len(msg.Content.Arguments) == 0 {
// TODO: Error reply
return
}
// Was this message to a user?
msg.World.RelayToVagueDestination(msg, msg.Content.Arguments[0], []transport.ClientId{msg.Sender.GetClientId()})
}
}

View File

@@ -1,8 +1,9 @@
package v2 package irc
import "fmt" import "fmt"
var ErrAlreadyInChannel = fmt.Errorf("already in channel") var ErrAlreadyInChannel = fmt.Errorf("already in channel")
var ErrMalformedCommand = fmt.Errorf("malformed command")
var ErrNotANick = fmt.Errorf("does not look like a nickname") var ErrNotANick = fmt.Errorf("does not look like a nickname")
var ErrNickAlreadyInUse = fmt.Errorf("nick already in use") var ErrNickAlreadyInUse = fmt.Errorf("nick already in use")
var ErrNotInChannel = fmt.Errorf("not in channel") var ErrNotInChannel = fmt.Errorf("not in channel")

41
src/irc/globals.go Normal file
View File

@@ -0,0 +1,41 @@
package irc
import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/logic"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
type Globals struct {
Server *transport.Server
Rulebook *logic.Rulebook
Users *UsersSystem
Notifications *NotificationsSystem
}
var globals *Globals = nil
func InitializeGlobals(server *transport.Server) {
globals = &Globals{
Server: server,
Rulebook: logic.NewRulebook(),
Users: NewUsersSystem(),
Notifications: NewNotificationsSystem(),
}
globals.Users.InitializeRules()
globals.Notifications.InitializeRules()
}
func GetGlobals() *Globals {
if globals == nil {
panic("globals not initialized")
}
return globals
}
func AddNewRule[T any](check func(T) error, carryOut func(T)) {
GetGlobals().Rulebook.Add(logic.NewRule(check, carryOut))
}
func Perform[T any](action T) error {
return GetGlobals().Rulebook.Perform(action)
}

View File

@@ -1,15 +1,15 @@
package irc package irc
import ( import (
"fmt"
"log" "log"
"strings"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/commands"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
) )
func ServeIrc(server *transport.Server) { func ServeIrc(server *transport.Server) {
world := world.NewWorld(server) InitializeGlobals(server)
for { for {
rawMessage, err := server.ReceiveMessage() rawMessage, err := server.ReceiveMessage()
@@ -18,8 +18,180 @@ func ServeIrc(server *transport.Server) {
return return
} }
wrappedMessage := world.Wrap(rawMessage) err = handleMessage(rawMessage)
if err != nil {
commands.HandleCommands(wrappedMessage) server.TerminateClient(rawMessage.Sender, err)
}
} }
} }
func handleMessage(m transport.IncomingMessage) error {
g := GetGlobals()
user := g.Users.ByClientIdOrCreate(m.Sender)
command := m.Content.Command
args := m.Content.Arguments
if command == "NICK" {
if len(args) != 1 {
return fmt.Errorf("%w: needs 1 argument", ErrMalformedCommand)
}
nick, err := ValidateNick(args[0])
if err != nil {
return err
}
err = user.SetNick(&nick)
if err != nil {
return err
}
return completeHandshakeIfPossible(user)
}
if command == "USER" {
if len(args) != 4 {
return fmt.Errorf("%w: needs 4 arguments", ErrMalformedCommand)
}
username := args[0]
zero := args[1]
star := args[2]
realName := args[3]
if zero != "0" || star != "*" {
return fmt.Errorf("%w: needs zero and star", ErrMalformedCommand)
}
user.SetUsername(&username)
user.SetRealName(&realName)
return completeHandshakeIfPossible(user)
}
if command == "JOIN" {
if len(args) != 1 {
return fmt.Errorf("%w: needs 1 argument", ErrMalformedCommand)
}
channelsToJoin := parseChannelList(args[0])
for _, channel := range channelsToJoin {
err := user.Join(channel)
log.Printf("joining %s %v", channel, err)
if err != nil {
return err
}
}
return nil
}
if command == "PART" {
if len(args) != 1 && len(args) != 2 {
return fmt.Errorf("%w: needs 1 or 2 arguments", ErrMalformedCommand)
}
channelsToPart := parseChannelList(args[0])
var reason *string
if len(args) == 2 {
reason = &args[1]
}
for _, channel := range channelsToPart {
err := user.Part(channel, reason)
if err != nil {
return err
}
}
return nil
}
if command == string(ChatModePrivmsg) || command == string(ChatModeNotice) {
mode := ChatMode(command)
if len(args) != 2 {
return fmt.Errorf("%w: needs 2 arguments", ErrMalformedCommand)
}
channelName, err := ValidateChannelName(args[0])
if err == nil {
return handleChannelMessage(user, mode, channelName.canonical, args[1])
}
nick, err := ValidateNick(args[0])
if err == nil {
other := g.Users.ByNick(nick)
if other == nil {
// TODO: Error for missing user
return nil
}
return handleUserMessage(user, mode, other, args[1])
}
return fmt.Errorf("%w: needs nick or channel name", ErrMalformedCommand)
}
// Unrecognized command
return nil
}
func completeHandshakeIfPossible(user *User) error {
if user.GetHasReceivedAuthHandshakeReply() {
log.Printf("has already completed handshake!")
return nil
}
nick := user.GetNick()
username := user.GetUsername()
realName := user.GetRealName()
isReady := !(nick == nil || username == nil || realName == nil)
if !isReady {
log.Printf("not ready to complete handshake!")
return nil
}
log.Printf("time to complete handshake!!")
user.SetHasReceivedAuthHandshakeReply(true)
group := NewBroadcastGroup()
group.AddUsers(user)
Broadcast(group, transport.Content{
Command: "NICK",
Arguments: []string{nick.Value},
})
Broadcast(group, transport.Content{
Command: "USER",
Arguments: []string{nick.Value, "0", "*", *realName},
})
return nil
}
func parseChannelList(arg string) []ChannelName {
var channels []ChannelName
for _, channelName := range strings.Split(arg, ",") {
validChannel, err := ValidateChannelName(channelName)
if err != nil { // can't join, not a channel
continue
}
channels = append(channels, validChannel)
}
return channels
}
func handleChannelMessage(sender *User, mode ChatMode, destination canonicalChannelName, message string) error {
return Perform(SendMessageToChannelAction{
User: sender,
Mode: mode,
Destination: destination,
Message: message,
})
}
func handleUserMessage(sender *User, mode ChatMode, destination *User, message string) error {
return Perform(SendMessageToUserAction{
User: sender,
Mode: mode,
Destination: destination,
Message: message,
})
}

View File

@@ -0,0 +1,150 @@
package irc
import (
"strings"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
type NotificationsSystem struct {
}
func NewNotificationsSystem() *NotificationsSystem {
return &NotificationsSystem{}
}
func (notifications *NotificationsSystem) InitializeRules() {
// nick changes
AddNewRule(
func(nc NickChangeAction) error { return nil },
func(nc NickChangeAction) {
if nc.NewNick == nil {
return // not expressible in the IRC protocol; probably shouldn't be allowed?
}
src := nc.User.GetSourceString()
content := transport.Content{
Source: &src,
Command: "NICK",
Arguments: []string{nc.NewNick.Value},
}
group := NewBroadcastGroup()
group.AddChannels(nc.User.GetChannels()...)
group.AddUsers(nc.User)
Broadcast(group, content)
},
)
// joins
AddNewRule(
func(j JoinAction) error { return nil },
func(j JoinAction) {
src := j.User.GetSourceString()
content := transport.Content{
Source: &src,
Command: "JOIN",
Arguments: []string{string(j.ChannelName.Value)},
}
group := NewBroadcastGroup()
group.AddChannels(j.ChannelName.canonical)
group.AddUsers(j.User)
Broadcast(group, content)
// tell the user who is here
group = NewBroadcastGroup()
group.AddUsers(j.User)
src2 := "server"
var nameList strings.Builder
var i = 0
for user := range GetGlobals().Users.ByChannel(j.ChannelName) {
if i != 0 {
nameList.WriteString(" ")
}
nameList.WriteString(user.nick.Value)
i += 1
}
Broadcast(group, transport.Content{
Source: &src2,
Command: "332",
Arguments: []string{
j.User.nick.Value, j.ChannelName.Value, "TODO: topic!",
},
})
Broadcast(group, transport.Content{
Source: &src2,
Command: "353",
Arguments: []string{
j.User.nick.Value, "=", j.ChannelName.Value, nameList.String(),
},
})
Broadcast(group, transport.Content{
Source: &src2,
Command: "366",
Arguments: []string{
j.User.nick.Value, j.ChannelName.Value, "End of /NAMES list",
},
})
},
)
// parts
AddNewRule(
func(p PartAction) error { return nil },
func(p PartAction) {
src := p.User.GetSourceString()
var args []string
args = append(args, string(p.ChannelName.Value))
if p.Reason != nil {
args = append(args, *p.Reason)
}
content := transport.Content{
Source: &src,
Command: "PART",
Arguments: args,
}
group := NewBroadcastGroup()
group.AddChannels(p.ChannelName.canonical)
group.AddUsers(p.User)
Broadcast(group, content)
},
)
// messages (to user)
AddNewRule(
func(s SendMessageToUserAction) error { return nil },
func(s SendMessageToUserAction) {
src := s.User.GetSourceString()
content := transport.Content{
Source: &src,
Command: string(s.Mode),
Arguments: []string{s.Destination.GetNick().Value, s.Message},
}
group := NewBroadcastGroup()
group.AddUsers(s.Destination)
Broadcast(group, content)
},
)
// messages (to channel)
AddNewRule(
func(s SendMessageToChannelAction) error { return nil },
func(s SendMessageToChannelAction) {
src := s.User.GetSourceString()
content := transport.Content{
Source: &src,
Command: string(s.Mode),
Arguments: []string{string(s.Destination), s.Message},
}
group := NewBroadcastGroup()
group.AddChannels(s.Destination)
group.AddSpecificallyExcludedUsers(s.User)
Broadcast(group, content)
},
)
}

View File

@@ -1,4 +1,4 @@
package v2 package irc
import ( import (
"fmt" "fmt"

View File

@@ -1,6 +1,7 @@
package v2 package irc
import ( import (
"fmt"
"slices" "slices"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
@@ -8,7 +9,8 @@ import (
type User struct { type User struct {
clientId transport.ClientId clientId transport.ClientId
sourceString string hostString *string
sourceString *string
nick *Nick nick *Nick
username *string username *string
realName *string realName *string
@@ -18,20 +20,27 @@ type User struct {
channels []canonicalChannelName channels []canonicalChannelName
} }
func NewUsersSystem() *UsersSystem { func (user *User) recomputeSourceString() {
return &UsersSystem{ nick := "unknown"
clientIdIndex: make(map[transport.ClientId]*User), if user.nick != nil {
nickIndex: make(map[canonicalNick]*User), nick = (*user.nick).Value
channelNameIndex: make(map[canonicalChannelName]map[*User]struct{}),
} }
hstr := fmt.Sprintf("clients/%d", user.clientId)
sstr := fmt.Sprintf("%s!%s", nick, hstr)
user.hostString = &hstr
user.sourceString = &sstr
} }
func (user *User) GetClientId() transport.ClientId { func (user *User) GetClientId() transport.ClientId {
return user.clientId return user.clientId
} }
func (user *User) GetHostString() string {
return *user.hostString
}
func (user *User) GetSourceString() string { func (user *User) GetSourceString() string {
return user.sourceString return *user.sourceString
} }
func (user *User) GetNick() *Nick { func (user *User) GetNick() *Nick {
@@ -39,15 +48,7 @@ func (user *User) GetNick() *Nick {
} }
func (user *User) SetNick(newNick *Nick) error { func (user *User) SetNick(newNick *Nick) error {
oldNick := user.nick return Perform(NickChangeAction{User: user, OldNick: user.nick, NewNick: newNick})
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 { func (user *User) GetUsername() *string {
@@ -86,23 +87,9 @@ func (user *User) GetChannels() []canonicalChannelName {
} }
func (user *User) Join(channelName ChannelName) error { func (user *User) Join(channelName ChannelName) error {
return Dispatch( return Perform(JoinAction{User: user, ChannelName: channelName})
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 { func (user *User) Part(channelName ChannelName, reason *string) error {
return Dispatch( return Perform(PartAction{User: user, ChannelName: channelName, Reason: reason})
func(pjh PartJoinHandler) error {
return pjh.AskPermissionForPart(user, channelName, reason)
},
func(pjh PartJoinHandler) {
pjh.HandlePart(user, channelName, reason)
},
)
} }

View File

@@ -1 +0,0 @@
package users

129
src/irc/usersSystem.go Normal file
View File

@@ -0,0 +1,129 @@
package irc
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 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{
clientId: clientId,
nick: nil,
username: nil,
realName: nil,
hasReceivedAuthHandshakeReply: false,
channels: nil,
}
user.recomputeSourceString()
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) ByCanonicalChannel(channelName canonicalChannelName) map[*User]struct{} {
return users.channelNameIndex[channelName]
}
func (users *UsersSystem) InitializeRules() {
// nick changes
AddNewRule(
func(nc NickChangeAction) error {
// users can always change from one nick to the same nick
if nc.OldNick != nil && nc.NewNick != nil && nc.OldNick.canonical == nc.NewNick.canonical {
return nil
}
// is this someone else's nick?
_, ok := users.nickIndex[nc.NewNick.canonical]
if ok {
return fmt.Errorf("%w: %s", ErrNickAlreadyInUse, nc.NewNick.Value)
}
return nil
},
func(nc NickChangeAction) {
if nc.OldNick != nil {
delete(users.nickIndex, nc.OldNick.canonical)
}
nc.User.nick = nc.NewNick
nc.User.recomputeSourceString()
if nc.NewNick != nil {
users.nickIndex[nc.NewNick.canonical] = nc.User
}
},
)
// joining channel
AddNewRule(
func(j JoinAction) error {
if slices.Contains(j.User.channels, j.ChannelName.canonical) {
return fmt.Errorf("%w: %s", ErrAlreadyInChannel, j.ChannelName)
}
return nil
},
func(j JoinAction) {
name := j.ChannelName.canonical
existing, ok := users.channelNameIndex[name]
if !ok {
existing = make(map[*User]struct{})
users.channelNameIndex[name] = existing
}
existing[j.User] = struct{}{}
},
)
// parting channel
AddNewRule(
func(p PartAction) error {
if !slices.Contains(p.User.channels, p.ChannelName.canonical) {
return fmt.Errorf("%w: %s", ErrNotInChannel, p.ChannelName.Value)
}
return nil
},
func(p PartAction) {
name := p.ChannelName.canonical
p.User.channels = slices.DeleteFunc(p.User.channels, func(ccn canonicalChannelName) bool {
return ccn == name
})
channelUsers := users.channelNameIndex[name]
delete(channelUsers, p.User)
if len(channelUsers) == 0 {
delete(users.channelNameIndex, name)
}
},
)
}

View File

@@ -1,24 +0,0 @@
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")
}

View File

@@ -1,30 +0,0 @@
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
}

View File

@@ -1,38 +0,0 @@
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
}
*/

View File

@@ -1,109 +0,0 @@
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)
}

View File

@@ -1,113 +0,0 @@
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{}{}
}

View File

@@ -1,122 +0,0 @@
package world
import (
"fmt"
"log"
"slices"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/users"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
type World struct {
Server *transport.Server
UsersSystem *users.UsersSystem
}
type WrappedMessage struct {
World *World
Sender *users.User
Content transport.Content
}
func NewWorld(server *transport.Server) *World {
usersSystem := users.NewUsersSystem()
return &World{
Server: server,
UsersSystem: usersSystem,
}
}
func (world *World) Wrap(msg transport.IncomingMessage) WrappedMessage {
sender := world.UsersSystem.ByClientIdOrCreate(msg.Sender)
return WrappedMessage{
World: world,
Sender: sender,
Content: msg.Content,
}
}
// transmission of messages
func (world *World) RelayToVagueDestination(
msg WrappedMessage,
name string,
exclude []transport.ClientId,
) {
nick, err := users.ValidateNick(name)
if err == nil {
// so it's a nick!
world.RelayToNick(msg, nick, exclude)
return
}
channel, err := users.ValidateChannelName(name)
if err == nil {
// so it's a channel!
world.RelayToChannel(msg, channel, exclude)
return
}
log.Fatalf("not sure how to send to %s", name)
// TODO: Error response: "what is this?"
}
func (world *World) RelayToClient(
msg WrappedMessage,
client transport.ClientId,
exclude []transport.ClientId,
) {
content := createAnnotatedContent(msg)
if slices.Contains(exclude, client) {
return // don't relay
}
world.Server.SendMessage(client, content)
}
func (world *World) RelayToNick(
msg WrappedMessage,
nick users.Nick,
exclude []transport.ClientId,
) {
content := createAnnotatedContent(msg)
user := world.UsersSystem.ByNick(nick)
if user == nil {
// TODO: Send an error reply. The user didn't exist
return
}
if slices.Contains(exclude, user.GetClientId()) {
return // don't relay
}
world.Server.SendMessage(user.GetClientId(), content)
}
func (world *World) RelayToChannel(
msg WrappedMessage,
channelName users.ChannelName,
exclude []transport.ClientId,
) {
content := createAnnotatedContent(msg)
members := world.UsersSystem.ByChannel(channelName)
log.Printf("Members of %s: %v\n", channelName, members)
for member := range members {
if slices.Contains(exclude, member.GetClientId()) {
return // don't relay
}
world.Server.SendMessage(member.GetClientId(), content)
}
}
func createAnnotatedContent(
msg WrappedMessage,
) transport.Content {
content := msg.Content
fullSource := fmt.Sprintf("%s!clients/%d", msg.Sender.GetNick().Value, msg.Sender.GetClientId())
content.Source = &fullSource
return content
}

115
src/logic/rules.go Normal file
View File

@@ -0,0 +1,115 @@
package logic
/// An Inform 7-style rulebook.
///
/// This is pretty icky, but basically the concept here:
///
/// For any action type, you can make a series of rules to:
///
/// (1) check if it's permissible
/// (2) actually carry it out
///
/// These rules will be fired off in the order that they were added
/// with the rule that a failed check results in an error,
/// and a successful check results in all of the carryOut steps
/// actually being caused to occur. (They are not allowed to fail.)
type Rulebook struct {
chapters []anyChapter
}
type chapter[T any] struct {
check []func(T) error
carryOut []func(T)
}
type anyChapter interface {
dispatchOn(interface{}) (bool, error)
}
type rule[T any] struct {
check *func(T) error
carryOut *func(T)
}
type AnyRule interface {
addTo(rb *Rulebook)
}
func NewRulebook() *Rulebook {
return &Rulebook{}
}
func NewRule[T any](check func(T) error, carryOut func(T)) *rule[T] {
return &rule[T]{
check: &check,
carryOut: &carryOut,
}
}
// why is this done in the rule and not the rulebook?
// because go lacks parameterized types on methods, so we _have_ to
// flip the control flow to put this logic in the type with the most knowledge
// of the generic type
func (r *rule[T]) addTo(rb *Rulebook) {
for i, ch := range rb.chapters {
if chSpecific, ok := ch.(chapter[T]); ok {
r.addToChapter(&chSpecific)
rb.chapters[i] = chSpecific
return
}
}
newChapter := chapter[T]{}
r.addToChapter(&newChapter)
rb.chapters = append(rb.chapters, newChapter)
}
func (r *rule[T]) addToChapter(chapter *chapter[T]) {
if check := r.check; check != nil {
chapter.check = append(chapter.check, *check)
}
if carryOut := r.carryOut; carryOut != nil {
chapter.carryOut = append(chapter.carryOut, *carryOut)
}
}
func (rb *Rulebook) Add(r AnyRule) {
r.addTo(rb)
}
func (rb *Rulebook) Perform(action interface{}) error {
for _, ch := range rb.chapters {
handled, err := ch.dispatchOn(action)
if handled {
return err
}
}
// there were zero rules
return nil
}
func (ch chapter[T]) dispatchOn(action interface{}) (bool, error) {
if specializedAction, ok := action.(T); ok {
return true, ch.dispatchOnSpecific(specializedAction)
}
// false: wrong dispatcher
return false, nil
}
func (ch chapter[T]) dispatchOnSpecific(item T) error {
for _, checkRule := range ch.check {
err := checkRule(item)
if err != nil {
return err
}
}
for _, carryOutRule := range ch.carryOut {
carryOutRule(item)
}
return nil
}

View File

@@ -29,7 +29,7 @@ func NewServer(address string) (*Server, error) {
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
connectedClients: newConnectedClients(), connectedClients: newConnectedClients(),
incomingMessages: make(chan IncomingMessage), incomingMessages: make(chan IncomingMessage, 1024),
} }
go (func() { go (func() {
@@ -55,7 +55,7 @@ func (server *Server) handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
clientCtx, cancel := context.WithCancelCause(server.ctx) clientCtx, cancel := context.WithCancelCause(server.ctx)
outgoingMessages := make(chan OutgoingMessage) outgoingMessages := make(chan OutgoingMessage, 1024)
clientId := server.connectedClients.Enroll(func(id ClientId) ConnectedClient { clientId := server.connectedClients.Enroll(func(id ClientId) ConnectedClient {
return ConnectedClient{ return ConnectedClient{
@@ -141,7 +141,9 @@ func (server *Server) SendMessage(client ClientId, content Content) {
Content: content, Content: content,
} }
server.connectedClients.BorrowIfPresent(client, func(connectedClient *ConnectedClient) { server.connectedClients.BorrowIfPresent(client, func(connectedClient *ConnectedClient) {
log.Printf("putting in outgoing")
connectedClient.outgoingMessages <- outgoing connectedClient.outgoingMessages <- outgoing
log.Printf("done putting in outgoing")
}) })
} }