Compare commits

..
12 Commits
Author SHA1 Message Date
Illusion 573a0da672 feat: handle KICK command/reply 2025-08-15 22:21:34 +02:00
Illusion 026021c118 fix: only join the initial channel after registration is complete
During registration, only a subset of commands are allowed.
Servers MUST send a RPL_WELCOME (001) after registration is complete so
we join the channel in response to that numeric reply.

https://modern.ircdocs.horse/#connection-registration
2025-08-15 22:18:55 +02:00
Illusion 1d10198d37 feat: handle ERR_NOSUCHNICK 2025-08-15 22:17:13 +02:00
Illusion eab5f8747a feat: support and handle changing nicknames 2025-08-15 21:35:27 +02:00
Illusion ff0e201e02 feat: support sending and receiving direct messages from other users 2025-08-15 21:34:04 +02:00
Illusion 0e213c691a feat: implement some miscellaneous numeric replies and errors 2025-08-15 21:21:21 +02:00
Illusion d5a99ada51 fix: better formatting for ERR_NEEDMOREPARAMS; display better information for LOCALUSERS and GLOBALUSERS 2025-08-15 21:18:40 +02:00
Illusion 41645fb679 feat: implement ISUPPORT and add support for a few easy features
fix: CAP ACK commands with no acknowledged capabilities causing an OOB.

Also log unsupported and unknown capabilities and features.
2025-08-15 21:10:52 +02:00
Illusion 4be731c813 irc: NOT IMPL -> UNIMPL; red color for error messages; deduplicate some code 2025-08-15 20:18:24 +02:00
Illusion 71471a0f69 sidepanel: sort user nicks alphabetically and ascendingly 2025-08-15 19:53:09 +02:00
Illusion 7e2f446671 refactor/feat: move IRCMessage to irc package; handle NOTICE commands 2025-08-13 13:05:15 +02:00
Illusion 6c2e54275c feat: implement TOPIC-related commands and replies 2025-08-13 11:31:16 +02:00
9 changed files with 767 additions and 189 deletions
+6
View File
@@ -69,6 +69,12 @@ func SwitchChannels() tea.Msg {
return SwitchChannelsMsg{} return SwitchChannelsMsg{}
} }
type UpdateNicksMsg struct{}
func UpdateNicks() tea.Msg {
return UpdateNicksMsg{}
}
type UpdateTabBarMsg struct{} type UpdateTabBarMsg struct{}
func UpdateTabBar() tea.Msg { func UpdateTabBar() tea.Msg {
+49 -16
View File
@@ -30,6 +30,16 @@ import (
"github.com/illusionman1212/gorc/ui" "github.com/illusionman1212/gorc/ui"
) )
type Message struct {
Timestamp string
Tags MessageTags // starts with @ | Optional
Source string // starts with : | Optional
Command string // can either be a string or a numeric value | Required
Parameters []string // Optional (Dependant on command)
}
type MessageTags map[string]string
type User struct { type User struct {
// User prefix in channel // User prefix in channel
Prefix string Prefix string
@@ -74,6 +84,9 @@ type Client struct {
// Active channel index // Active channel index
ActiveChannelIndex int ActiveChannelIndex int
// The channel to join immediately after registration completes.
InitialChannel string
// Reference to the bubbletea program // Reference to the bubbletea program
Tea *tea.Program Tea *tea.Program
@@ -85,24 +98,46 @@ type Client struct {
// The acknowledged capabilities // The acknowledged capabilities
EnabledCapabilities Capabilities EnabledCapabilities Capabilities
// The features currently enabled for this client
EnabledFeatures Features
} }
type Capabilities map[string]string type Capabilities map[string]string
type Features map[string]string
type MsgFmtOpts struct { type MsgFmtOpts struct {
WithTimestamp bool WithTimestamp bool
// TODO: make these a bitset? they should compliment eachother instead of conflicting or rendering twice.
AsServerMsg bool AsServerMsg bool
AsErrorMsg bool
NotImpl bool NotImpl bool
AsDate bool AsDate bool
} }
var notImpl = lipgloss.NewStyle().Foreground(ui.ErrorColor).Render("[NOT IMPL]") var unimpl = lipgloss.NewStyle().Foreground(ui.ErrorColor).Render("[UNIMPL]")
var serverMsgStyle = lipgloss.NewStyle().Foreground(ui.ServerMsgColor) var serverMsgStyle = lipgloss.NewStyle().Foreground(ui.ServerMsgColor)
var errorMsgStyle = lipgloss.NewStyle().Foreground(ui.ErrorColor)
var timestampStyle = serverMsgStyle var timestampStyle = serverMsgStyle
var dateStyle = lipgloss.NewStyle().Foreground(ui.DateColor) var dateStyle = lipgloss.NewStyle().Foreground(ui.DateColor)
const CRLF = "\r\n" const CRLF = "\r\n"
func (m *Message) SetTimestamp() {
if serverTime, ok := m.Tags["time"]; ok {
t, err := time.Parse("2006-01-02T15:04:05.000Z", serverTime)
if err != nil {
// TODO: properly handle error
log.Fatalln(err)
}
m.Timestamp = fmt.Sprintf("[%02d:%02d]", t.Local().Hour(), t.Local().Minute())
} else {
now := time.Now()
m.Timestamp = fmt.Sprintf("[%02d:%02d]", now.Hour(), now.Minute())
}
}
func (c *Channel) AppendMsg(timestamp string, fullMsg string, opts MsgFmtOpts) { func (c *Channel) AppendMsg(timestamp string, fullMsg string, opts MsgFmtOpts) {
prefixes := "" prefixes := ""
style := ui.DefaultStyle style := ui.DefaultStyle
@@ -112,7 +147,7 @@ func (c *Channel) AppendMsg(timestamp string, fullMsg string, opts MsgFmtOpts) {
} }
if opts.NotImpl { if opts.NotImpl {
prefixes += notImpl + " " prefixes += unimpl + " "
} }
if opts.AsServerMsg { if opts.AsServerMsg {
@@ -120,6 +155,11 @@ func (c *Channel) AppendMsg(timestamp string, fullMsg string, opts MsgFmtOpts) {
style = serverMsgStyle style = serverMsgStyle
} }
if opts.AsErrorMsg {
prefixes += errorMsgStyle.Render("==") + " "
style = errorMsgStyle
}
if opts.AsDate { if opts.AsDate {
style = dateStyle style = dateStyle
} }
@@ -130,6 +170,13 @@ func (c *Channel) AppendMsg(timestamp string, fullMsg string, opts MsgFmtOpts) {
func (c *Client) Initialize(host string, port string, tlsEnabled bool) { func (c *Client) Initialize(host string, port string, tlsEnabled bool) {
addr := fmt.Sprintf("%s:%s", host, port) addr := fmt.Sprintf("%s:%s", host, port)
c.Host = host
c.Port = port
c.ActiveChannel = host
c.Channels = make([]Channel, 0)
c.EnabledCapabilities = make(Capabilities, 0)
c.EnabledFeatures = make(Features, 0)
if tlsEnabled { if tlsEnabled {
cfg := &tls.Config{ServerName: host} cfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, cfg) conn, err := tls.Dial("tcp", addr, cfg)
@@ -139,11 +186,6 @@ func (c *Client) Initialize(host string, port string, tlsEnabled bool) {
} }
c.TCPConn = conn c.TCPConn = conn
c.Host = host
c.Port = port
c.ActiveChannel = host
c.Channels = make([]Channel, 0)
c.EnabledCapabilities = make(Capabilities, 0)
return return
} }
@@ -158,11 +200,6 @@ func (c *Client) Initialize(host string, port string, tlsEnabled bool) {
} }
c.TCPConn = conn c.TCPConn = conn
c.Host = host
c.Port = port
c.ActiveChannel = host
c.Channels = make([]Channel, 0)
c.EnabledCapabilities = make(Capabilities, 0)
} }
func (c *Client) Register(nick string, password string, channel string) { func (c *Client) Register(nick string, password string, channel string) {
@@ -179,10 +216,6 @@ func (c *Client) Register(nick string, password string, channel string) {
// set user-wanted nickname // set user-wanted nickname
c.Nickname = nick c.Nickname = nick
c.SendCommand(commands.USER, nick, "0", "*", nick) c.SendCommand(commands.USER, nick, "0", "*", nick)
// joining a channel when registering is optional
if channel != "" {
c.SendCommand(commands.JOIN, channel)
}
} }
func (c *Client) SetDay() { func (c *Client) SetDay() {
+72 -69
View File
@@ -58,7 +58,7 @@ const (
NOTICE = "NOTICE" // Send notices between users and to channels. NOTICE = "NOTICE" // Send notices between users and to channels.
// Operator Messages // Operator Messages
KILL = "KILL" // CLose connection between a client and the server. IRC Operators only (do we need this ??) KILL = "KILL" // Close connection between a client and the server. IRC Operators only (do we need this ??)
// Optional Messages // Optional Messages
AWAY = "AWAY" // Indicate that the client (user) is away/afk/etc.. AWAY = "AWAY" // Indicate that the client (user) is away/afk/etc..
@@ -167,12 +167,12 @@ const (
RPL_GLOBALUSERS = "266" // aircd, Hybird, Bahamut - Implemented RPL_GLOBALUSERS = "266" // aircd, Hybird, Bahamut - Implemented
RPL_WHOISCERTFP = "276" // oftc-hybrid - Not Implemented - Has Conflicts (TODO:) RPL_WHOISCERTFP = "276" // oftc-hybrid - Not Implemented - Has Conflicts (TODO:)
RPL_NONE = "300" // RFC1459 - Not Implemented (TODO:) RPL_NONE = "300" // RFC1459 - Not Implemented (TODO:)
RPL_AWAY = "301" // RFC1459 - Not Implemented (TODO:) RPL_AWAY = "301" // RFC1459 - Implemented
RPL_USERHOST = "302" // RFC1459 - Not Implemented (TODO:) RPL_USERHOST = "302" // RFC1459 - Not Implemented (TODO:)
RPL_ISON = "303" // RFC1459 - Not Implemented (TODO:) RPL_ISON = "303" // RFC1459 - Not Implemented (TODO:)
RPL_TEXT = "304" // irc2 - Not Implemented (TODO:) RPL_TEXT = "304" // irc2 - Not Implemented (TODO:)
RPL_UNAWAY = "305" // RFC1459 - Not Implemented (TODO:) RPL_UNAWAY = "305" // RFC1459 - Implemented
RPL_NOWAWAY = "306" // RFC1459 - Not Implemented (TODO:) RPL_NOWAWAY = "306" // RFC1459 - Implemented
RPL_WHOISUSER = "311" // RFC1459 - Implemented RPL_WHOISUSER = "311" // RFC1459 - Implemented
RPL_WHOISSERVER = "312" // RFC1459 - Implemented RPL_WHOISSERVER = "312" // RFC1459 - Implemented
RPL_WHOISOPERATOR = "313" // RFC1459 - Not Implemented (TODO:) RPL_WHOISOPERATOR = "313" // RFC1459 - Not Implemented (TODO:)
@@ -187,8 +187,8 @@ const (
RPL_CHANNELMODEIS = "324" // RFC1459 - Not Implemented (TODO:) RPL_CHANNELMODEIS = "324" // RFC1459 - Not Implemented (TODO:)
RPL_UNIQOPIS = "325" // RFC2812 - Not Implemented - Has Conflicts (TODO:) RPL_UNIQOPIS = "325" // RFC2812 - Not Implemented - Has Conflicts (TODO:)
RPL_CREATIONTIME = "329" // Bahamut,InspIRCd - Not Implemented (TODO:) RPL_CREATIONTIME = "329" // Bahamut,InspIRCd - Not Implemented (TODO:)
RPL_NOTOPIC = "331" // RFC1459 - Not Implemented (TODO:) RPL_NOTOPIC = "331" // RFC1459 - Implemented
RPL_TOPIC = "332" // RFC1459 - Not Implemented (TODO:) RPL_TOPIC = "332" // RFC1459 - Implemented
RPL_TOPICWHOTIME = "333" // ircu,InspIRCd - Not Implemented (TODO:) RPL_TOPICWHOTIME = "333" // ircu,InspIRCd - Not Implemented (TODO:)
RPL_INVITING = "341" // RFC1459 - Not Implemented (TODO:) RPL_INVITING = "341" // RFC1459 - Not Implemented (TODO:)
RPL_SUMMONING = "342" // RFC1459 - Not Implemented - Deprecated (TODO:) RPL_SUMMONING = "342" // RFC1459 - Not Implemented - Deprecated (TODO:)
@@ -197,7 +197,7 @@ const (
RPL_EXCEPTLIST = "348" // RFC2812 - Not Implemented (TODO:) RPL_EXCEPTLIST = "348" // RFC2812 - Not Implemented (TODO:)
RPL_ENDOFEXCEPTLIST = "349" // RFC2812 - Not Implemented (TODO:) RPL_ENDOFEXCEPTLIST = "349" // RFC2812 - Not Implemented (TODO:)
RPL_WHOISGATEWAY = "350" // InspIRCd - Not Implemented (TODO:) RPL_WHOISGATEWAY = "350" // InspIRCd - Not Implemented (TODO:)
RPL_VERSION = "351" // RFC1459 - Not Implemented (TODO:) RPL_VERSION = "351" // RFC1459 - Implemented
RPL_WHOREPLY = "352" // RFC1459 - Not Implemented (TODO:) RPL_WHOREPLY = "352" // RFC1459 - Not Implemented (TODO:)
RPL_NAMREPLY = "353" // RFC1459 - Implemented RPL_NAMREPLY = "353" // RFC1459 - Implemented
RPL_KILLDONE = "361" // RFC1459 - Not Implemented - Deprecated (TODO:) RPL_KILLDONE = "361" // RFC1459 - Not Implemented - Deprecated (TODO:)
@@ -209,10 +209,10 @@ const (
RPL_BANLIST = "367" // RFC1459 - Not Implemented (TODO:) RPL_BANLIST = "367" // RFC1459 - Not Implemented (TODO:)
RPL_ENDOFBANLIST = "368" // RFC1459 - Not Implemented (TODO:) RPL_ENDOFBANLIST = "368" // RFC1459 - Not Implemented (TODO:)
RPL_ENDOFWHOWAS = "369" // RFC1459 - Not Implemented (TODO:) RPL_ENDOFWHOWAS = "369" // RFC1459 - Not Implemented (TODO:)
RPL_INFO = "371" // RFC1459 - Not Implemented (TODO:) RPL_INFO = "371" // RFC1459 - Implemented
RPL_MOTD = "372" // RFC1459 - Implemented RPL_MOTD = "372" // RFC1459 - Implemented
RPL_INFOSTART = "373" // RFC1459 - Not Implemented - Deprecated (TODO:) RPL_INFOSTART = "373" // RFC1459 - Not Implemented - Deprecated (TODO:)
RPL_ENDOFINFO = "374" // RFC1459 - Not Implemented (TODO:) RPL_ENDOFINFO = "374" // RFC1459 - Implemented
RPL_MOTDSTART = "375" // RFC1459 - Implemented RPL_MOTDSTART = "375" // RFC1459 - Implemented
RPL_ENDOFMOTD = "376" // RFC1459 - Not Implemented (TODO:) RPL_ENDOFMOTD = "376" // RFC1459 - Not Implemented (TODO:)
RPL_WHOISHOST = "378" // ??????? - Implemented - Has Conflicts RPL_WHOISHOST = "378" // ??????? - Implemented - Has Conflicts
@@ -228,11 +228,11 @@ const (
RPL_NOUSERS = "395" // RFC1459 - Not Implemented (TODO:) RPL_NOUSERS = "395" // RFC1459 - Not Implemented (TODO:)
ERR_UNKNOWNERROR = "400" // ??????? - Not Implemented (TODO:) ERR_UNKNOWNERROR = "400" // ??????? - Not Implemented (TODO:)
ERR_NOSUCHNICK = "401" // RFC1459 - Not Implemented (TODO:) ERR_NOSUCHNICK = "401" // RFC1459 - Implemented
ERR_NOSUCHSERVER = "402" // RFC1459 - Implemented ERR_NOSUCHSERVER = "402" // RFC1459 - Implemented
ERR_NOSUCHCHANNEL = "403" // RFC1459 - Not Implemented (TODO:) ERR_NOSUCHCHANNEL = "403" // RFC1459 - Implemented
ERR_CANNOTSENDTOCHAN = "404" // RFC1459 - Not Implemented (TODO:) ERR_CANNOTSENDTOCHAN = "404" // RFC1459 - Implemented
ERR_TOOMANYCHANNELS = "405" // RFC1459 - Not Implemented (TODO:) ERR_TOOMANYCHANNELS = "405" // RFC1459 - Implemented
ERR_WASNOSUCHNICK = "406" // RFC1459 - Not Implemented (TODO:) ERR_WASNOSUCHNICK = "406" // RFC1459 - Not Implemented (TODO:)
ERR_NOSUCHSERVICE = "408" // RFC2812 - Not Implemented - Has Conflicts (TODO:) ERR_NOSUCHSERVICE = "408" // RFC2812 - Not Implemented - Has Conflicts (TODO:)
ERR_NOORIGIN = "409" // RFC1459 - Not Implemented (TODO:) ERR_NOORIGIN = "409" // RFC1459 - Not Implemented (TODO:)
@@ -242,7 +242,7 @@ const (
ERR_WILDTOPLEVEL = "414" // RFC1459 - Not Implemented (TODO:) ERR_WILDTOPLEVEL = "414" // RFC1459 - Not Implemented (TODO:)
ERR_BADMASK = "415" // RFC2812 - Not Implemented (TODO:) ERR_BADMASK = "415" // RFC2812 - Not Implemented (TODO:)
ERR_UNKNOWNCOMMAND = "421" // RFC1459 - Implemented ERR_UNKNOWNCOMMAND = "421" // RFC1459 - Implemented
ERR_NOMOTD = "422" // RFC1459 - Not Implemented (TODO:) ERR_NOMOTD = "422" // RFC1459 - Implemented
ERR_NOADMININFO = "423" // RFC1459 - Not Implemented (TODO:) ERR_NOADMININFO = "423" // RFC1459 - Not Implemented (TODO:)
ERR_FILEERROR = "424" // RFC1459 - Not Implemented (TODO:) ERR_FILEERROR = "424" // RFC1459 - Not Implemented (TODO:)
ERR_NONICKNAMEGIVEN = "431" // RFC1459 - Implemented ERR_NONICKNAMEGIVEN = "431" // RFC1459 - Implemented
@@ -269,10 +269,10 @@ const (
ERR_INVITEONLYCHAN = "473" // RFC1459 - Not Implemented (TODO:) ERR_INVITEONLYCHAN = "473" // RFC1459 - Not Implemented (TODO:)
ERR_BANNEDFROMCHAN = "474" // RFC1459 - Not Implemented (TODO:) ERR_BANNEDFROMCHAN = "474" // RFC1459 - Not Implemented (TODO:)
ERR_BADCHANNELKEY = "475" // RFC1459 - Not Implemented (TODO:) ERR_BADCHANNELKEY = "475" // RFC1459 - Not Implemented (TODO:)
ERR_BADCHANMASK = "476" // RFC2812 - Not Implemented (TODO:) ERR_BADCHANMASK = "476" // RFC2812 - Implemented
ERR_BANLISTFULL = "478" // RFC2812 - Not Implemented (TODO:) ERR_BANLISTFULL = "478" // RFC2812 - Not Implemented (TODO:)
ERR_NOPRIVILEGES = "481" // RFC1459 - Not Implemented (TODO:) ERR_NOPRIVILEGES = "481" // RFC1459 - Not Implemented (TODO:)
ERR_CHANOPRIVSNEEDED = "482" // RFC1459 - Not Implemented (TODO:) ERR_CHANOPRIVSNEEDED = "482" // RFC1459 - Implemented
ERR_CANTKILLSERVER = "483" // RFC1459 - Not Implemented (TODO:) ERR_CANTKILLSERVER = "483" // RFC1459 - Not Implemented (TODO:)
ERR_RESTRICTED = "484" // RFC2812 - Not Implemented - Has Conflicts (TODO:) ERR_RESTRICTED = "484" // RFC2812 - Not Implemented - Has Conflicts (TODO:)
ERR_UNIQOPRIVSNEEDED = "485" // RFC2812 - Not Implemented (TODO:) ERR_UNIQOPRIVSNEEDED = "485" // RFC2812 - Not Implemented (TODO:)
@@ -305,64 +305,67 @@ const (
/* --- RPL_ISUPPORT Parameters --- */ /* --- RPL_ISUPPORT Parameters --- */
var Features = map[string]bool{ var Features = map[string]bool{
"ACCEPT": true, "ACCEPT": false,
// The AWAYLEN parameter indicates the maximum length for the <reason> of an AWAY command.
// If an AWAY <reason> has more characters than this parameter, it may be silently truncated by the server before being passed on to other clients.
// Clients MAY receive an AWAY <reason> that has more characters than this parameter
"AWAYLEN": true, "AWAYLEN": true,
"BOT": true, "BOT": false,
"CALLERID": true, "CALLERID": false,
"CASEMAPPING": true, "CASEMAPPING": false,
"CHANLIMIT": true, "CHANLIMIT": false,
"CHANMODES": true, "CHANMODES": false,
"CHANNELLEN": true, "CHANNELLEN": false,
"CHANTYPES": true, // Channel Types. Default is #. Available are #& "CHANTYPES": false, // Channel Types. Default is #. Available are #&
"CHARSET": true, // Deprecated but might still be used "CHARSET": false, // Deprecated but might still be used
"CLIENTVER": true, // Deprecated but might still be used "CLIENTVER": false, // Deprecated but might still be used
"CNOTICE": true, "CNOTICE": false,
"CPRIVMSG": true, "CPRIVMSG": false,
"DEAF": true, "DEAF": false,
"ELIST": true, "ELIST": false,
"ESILENCE": true, "ESILENCE": false,
"ETRACE": true, "ETRACE": false,
"EXCEPTS": true, "EXCEPTS": false,
"EXTBAN": true, "EXTBAN": false,
"FNC": true, // Deprecated but might still be used "FNC": false, // Deprecated but might still be used
"INVEX": true, "INVEX": false,
"KEYLEN": true, "KEYLEN": false,
"KICKLEN": true, "KICKLEN": true,
"KNOCK": true, "KNOCK": false,
"LINELEN": true, // Proposed "LINELEN": false, // Proposed
"MAP": true, // Deprecated but might still be used "MAP": false, // Deprecated but might still be used
"MAXBANS": true, // Deprecated but might still be used "MAXBANS": false, // Deprecated but might still be used
"MAXCHANNELS": true, // Deprecated but might still be used "MAXCHANNELS": false, // Deprecated but might still be used
"MAXLIST": true, "MAXLIST": false,
"MAXNICKLEN": true, "MAXNICKLEN": false,
"MAXPARA": true, // Deprecated but might still be used "MAXPARA": false, // Deprecated but might still be used
"MAXTARGETS": true, "MAXTARGETS": false,
"METADATA": true, "METADATA": false,
"MODES": true, "MODES": false,
"MONITOR": true, "MONITOR": false,
"NAMESX": true, // Deprecated but might still be used "NAMESX": false, // Deprecated but might still be used
"NETWORK": true, "NETWORK": true,
"NICKLEN": true, "NICKLEN": true,
"OVERRIDE": true, "OVERRIDE": false,
"PREFIX": true, "PREFIX": false,
"SAFELIST": true, "SAFELIST": false,
"SECURELIST": true, "SECURELIST": false,
"SILENCE": true, "SILENCE": false,
"SSL": true, // Deprecated but might still be used "SSL": false, // Deprecated but might still be used
"STARTTLS": true, // Deprecated but might still be used "STARTTLS": false, // Deprecated but might still be used
"STATUSMSG": true, "STATUSMSG": false,
"STD": true, // Deprecated but might still be used "STD": false, // Deprecated but might still be used
"TARGMAX": true, "TARGMAX": false,
"TOPICLEN": true, "TOPICLEN": true,
"UHNAMES": true, // Deprecated but might still be used "UHNAMES": false, // Deprecated but might still be used
"USERIP": true, "USERIP": false,
"USERLEN": true, // Proposed "USERLEN": false, // Proposed
"VBANLIST": true, // Deprecared but might still be used "VBANLIST": false, // Deprecared but might still be used
"VLIST": true, "VLIST": false,
"WALLCHOPS": true, // Deprecated but might still be used "WALLCHOPS": false, // Deprecated but might still be used
"WALLVOICES": true, // Deprecated but might still be used "WALLVOICES": false, // Deprecated but might still be used
"WATCH": true, "WATCH": false,
"WHOX": true, "WHOX": false,
} }
var Capabilities = map[string]bool{ var Capabilities = map[string]bool{
+496 -53
View File
@@ -35,6 +35,7 @@ import (
func ReadLoop(client *irc.Client) { func ReadLoop(client *irc.Client) {
// 512 bytes as a base + 8192 additional bytes for tags // 512 bytes as a base + 8192 additional bytes for tags
// as specified here: https://modern.ircdocs.horse/#message-format
r := bufio.NewReaderSize(client.TCPConn, 8192+512) r := bufio.NewReaderSize(client.TCPConn, 8192+512)
for { for {
@@ -54,29 +55,68 @@ func ReadLoop(client *irc.Client) {
} }
} }
func handlePing(msg parser.IRCMessage, client *irc.Client) { func handlePing(msg irc.Message, client *irc.Client) {
token := msg.Parameters[0] token := msg.Parameters[0]
client.SendCommand(commands.PONG, token) client.SendCommand(commands.PONG, token)
} }
func handlePrivMsg(msg parser.IRCMessage, client *irc.Client) { func handlePrivMsg(msg irc.Message, client *irc.Client) {
nick := strings.SplitN(msg.Source, "!", 2)[0] source := strings.ToLower(strings.SplitN(msg.Source, "!", 2)[0])
channel := msg.Parameters[0] targets := strings.Split(msg.Parameters[0], ",")
msgContent := msg.Parameters[1] msgContent := msg.Parameters[1]
privMsg := fmt.Sprintf("%s: %s", nick, msgContent) privMsg := fmt.Sprintf("%s: %s", source, msgContent)
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
WithTimestamp: true, WithTimestamp: true,
} }
targetsLoop:
for _, target := range targets {
for i, c := range client.Channels { for i, c := range client.Channels {
if c.Name == channel { if c.Name == target || c.Name == source {
client.Channels[i].AppendMsg(msg.Timestamp, privMsg, msgOpts) client.Channels[i].AppendMsg(msg.Timestamp, privMsg, msgOpts)
continue targetsLoop
}
}
if target == client.Nickname {
newChannel := irc.Channel{
Name: source,
Users: map[string]irc.User{source: {}},
}
newChannel.AppendMsg(msg.Timestamp, privMsg, msgOpts)
client.Channels = append(client.Channels, newChannel)
} }
} }
} }
func handleJoin(msg parser.IRCMessage, client *irc.Client) { func handleNotice(msg irc.Message, client *irc.Client) {
source := strings.SplitN(msg.Source, "!", 2)[0]
targets := strings.Split(msg.Parameters[0], ",")
msgContent := msg.Parameters[1]
notice := fmt.Sprintf("%s: %s", source, msgContent)
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
}
for _, target := range targets {
if target == "*" || target == client.Nickname {
client.Channels[0].AppendMsg(msg.Timestamp, notice, msgOpts)
continue
}
for i, c := range client.Channels {
if c.Name == targets[0] {
client.Channels[i].AppendMsg(msg.Timestamp, notice, msgOpts)
}
}
}
}
func handleJoin(msg irc.Message, client *irc.Client) {
nick := strings.SplitN(msg.Source, "!", 2)[0] nick := strings.SplitN(msg.Source, "!", 2)[0]
channel := msg.Parameters[0] channel := msg.Parameters[0]
@@ -115,7 +155,100 @@ func handleJoin(msg parser.IRCMessage, client *irc.Client) {
} }
} }
func handleQuit(msg parser.IRCMessage, client *irc.Client) { // Sent from server to client to acknowledge a nickname change, or to inform of a nickname change of another user.
func handleNick(msg irc.Message, client *irc.Client) {
oldNick := strings.SplitN(msg.Source, "!", 2)[0]
newNick := msg.Parameters[0]
// TODO: the actual case-sensitivity is determined by the CASEMAPPING feature
isMe := strings.EqualFold(oldNick, client.Nickname)
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
message := ""
if isMe {
message = fmt.Sprintf("Your nickname was changed to %v", newNick)
} else {
message = fmt.Sprintf("%v changed their nickname to %v", oldNick, newNick)
}
for i, channel := range client.Channels {
// Rename this user in every channel we can find
if user, ok := channel.Users[oldNick]; ok {
delete(client.Channels[i].Users, oldNick)
client.Channels[i].Users[newNick] = user
client.Channels[i].AppendMsg(msg.Timestamp, message, msgOpts)
}
// If we have a private channel open with this user, rename it as well.
if strings.EqualFold(channel.Name, oldNick) {
client.Channels[i].Name = newNick
if client.ActiveChannelIndex == i {
client.ActiveChannel = newNick
}
}
}
if isMe {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
client.Nickname = newNick
}
client.Tea.Send(cmds.UpdateNicks())
}
func handleKick(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[0]
nicks := strings.Split(msg.Parameters[1], ",")
// Reason is optional
reason := ""
if len(msg.Parameters) > 2 {
reason = msg.Parameters[2]
}
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
for _, nick := range nicks {
kicker := strings.SplitN(msg.Source, "!", 2)[0]
message := fmt.Sprintf("%v kicked %v from %v (%v)", kicker, nick, channel, reason)
for i, c := range client.Channels {
if c.Name == channel {
if nick == client.Nickname {
client.Channels = slices.Delete(client.Channels, i, i+1)
if client.ActiveChannelIndex >= i {
client.ActiveChannelIndex--
client.ActiveChannel = client.Channels[client.ActiveChannelIndex].Name
}
client.LastTabIndexInTabBar--
message = fmt.Sprintf("You were kicked by %v from %v (%v)", kicker, channel, reason)
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
return
}
delete(client.Channels[i].Users, nick)
client.Channels[i].AppendMsg(msg.Timestamp, message, msgOpts)
}
}
}
client.Tea.Send(cmds.SwitchChannels())
}
func handleQuit(msg irc.Message, client *irc.Client) {
nick := strings.SplitN(msg.Source, "!", 2)[0] nick := strings.SplitN(msg.Source, "!", 2)[0]
reason := msg.Parameters[0] reason := msg.Parameters[0]
quitMsg := fmt.Sprintf("%s has quit (%s)", nick, reason) quitMsg := fmt.Sprintf("%s has quit (%s)", nick, reason)
@@ -137,7 +270,7 @@ func handleQuit(msg parser.IRCMessage, client *irc.Client) {
client.Tea.Send(cmds.SwitchChannels()) client.Tea.Send(cmds.SwitchChannels())
} }
func handlePart(msg parser.IRCMessage, client *irc.Client) { func handlePart(msg irc.Message, client *irc.Client) {
nick := strings.SplitN(msg.Source, "!", 2)[0] nick := strings.SplitN(msg.Source, "!", 2)[0]
channel := msg.Parameters[0] channel := msg.Parameters[0]
reason := "" reason := ""
@@ -171,7 +304,23 @@ func handlePart(msg parser.IRCMessage, client *irc.Client) {
client.Tea.Send(cmds.SwitchChannels()) client.Tea.Send(cmds.SwitchChannels())
} }
func handleCAP(msg parser.IRCMessage, client *irc.Client) { func handleTopic(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[0]
topic := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{
AsServerMsg: true,
}
for i, c := range client.Channels {
if c.Name == channel {
client.Channels[i].Topic = topic
client.Channels[i].AppendMsg(msg.Timestamp, fmt.Sprintf("Topic changed: %v", topic), msgOpts)
}
}
}
func handleCAP(msg irc.Message, client *irc.Client) {
switch msg.Parameters[1] { switch msg.Parameters[1] {
case "LIST": case "LIST":
fallthrough fallthrough
@@ -192,7 +341,8 @@ func handleCAP(msg parser.IRCMessage, client *irc.Client) {
value = parts[1] value = parts[1]
} }
if commands.Capabilities[key] { if capEnabled, ok := commands.Capabilities[key]; ok {
if capEnabled {
// When the final parameter approaches 510 bytes, // When the final parameter approaches 510 bytes,
// we send multiple REQ commands // we send multiple REQ commands
// (https://ircv3.net/specs/extensions/capability-negotiation.html#the-cap-req-subcommand) // (https://ircv3.net/specs/extensions/capability-negotiation.html#the-cap-req-subcommand)
@@ -204,6 +354,9 @@ func handleCAP(msg parser.IRCMessage, client *irc.Client) {
capabilities[key] = value capabilities[key] = value
length += len(capability) length += len(capability)
} else {
log.Println("Unsupported capability:", capability)
}
} else { } else {
log.Println("Unknown capability:", capability) log.Println("Unknown capability:", capability)
} }
@@ -223,6 +376,9 @@ func handleCAP(msg parser.IRCMessage, client *irc.Client) {
} }
client.SendCommand(commands.CAP, "END") client.SendCommand(commands.CAP, "END")
case "ACK": case "ACK":
if msg.Parameters[2] == "" {
return
}
caps := strings.Split(msg.Parameters[2], " ") caps := strings.Split(msg.Parameters[2], " ")
for _, capability := range caps { for _, capability := range caps {
if capability[0] == '-' { if capability[0] == '-' {
@@ -252,7 +408,7 @@ func handleCAP(msg parser.IRCMessage, client *irc.Client) {
} }
} }
func handleWELCOME(msg parser.IRCMessage, client *irc.Client) { func handleWELCOME(msg irc.Message, client *irc.Client) {
nick := msg.Parameters[0] nick := msg.Parameters[0]
welcomeMsg := msg.Parameters[1] welcomeMsg := msg.Parameters[1]
@@ -261,12 +417,18 @@ func handleWELCOME(msg parser.IRCMessage, client *irc.Client) {
AsServerMsg: true, AsServerMsg: true,
} }
// set server-registered nickname // set server-registered nickname because the server MAY return a different nickname than
// the one the user chose because of length restrictions or otherwise.
client.Nickname = nick client.Nickname = nick
client.Channels[0].AppendMsg(msg.Timestamp, welcomeMsg, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, welcomeMsg, msgOpts)
// Only join the user-requested channel AFTER registration is complete.
if client.InitialChannel != "" {
client.SendCommand(commands.JOIN, client.InitialChannel)
}
} }
func handleYOURHOST(msg parser.IRCMessage, client *irc.Client) { func handleYOURHOST(msg irc.Message, client *irc.Client) {
host := msg.Parameters[1] host := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -277,7 +439,7 @@ func handleYOURHOST(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, host, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, host, msgOpts)
} }
func handleCREATED(msg parser.IRCMessage, client *irc.Client) { func handleCREATED(msg irc.Message, client *irc.Client) {
created := msg.Parameters[1] created := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -288,7 +450,7 @@ func handleCREATED(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, created, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, created, msgOpts)
} }
func handleMYINFO(msg parser.IRCMessage, client *irc.Client) { func handleMYINFO(msg irc.Message, client *irc.Client) {
info := strings.Join(msg.Parameters[1:], " ") info := strings.Join(msg.Parameters[1:], " ")
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -299,7 +461,35 @@ func handleMYINFO(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, info, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, info, msgOpts)
} }
func handleLUSERCLIENT(msg parser.IRCMessage, client *irc.Client) { func handleISUPPORT(msg irc.Message, client *irc.Client) {
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
for _, token := range msg.Parameters[1 : len(msg.Parameters)-1] {
splits := strings.Split(token, "=")
key := splits[0]
value := ""
if len(splits) > 1 {
value = splits[1]
}
if supported, known := commands.Features[key]; known {
if supported {
client.EnabledFeatures[key] = value
} else {
log.Printf("Unsupported feature: \"%v\" with value: \"%v\"\n", key, value)
}
} else {
log.Printf("Unknown feature: \"%v\" with value: \"%v\"\n", key, value)
}
client.Channels[0].AppendMsg(msg.Timestamp, token, msgOpts)
}
}
func handleLUSERCLIENT(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -310,7 +500,7 @@ func handleLUSERCLIENT(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleLUSEROP(msg parser.IRCMessage, client *irc.Client) { func handleLUSEROP(msg irc.Message, client *irc.Client) {
message := strings.Join(msg.Parameters[1:], " ") message := strings.Join(msg.Parameters[1:], " ")
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -321,7 +511,7 @@ func handleLUSEROP(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleLUSERUNKNOWN(msg parser.IRCMessage, client *irc.Client) { func handleLUSERUNKNOWN(msg irc.Message, client *irc.Client) {
message := strings.Join(msg.Parameters[1:], " ") message := strings.Join(msg.Parameters[1:], " ")
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -332,7 +522,7 @@ func handleLUSERUNKNOWN(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleLUSERCHANNELS(msg parser.IRCMessage, client *irc.Client) { func handleLUSERCHANNELS(msg irc.Message, client *irc.Client) {
message := strings.Join(msg.Parameters[1:], " ") message := strings.Join(msg.Parameters[1:], " ")
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -343,7 +533,7 @@ func handleLUSERCHANNELS(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleLUSERME(msg parser.IRCMessage, client *irc.Client) { func handleLUSERME(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -354,7 +544,54 @@ func handleLUSERME(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleLOCALUSERS(msg parser.IRCMessage, client *irc.Client) { func handleLOCALUSERS(msg irc.Message, client *irc.Client) {
message := ""
if len(msg.Parameters) > 2 {
message = msg.Parameters[3]
} else {
message = msg.Parameters[1]
}
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleGLOBALUSERS(msg irc.Message, client *irc.Client) {
message := ""
if len(msg.Parameters) > 2 {
message = msg.Parameters[3]
} else {
message = msg.Parameters[1]
}
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleAWAY(msg irc.Message, client *irc.Client) {
nick := strings.SplitN(msg.Parameters[1], "!", 2)[0]
reason := msg.Parameters[2]
awayMsg := fmt.Sprintf("%s is away (%s)", nick, reason)
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, awayMsg, msgOpts)
}
func handleUNAWAY(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -365,7 +602,7 @@ func handleLOCALUSERS(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleGLOBALUSERS(msg parser.IRCMessage, client *irc.Client) { func handleNOWAWAY(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -376,7 +613,7 @@ func handleGLOBALUSERS(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleWHOISUSER(msg parser.IRCMessage, client *irc.Client) { func handleWHOISUSER(msg irc.Message, client *irc.Client) {
nick := msg.Parameters[1] nick := msg.Parameters[1]
user := msg.Parameters[2] user := msg.Parameters[2]
host := msg.Parameters[3] host := msg.Parameters[3]
@@ -399,7 +636,7 @@ func handleWHOISUSER(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleWHOISSERVER(msg parser.IRCMessage, client *irc.Client) { func handleWHOISSERVER(msg irc.Message, client *irc.Client) {
server := msg.Parameters[2] server := msg.Parameters[2]
serverInfo := msg.Parameters[3] serverInfo := msg.Parameters[3]
@@ -417,7 +654,7 @@ func handleWHOISSERVER(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleWHOISIDLE(msg parser.IRCMessage, client *irc.Client) { func handleWHOISIDLE(msg irc.Message, client *irc.Client) {
idleSeconds := msg.Parameters[2] idleSeconds := msg.Parameters[2]
connectedTimestamp, err := strconv.ParseInt(msg.Parameters[3], 10, 64) connectedTimestamp, err := strconv.ParseInt(msg.Parameters[3], 10, 64)
@@ -441,7 +678,7 @@ func handleWHOISIDLE(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleENDOFWHOIS(msg parser.IRCMessage, client *irc.Client) { func handleENDOFWHOIS(msg irc.Message, client *irc.Client) {
message := msg.Parameters[2] message := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -452,7 +689,7 @@ func handleENDOFWHOIS(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleWHOISCHANNELS(msg parser.IRCMessage, client *irc.Client) { func handleWHOISCHANNELS(msg irc.Message, client *irc.Client) {
chans := msg.Parameters[2] chans := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -465,7 +702,56 @@ func handleWHOISCHANNELS(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleNAMREPLY(msg parser.IRCMessage, client *irc.Client) { func handleNOTOPIC(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
msgStr := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
AsServerMsg: true,
}
for i, c := range client.Channels {
if c.Name == channel {
client.Channels[i].AppendMsg(msg.Timestamp, msgStr, msgOpts)
}
}
}
func handleTOPIC(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
topic := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
AsServerMsg: true,
}
for i, c := range client.Channels {
if c.Name == channel {
client.Channels[i].Topic = topic
client.Channels[i].AppendMsg(msg.Timestamp, fmt.Sprintf("TOPIC: %v", topic), msgOpts)
}
}
}
func handleVERSION(msg irc.Message, client *irc.Client) {
version := msg.Parameters[1]
server := msg.Parameters[2]
versionMsg := fmt.Sprintf("Server: %v | Version: %v", server, version)
if len(msg.Parameters) > 3 && msg.Parameters[3] != "" {
comments := msg.Parameters[3]
versionMsg = fmt.Sprintf("Server: %v | Version: %v | (%s)", server, version, comments)
}
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, versionMsg, msgOpts)
}
func handleNAMREPLY(msg irc.Message, client *irc.Client) {
// TODO: do i need these // TODO: do i need these
// client := msg.Parameters[0] // client := msg.Parameters[0]
// chanSymbol := msg.Parameters[1] // chanSymbol := msg.Parameters[1]
@@ -495,7 +781,29 @@ func handleNAMREPLY(msg parser.IRCMessage, client *irc.Client) {
} }
} }
func handleMOTDStart(msg parser.IRCMessage, client *irc.Client) { func handleINFO(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleENDOFINFO(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleMOTDStart(msg irc.Message, client *irc.Client) {
message := strings.Join(msg.Parameters[1:], " ") message := strings.Join(msg.Parameters[1:], " ")
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -506,7 +814,7 @@ func handleMOTDStart(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleMOTD(msg parser.IRCMessage, client *irc.Client) { func handleMOTD(msg irc.Message, client *irc.Client) {
messageLine := msg.Parameters[1] messageLine := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -517,7 +825,7 @@ func handleMOTD(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, messageLine, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, messageLine, msgOpts)
} }
func handleWHOISHOST(msg parser.IRCMessage, client *irc.Client) { func handleWHOISHOST(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + " " + msg.Parameters[2] message := msg.Parameters[1] + " " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -528,7 +836,7 @@ func handleWHOISHOST(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleWHOISMODES(msg parser.IRCMessage, client *irc.Client) { func handleWHOISMODES(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + " " + msg.Parameters[2] message := msg.Parameters[1] + " " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -539,29 +847,85 @@ func handleWHOISMODES(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleNOSUCHSERVER(msg parser.IRCMessage, client *irc.Client) { func handleNOSUCHNICK(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + " " + msg.Parameters[2] message := msg.Parameters[1] + ": " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
WithTimestamp: true, WithTimestamp: true,
AsServerMsg: true, AsErrorMsg: true,
} }
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleUNKNOWNCOMMAND(msg parser.IRCMessage, client *irc.Client) { func handleNOSUCHSERVER(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + " " + msg.Parameters[2] message := msg.Parameters[1] + ": " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
WithTimestamp: true, WithTimestamp: true,
AsServerMsg: true, AsErrorMsg: true,
} }
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleNONICKNAMEGIVEN(msg parser.IRCMessage, client *irc.Client) { func handleNOSUCHCHANNEL(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
message := msg.Parameters[2]
message = fmt.Sprintf("%v: %v", channel, message)
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleCANNOTSENDTOCHAN(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
message := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
for i, c := range client.Channels {
if c.Name == channel {
client.Channels[i].AppendMsg(msg.Timestamp, message, msgOpts)
return
}
}
message = fmt.Sprintf("%v: %v", channel, message)
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleTOOMANYCHANNELS(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
message := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, fmt.Sprintf("%v: %v", channel, message), msgOpts)
}
func handleUNKNOWNCOMMAND(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + " " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleNOMOTD(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -572,18 +936,7 @@ func handleNONICKNAMEGIVEN(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func handleNEEDMOREPARAMS(msg parser.IRCMessage, client *irc.Client) { func handleNONICKNAMEGIVEN(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + " " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleALREADYREGISTERED(msg parser.IRCMessage, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{ msgOpts := irc.MsgFmtOpts{
@@ -594,19 +947,77 @@ func handleALREADYREGISTERED(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
} }
func HandleCommand(msg parser.IRCMessage, client *irc.Client) { func handleNEEDMOREPARAMS(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] + ": " + msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleALREADYREGISTERED(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts)
}
func handleBADCHANMASK(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
message := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
client.Channels[0].AppendMsg(msg.Timestamp, fmt.Sprintf("%v: %v", channel, message), msgOpts)
}
func handleCHANOPRIVSNEEDED(msg irc.Message, client *irc.Client) {
channel := msg.Parameters[1]
message := msg.Parameters[2]
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsErrorMsg: true,
}
for i, c := range client.Channels {
if c.Name == channel {
client.Channels[i].AppendMsg(msg.Timestamp, message, msgOpts)
}
}
}
func HandleCommand(msg irc.Message, client *irc.Client) {
// TODO: handle different commands // TODO: handle different commands
switch msg.Command { switch msg.Command {
case commands.PING: case commands.PING:
handlePing(msg, client) handlePing(msg, client)
case commands.PRIVMSG: case commands.PRIVMSG:
handlePrivMsg(msg, client) handlePrivMsg(msg, client)
case commands.NOTICE:
handleNotice(msg, client)
case commands.JOIN: case commands.JOIN:
handleJoin(msg, client) handleJoin(msg, client)
case commands.NICK:
handleNick(msg, client)
case commands.KICK:
handleKick(msg, client)
case commands.QUIT: case commands.QUIT:
handleQuit(msg, client) handleQuit(msg, client)
case commands.PART: case commands.PART:
handlePart(msg, client) handlePart(msg, client)
case commands.TOPIC:
handleTopic(msg, client)
case commands.CAP: case commands.CAP:
handleCAP(msg, client) handleCAP(msg, client)
case commands.RPL_WELCOME: case commands.RPL_WELCOME:
@@ -617,6 +1028,8 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
handleCREATED(msg, client) handleCREATED(msg, client)
case commands.RPL_MYINFO: case commands.RPL_MYINFO:
handleMYINFO(msg, client) handleMYINFO(msg, client)
case commands.RPL_ISUPPORT:
handleISUPPORT(msg, client)
case commands.RPL_LUSERCLIENT: case commands.RPL_LUSERCLIENT:
handleLUSERCLIENT(msg, client) handleLUSERCLIENT(msg, client)
case commands.RPL_LUSEROP: case commands.RPL_LUSEROP:
@@ -631,6 +1044,12 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
handleLOCALUSERS(msg, client) handleLOCALUSERS(msg, client)
case commands.RPL_GLOBALUSERS: case commands.RPL_GLOBALUSERS:
handleGLOBALUSERS(msg, client) handleGLOBALUSERS(msg, client)
case commands.RPL_AWAY:
handleAWAY(msg, client)
case commands.RPL_UNAWAY:
handleUNAWAY(msg, client)
case commands.RPL_NOWAWAY:
handleNOWAWAY(msg, client)
case commands.RPL_WHOISUSER: case commands.RPL_WHOISUSER:
handleWHOISUSER(msg, client) handleWHOISUSER(msg, client)
case commands.RPL_WHOISSERVER: case commands.RPL_WHOISSERVER:
@@ -641,6 +1060,12 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
handleENDOFWHOIS(msg, client) handleENDOFWHOIS(msg, client)
case commands.RPL_WHOISCHANNELS: case commands.RPL_WHOISCHANNELS:
handleWHOISCHANNELS(msg, client) handleWHOISCHANNELS(msg, client)
case commands.RPL_NOTOPIC:
handleNOTOPIC(msg, client)
case commands.RPL_TOPIC:
handleTOPIC(msg, client)
case commands.RPL_VERSION:
handleVERSION(msg, client)
case commands.RPL_NAMREPLY: case commands.RPL_NAMREPLY:
handleNAMREPLY(msg, client) handleNAMREPLY(msg, client)
case commands.RPL_ENDOFNAMES: case commands.RPL_ENDOFNAMES:
@@ -649,6 +1074,10 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
// start a timeout and update said timeout on every RPL_NAMREPLY // start a timeout and update said timeout on every RPL_NAMREPLY
// and log an error if timeout ends without receiving this command. // and log an error if timeout ends without receiving this command.
case commands.RPL_INFO:
handleINFO(msg, client)
case commands.RPL_ENDOFINFO:
handleENDOFINFO(msg, client)
case commands.RPL_MOTDSTART: case commands.RPL_MOTDSTART:
handleMOTDStart(msg, client) handleMOTDStart(msg, client)
case commands.RPL_MOTD: case commands.RPL_MOTD:
@@ -663,16 +1092,30 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
handleWHOISHOST(msg, client) handleWHOISHOST(msg, client)
case commands.RPL_WHOISMODES: case commands.RPL_WHOISMODES:
handleWHOISMODES(msg, client) handleWHOISMODES(msg, client)
case commands.ERR_NOSUCHNICK:
handleNOSUCHNICK(msg, client)
case commands.ERR_NOSUCHSERVER: case commands.ERR_NOSUCHSERVER:
handleNOSUCHSERVER(msg, client) handleNOSUCHSERVER(msg, client)
case commands.ERR_NOSUCHCHANNEL:
handleNOSUCHCHANNEL(msg, client)
case commands.ERR_CANNOTSENDTOCHAN:
handleCANNOTSENDTOCHAN(msg, client)
case commands.ERR_TOOMANYCHANNELS:
handleTOOMANYCHANNELS(msg, client)
case commands.ERR_UNKNOWNCOMMAND: case commands.ERR_UNKNOWNCOMMAND:
handleUNKNOWNCOMMAND(msg, client) handleUNKNOWNCOMMAND(msg, client)
case commands.ERR_NOMOTD:
handleNOMOTD(msg, client)
case commands.ERR_NONICKNAMEGIVEN: case commands.ERR_NONICKNAMEGIVEN:
handleNONICKNAMEGIVEN(msg, client) handleNONICKNAMEGIVEN(msg, client)
case commands.ERR_NEEDMOREPARAMS: case commands.ERR_NEEDMOREPARAMS:
handleNEEDMOREPARAMS(msg, client) handleNEEDMOREPARAMS(msg, client)
case commands.ERR_ALREADYREGISTERED: case commands.ERR_ALREADYREGISTERED:
handleALREADYREGISTERED(msg, client) handleALREADYREGISTERED(msg, client)
case commands.ERR_BADCHANMASK:
handleBADCHANMASK(msg, client)
case commands.ERR_CHANOPRIVSNEEDED:
handleCHANOPRIVSNEEDED(msg, client)
default: default:
fullMsg := fmt.Sprintf( fullMsg := fmt.Sprintf(
"%s %s %s %s", "%s %s %s %s",
+56 -1
View File
@@ -17,7 +17,9 @@
package handler package handler
import ( import (
"fmt"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/illusionman1212/gorc/cmds" "github.com/illusionman1212/gorc/cmds"
@@ -25,10 +27,61 @@ import (
"github.com/illusionman1212/gorc/irc/commands" "github.com/illusionman1212/gorc/irc/commands"
) )
func handleSlashPrivMsg(params []string, client *irc.Client) tea.Cmd {
var batchedCmds []tea.Cmd
if len(params) < 2 {
client.SendCommand(commands.PRIVMSG, params...)
return nil
}
target := strings.ToLower(params[0])
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
}
now := time.Now()
timestamp := fmt.Sprintf("[%02d:%02d]", now.Hour(), now.Minute())
msg := fmt.Sprintf("%s: %s", client.Nickname, strings.Join(params[1:], " "))
for i, c := range client.Channels {
if c.Name == target {
client.ActiveChannelIndex = i
client.ActiveChannel = target
client.Channels[i].AppendMsg(timestamp, msg, msgOpts)
client.SendCommand(commands.PRIVMSG, params...)
return cmds.SwitchChannels
}
}
// If we're messaging a user and their "channel" wasn't found in the previous loop, then create it and append it
if target[0] != '#' && target[0] != '&' {
newChannel := irc.Channel{
Name: target,
Users: map[string]irc.User{target: {}},
}
newChannel.AppendMsg(timestamp, msg, msgOpts)
client.ActiveChannelIndex = len(client.Channels)
client.ActiveChannel = target
client.Channels = append(client.Channels, newChannel)
batchedCmds = append(batchedCmds, cmds.UpdateTabBar)
}
client.SendCommand(commands.PRIVMSG, params...)
batchedCmds = append(batchedCmds, cmds.SwitchChannels)
return tea.Batch(batchedCmds...)
}
func handleSlashJoin(params []string, client *irc.Client) tea.Cmd { func handleSlashJoin(params []string, client *irc.Client) tea.Cmd {
channel := "" channel := ""
if len(params) > 0 { if len(params) > 0 {
channel = params[0] channel = strings.ToLower(params[0])
} }
for i, c := range client.Channels { for i, c := range client.Channels {
@@ -52,6 +105,8 @@ func HandleSlashCommand(msg string, client *irc.Client) tea.Cmd {
} }
switch command { switch command {
case commands.PRIVMSG:
return handleSlashPrivMsg(params, client)
case commands.JOIN: case commands.JOIN:
return handleSlashJoin(params, client) return handleSlashJoin(params, client)
case commands.QUIT: case commands.QUIT:
+8 -33
View File
@@ -17,37 +17,12 @@
package parser package parser
import ( import (
"fmt"
"log"
"regexp" "regexp"
"strings" "strings"
"time"
"github.com/illusionman1212/gorc/irc"
) )
type IRCMessage struct {
Timestamp string
Tags Tags // starts with @ | Optional
Source string // starts with : | Optional
Command string // can either be a string or a numeric value | Required
Parameters []string // Optional (Dependant on command)
}
type Tags map[string]string
func (m *IRCMessage) setTimestamp() {
if serverTime, ok := m.Tags["time"]; ok {
t, err := time.Parse("2006-01-02T15:04:05.000Z", serverTime)
if err != nil {
// TODO: properly handle error
log.Fatalln(err)
}
m.Timestamp = fmt.Sprintf("[%02d:%02d]", t.Local().Hour(), t.Local().Minute())
} else {
now := time.Now()
m.Timestamp = fmt.Sprintf("[%02d:%02d]", now.Hour(), now.Minute())
}
}
func escapeCharacters(char rune) string { func escapeCharacters(char rune) string {
switch char { switch char {
case 's': case 's':
@@ -75,8 +50,8 @@ func escapeCharacters(char rune) string {
} }
} }
func parseTags(rawTags string) Tags { func parseTags(rawTags string) irc.MessageTags {
tags := make(Tags, 0) tags := make(irc.MessageTags, 0)
rawTagsSlice := strings.Split(rawTags, ";") rawTagsSlice := strings.Split(rawTags, ";")
for _, tag := range rawTagsSlice { for _, tag := range rawTagsSlice {
@@ -113,8 +88,8 @@ func parseTags(rawTags string) Tags {
return tags return tags
} }
func ParseIRCMessage(line string) (IRCMessage, bool) { func ParseIRCMessage(line string) (irc.Message, bool) {
ircMessage := IRCMessage{} ircMessage := irc.Message{}
multipleSpacesRegex := regexp.MustCompile(`[^\S\t]+`) multipleSpacesRegex := regexp.MustCompile(`[^\S\t]+`)
trailingParamRegex := regexp.MustCompile(`[^\S\t]+:`) trailingParamRegex := regexp.MustCompile(`[^\S\t]+:`)
whitespaceRegex := regexp.MustCompile(`^[^\S\t]+$`) whitespaceRegex := regexp.MustCompile(`^[^\S\t]+$`)
@@ -122,7 +97,7 @@ func ParseIRCMessage(line string) (IRCMessage, bool) {
if len(line) <= 0 { if len(line) <= 0 {
// TODO: do something here ??? // TODO: do something here ???
// log.Println("empty message") // log.Println("empty message")
return IRCMessage{}, false return irc.Message{}, false
} }
if line[0] == '@' { if line[0] == '@' {
@@ -169,7 +144,7 @@ func ParseIRCMessage(line string) (IRCMessage, bool) {
ircMessage.Parameters = finalParams ircMessage.Parameters = finalParams
} }
ircMessage.setTimestamp() ircMessage.SetTimestamp()
return ircMessage, true return ircMessage, true
} }
+1
View File
@@ -91,6 +91,7 @@ func (s State) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
tlsEnabled := s.UI.Login.TLS tlsEnabled := s.UI.Login.TLS
s.UI.CurrentScreen = MainScreen s.UI.CurrentScreen = MainScreen
s.Client.InitialChannel = channel
s.Client.Initialize(host, port, tlsEnabled) s.Client.Initialize(host, port, tlsEnabled)
s.Client.Register(nickname, password, channel) s.Client.Register(nickname, password, channel)
+31
View File
@@ -18,6 +18,7 @@ package mainscreen
import ( import (
"math" "math"
"strconv"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@@ -194,6 +195,36 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) {
cmdsToProcess = append(cmdsToProcess, cmd) cmdsToProcess = append(cmdsToProcess, cmd)
case InputBox: case InputBox:
s.InputBox, cmd = s.InputBox.Update(msg) s.InputBox, cmd = s.InputBox.Update(msg)
width := 0
command := strings.Split(s.InputBox.Input.Value(), " ")[0]
if len(command) > 0 && command[0] == '/' {
switch strings.ToUpper(command)[1:] {
case commands.AWAY:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["AWAYLEN"], 10, 32)
if err == nil {
width = int(w)
}
case commands.TOPIC:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["TOPICLEN"], 10, 32)
if err == nil {
width = int(w)
}
case commands.NICK:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["NICKLEN"], 10, 32)
if err == nil {
width = int(w)
}
case commands.KICK:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["KICKLEN"], 10, 32)
if err == nil {
width = int(w)
}
}
}
s.InputBox.Input.CharLimit = width
cmdsToProcess = append(cmdsToProcess, cmd) cmdsToProcess = append(cmdsToProcess, cmd)
} }
+33 -2
View File
@@ -19,7 +19,10 @@ package mainscreen
import ( import (
"fmt" "fmt"
"math" "math"
"sort"
"strings" "strings"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -40,11 +43,38 @@ func (s *SidePanelState) getHeader() string {
usersCount = len(s.Client.Channels[s.Client.ActiveChannelIndex].Users) usersCount = len(s.Client.Channels[s.Client.ActiveChannelIndex].Users)
} }
separator := strings.Repeat("—", s.Viewport.Width-s.Viewport.Style.GetHorizontalFrameSize()) + "\n" separator := strings.Repeat("—", s.Viewport.Width-s.Viewport.Style.GetHorizontalFrameSize()) + "\n"
header := fmt.Sprintf("Users in this channel (%d)\n", usersCount) + separator header := fmt.Sprintf("%d Users\n", usersCount) + separator
return header return header
} }
// lessCaseInsensitive compares s, t without allocating
func lessCaseInsensitive(s, t string) bool {
for {
if len(t) == 0 {
return false
}
if len(s) == 0 {
return true
}
c, sizec := utf8.DecodeRuneInString(s)
d, sized := utf8.DecodeRuneInString(t)
lowerc := unicode.ToLower(c)
lowerd := unicode.ToLower(d)
if lowerc < lowerd {
return true
}
if lowerc > lowerd {
return false
}
s = s[sizec:]
t = t[sized:]
}
}
func (s *SidePanelState) getLatestNicks() []string { func (s *SidePanelState) getLatestNicks() []string {
nicks := make([]string, 0) nicks := make([]string, 0)
@@ -55,6 +85,7 @@ func (s *SidePanelState) getLatestNicks() []string {
} }
} }
sort.Slice(nicks, func(i, j int) bool { return lessCaseInsensitive(nicks[i], nicks[j]) })
return nicks return nicks
} }
@@ -73,7 +104,7 @@ func (s SidePanelState) Update(msg tea.Msg) (SidePanelState, tea.Cmd) {
var cmdsToProcess []tea.Cmd var cmdsToProcess []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case cmds.SwitchChannelsMsg: case cmds.SwitchChannelsMsg, cmds.UpdateNicksMsg:
s.UpdateNicks() s.UpdateNicks()
return s, nil return s, nil
case tea.KeyMsg: case tea.KeyMsg: