408 lines
12 KiB
Go
408 lines
12 KiB
Go
|
package libnmdc
|
||
|
|
||
|
import (
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"regexp"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
)
|
||
|
|
||
|
type NmdcProtocol struct {
|
||
|
hc *HubConnection
|
||
|
sentOurHello bool
|
||
|
supports map[string]struct{}
|
||
|
|
||
|
rxPublicChat *regexp.Regexp
|
||
|
rxIncomingTo *regexp.Regexp
|
||
|
rxUserCommand *regexp.Regexp
|
||
|
rxMyInfo *regexp.Regexp
|
||
|
rxMyInfoNoTag *regexp.Regexp
|
||
|
}
|
||
|
|
||
|
func NewNmdcProtocol(hc *HubConnection) Protocol {
|
||
|
proto := NmdcProtocol{}
|
||
|
proto.hc = hc
|
||
|
|
||
|
// With the `m` flag, use \A instead of ^ to anchor to start
|
||
|
// This fixes accidentally finding a better match in the middle of a multi-line message
|
||
|
|
||
|
proto.rxPublicChat = regexp.MustCompile(`(?ms)\A<([^>]*)> (.*)$`)
|
||
|
proto.rxIncomingTo = regexp.MustCompile(`(?ms)\A([^ ]+) From: ([^ ]+) \$<([^>]*)> (.*)`)
|
||
|
proto.rxUserCommand = regexp.MustCompile(`(?ms)\A(\d+) (\d+)\s?([^\$]*)\$?(.*)`)
|
||
|
|
||
|
// Format: $ALL <nick> <description>$ $<connection><flag>$<e-mail>$<sharesize>$
|
||
|
|
||
|
HEAD := `(?ms)^\$ALL ([^ ]+) `
|
||
|
FOOT := `\$.\$([^$]+)\$([^$]*)\$([0-9]*)\$$`
|
||
|
|
||
|
proto.rxMyInfo = regexp.MustCompile(HEAD + `([^<]*)<(.+?) V:([^,]+),M:(.),H:([0-9]+)/([0-9]+)/([0-9]+),S:([0-9]+)>` + FOOT)
|
||
|
proto.rxMyInfoNoTag = regexp.MustCompile(HEAD + `([^$]*)` + FOOT) // Fallback for no tag
|
||
|
|
||
|
// Done
|
||
|
return &proto
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) ProcessCommand(message string) {
|
||
|
|
||
|
// Zero-length protocol message
|
||
|
// ````````````````````````````
|
||
|
if len(message) == 0 {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Public chat
|
||
|
// ```````````
|
||
|
if this.rxPublicChat.MatchString(message) {
|
||
|
pubchat_parts := this.rxPublicChat.FindStringSubmatch(message)
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_PUBLIC, Nick: pubchat_parts[1], Message: this.unescape(pubchat_parts[2])})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// System messages
|
||
|
// ```````````````
|
||
|
if message[0] != '$' {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_SYSTEM_MESSAGE_FROM_HUB, Nick: this.hc.HubName, Message: this.unescape(message)})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Protocol messages
|
||
|
// `````````````````
|
||
|
|
||
|
commandParts := strings.SplitN(message, " ", 2)
|
||
|
switch commandParts[0] {
|
||
|
|
||
|
case "$Lock":
|
||
|
this.hc.SayRaw("$Supports NoHello NoGetINFO UserCommand UserIP2 QuickList ChatOnly|" +
|
||
|
"$Key " + this.unlock([]byte(commandParts[1])) + "|")
|
||
|
this.sentOurHello = false
|
||
|
|
||
|
case "$Hello":
|
||
|
if commandParts[1] == this.hc.Hco.Self.Nick && !this.sentOurHello {
|
||
|
this.hc.SayRaw("$Version 1,0091|")
|
||
|
this.hc.SayRaw("$GetNickList|")
|
||
|
this.SayInfo()
|
||
|
this.sentOurHello = true
|
||
|
|
||
|
} else {
|
||
|
this.hc.userJoined_NameOnly(commandParts[1])
|
||
|
|
||
|
}
|
||
|
|
||
|
case "$HubName":
|
||
|
this.hc.HubName = commandParts[1]
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_HUBNAME_CHANGED, Nick: commandParts[1]})
|
||
|
|
||
|
case "$ValidateDenide": // sic
|
||
|
if len(this.hc.Hco.NickPassword) > 0 {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_SYSTEM_MESSAGE_FROM_CONN, Message: "Incorrect password."})
|
||
|
} else {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_SYSTEM_MESSAGE_FROM_CONN, Message: "Nick already in use."})
|
||
|
}
|
||
|
|
||
|
case "$HubIsFull":
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_SYSTEM_MESSAGE_FROM_CONN, Message: "Hub is full."})
|
||
|
|
||
|
case "$BadPass":
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_SYSTEM_MESSAGE_FROM_CONN, Message: "Incorrect password."})
|
||
|
|
||
|
case "$GetPass":
|
||
|
if len(this.hc.Hco.NickPassword) == 0 {
|
||
|
// We've got a problem. MyPass with no arguments is a syntax error with no message = instant close
|
||
|
// Just drop the connection
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_SYSTEM_MESSAGE_FROM_CONN, Message: "This account is passworded."})
|
||
|
this.hc.Disconnect()
|
||
|
} else {
|
||
|
this.hc.SayRaw("$MyPass " + this.escape(this.hc.Hco.NickPassword) + "|")
|
||
|
}
|
||
|
|
||
|
case "$Quit":
|
||
|
this.hc.usersMut.Lock()
|
||
|
delete(this.hc.users, commandParts[1])
|
||
|
this.hc.usersMut.Unlock() // Don't lock over a processEvent boundary
|
||
|
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_USER_PART, Nick: commandParts[1]})
|
||
|
|
||
|
case "$MyINFO":
|
||
|
u, err := this.parseMyINFO(commandParts[1])
|
||
|
if err != nil {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_DEBUG_MESSAGE, Message: err.Error()})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
this.hc.userJoined_Full(u)
|
||
|
|
||
|
case "$NickList":
|
||
|
nicklist := strings.Split(commandParts[1], "$$")
|
||
|
for _, nick := range nicklist {
|
||
|
if len(nick) > 0 {
|
||
|
this.hc.userJoined_NameOnly(nick)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
case "$OpList":
|
||
|
oplist := strings.Split(commandParts[1], "$$")
|
||
|
opmap := map[string]struct{}{}
|
||
|
|
||
|
// Organise/sort the list, and ensure we're not meeting an operator for
|
||
|
// the first time
|
||
|
for _, nick := range oplist {
|
||
|
if len(nick) > 0 {
|
||
|
opmap[nick] = struct{}{}
|
||
|
this.hc.userJoined_NameOnly(nick) // assert existence; noop otherwise
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Mark all mentioned nicks as being operators, and all unmentioned nicks
|
||
|
// as being /not/ an operator. (second pass minimises RW mutex use)
|
||
|
func() {
|
||
|
this.hc.usersMut.Lock()
|
||
|
defer this.hc.usersMut.Unlock()
|
||
|
|
||
|
for nick, userinfo := range this.hc.users {
|
||
|
_, isop := opmap[nick]
|
||
|
|
||
|
userinfo.IsOperator = isop
|
||
|
this.hc.users[nick] = userinfo
|
||
|
}
|
||
|
}()
|
||
|
|
||
|
case "$To:":
|
||
|
valid := false
|
||
|
if this.rxIncomingTo.MatchString(commandParts[1]) {
|
||
|
txparts := this.rxIncomingTo.FindStringSubmatch(commandParts[1])
|
||
|
if txparts[1] == this.hc.Hco.Self.Nick && txparts[2] == txparts[3] {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_PRIVATE, Nick: txparts[2], Message: this.unescape(txparts[4])})
|
||
|
valid = true
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if !valid {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_DEBUG_MESSAGE, Message: "Malformed private message '" + commandParts[1] + "'"})
|
||
|
}
|
||
|
|
||
|
case "$UserIP":
|
||
|
this.hc.usersMut.Lock()
|
||
|
|
||
|
pairs := strings.Split(commandParts[1], "$$")
|
||
|
notifyOfUpdate := make([]string, 0, len(pairs))
|
||
|
|
||
|
nextIPPair:
|
||
|
for _, pair := range pairs {
|
||
|
parts := strings.SplitN(pair, " ", 2)
|
||
|
if len(parts) != 2 {
|
||
|
// ????
|
||
|
continue nextIPPair
|
||
|
}
|
||
|
|
||
|
ip2nick := parts[0]
|
||
|
ip2addr := parts[1]
|
||
|
|
||
|
uinfo, ok := this.hc.users[ip2nick]
|
||
|
if !ok {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_DEBUG_MESSAGE, Message: "Recieved IP '" + ip2addr + "' for unknown user '" + ip2nick + "'"})
|
||
|
continue nextIPPair
|
||
|
}
|
||
|
|
||
|
if uinfo.IPAddress != ip2addr {
|
||
|
uinfo.IPAddress = ip2addr
|
||
|
notifyOfUpdate = append(notifyOfUpdate, ip2nick)
|
||
|
this.hc.users[ip2nick] = uinfo
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.hc.usersMut.Unlock()
|
||
|
|
||
|
for _, nick := range notifyOfUpdate {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_USER_UPDATED_INFO, Nick: nick})
|
||
|
}
|
||
|
|
||
|
case "$ForceMove":
|
||
|
this.hc.Hco.Address = HubAddress(commandParts[1])
|
||
|
this.hc.conn.Close() // we'll reconnect onto the new address
|
||
|
|
||
|
case "$UserCommand":
|
||
|
// $UserCommand 1 1 Group chat\New group chat$<%[mynick]> !groupchat_new||
|
||
|
if this.rxUserCommand.MatchString(commandParts[1]) {
|
||
|
usc := this.rxUserCommand.FindStringSubmatch(commandParts[1])
|
||
|
|
||
|
typeInt, _ := strconv.Atoi(usc[1])
|
||
|
contextInt, _ := strconv.Atoi(usc[2])
|
||
|
|
||
|
uscStruct := UserCommand{
|
||
|
Type: UserCommandType(typeInt),
|
||
|
Context: UserCommandContext(contextInt),
|
||
|
Message: usc[3],
|
||
|
Command: this.unescape(usc[4]),
|
||
|
}
|
||
|
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_USERCOMMAND, UserCommand: &uscStruct})
|
||
|
|
||
|
} else {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_DEBUG_MESSAGE, Message: "Malformed usercommand '" + commandParts[1] + "'"})
|
||
|
}
|
||
|
|
||
|
case "$Supports":
|
||
|
this.supports = make(map[string]struct{})
|
||
|
for _, s := range strings.Split(commandParts[1], " ") {
|
||
|
this.supports[s] = struct{}{}
|
||
|
}
|
||
|
|
||
|
if !this.sentOurHello {
|
||
|
|
||
|
// Need to log in.
|
||
|
// If the hub supports QuickList, we can skip one network roundtrip
|
||
|
if _, ok := this.supports["QuickList"]; ok {
|
||
|
this.SayInfo()
|
||
|
this.hc.SayRaw("$GetNickList|")
|
||
|
} else {
|
||
|
this.hc.SayRaw("$ValidateNick " + this.escape(this.hc.Hco.Self.Nick) + "|")
|
||
|
}
|
||
|
|
||
|
// This also counts as the end of the handshake from our POV. Consider
|
||
|
// ourselves logged in
|
||
|
this.sentOurHello = true
|
||
|
if this.hc.State != CONNECTIONSTATE_CONNECTED {
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_CONNECTION_STATE_CHANGED, StateChange: CONNECTIONSTATE_CONNECTED})
|
||
|
this.hc.State = CONNECTIONSTATE_CONNECTED
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
// IGNORABLE COMMANDS
|
||
|
case "$HubTopic":
|
||
|
case "$Search":
|
||
|
case "$ConnectToMe":
|
||
|
|
||
|
default:
|
||
|
this.hc.processEvent(HubEvent{EventType: EVENT_DEBUG_MESSAGE, Message: "Unhandled protocol command '" + commandParts[0] + "'"})
|
||
|
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) escape(plaintext string) string {
|
||
|
v1 := strings.Replace(plaintext, "&", "&", -1)
|
||
|
v2 := strings.Replace(v1, "|", "|", -1)
|
||
|
return strings.Replace(v2, "$", "$", -1)
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) unescape(encoded string) string {
|
||
|
v1 := strings.Replace(encoded, "$", "$", -1)
|
||
|
v2 := strings.Replace(v1, "|", "|", -1)
|
||
|
return strings.Replace(v2, "&", "&", -1)
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) SayPublic(message string) {
|
||
|
this.hc.SayRaw("<" + this.hc.Hco.Self.Nick + "> " + this.escape(message) + "|")
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) SayPrivate(recipient, message string) {
|
||
|
this.hc.SayRaw("$To: " + recipient + " From: " + this.hc.Hco.Self.Nick + " $<" + this.hc.Hco.Self.Nick + "> " + this.escape(message) + "|")
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) SayInfo() {
|
||
|
this.hc.SayRaw(this.getUserMyINFO(this.hc.Hco.Self) + "|")
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) parseMyINFO(protomsg string) (*UserInfo, error) {
|
||
|
ret := UserInfo{}
|
||
|
|
||
|
// Normal format (with tag in exact V/M/H/S order)
|
||
|
matches := this.rxMyInfo.FindStringSubmatch(protomsg)
|
||
|
if matches != nil {
|
||
|
ret.Nick = matches[1]
|
||
|
ret.Description = this.unescape(matches[2])
|
||
|
ret.ClientTag = this.unescape(matches[3])
|
||
|
ret.ClientVersion = matches[4]
|
||
|
ret.ConnectionMode = ConnectionMode(matches[5][0])
|
||
|
maybeParse(matches[6], &ret.HubsUnregistered, 0)
|
||
|
maybeParse(matches[7], &ret.HubsRegistered, 0)
|
||
|
maybeParse(matches[8], &ret.HubsOperator, 0)
|
||
|
maybeParse(matches[9], &ret.Slots, 0)
|
||
|
if len(matches[10]) > 1 {
|
||
|
ret.Speed = matches[10][:len(matches[10])-2]
|
||
|
} else {
|
||
|
ret.Speed = ""
|
||
|
}
|
||
|
ret.Flag = UserFlag(matches[10][len(matches[10])-1])
|
||
|
ret.Email = this.unescape(matches[11])
|
||
|
maybeParse(matches[12], &ret.ShareSize, 0)
|
||
|
|
||
|
return &ret, nil
|
||
|
}
|
||
|
|
||
|
// No-tag format, used in early connection
|
||
|
matches = this.rxMyInfoNoTag.FindStringSubmatch(protomsg)
|
||
|
if matches != nil {
|
||
|
ret.Nick = matches[1]
|
||
|
ret.Description = this.unescape(matches[2])
|
||
|
ret.ClientTag = ""
|
||
|
ret.ClientVersion = "0"
|
||
|
ret.ConnectionMode = CONNECTIONMODE_PASSIVE
|
||
|
ret.HubsUnregistered = 0
|
||
|
ret.HubsRegistered = 0
|
||
|
ret.HubsOperator = 0
|
||
|
ret.Slots = 0
|
||
|
|
||
|
if len(matches[3]) > 1 {
|
||
|
ret.Speed = matches[3][:len(matches[3])-2]
|
||
|
} else {
|
||
|
ret.Speed = ""
|
||
|
}
|
||
|
ret.Flag = UserFlag(matches[3][len(matches[3])-1])
|
||
|
ret.Email = this.unescape(matches[4])
|
||
|
maybeParse(matches[5], &ret.ShareSize, 0)
|
||
|
|
||
|
return &ret, nil
|
||
|
}
|
||
|
|
||
|
// Couldn't get anything out of it...
|
||
|
return nil, errors.New("Malformed MyINFO")
|
||
|
}
|
||
|
|
||
|
// Returns the MyINFO command, WITH leading $MyINFO, and WITHOUT trailing pipe
|
||
|
func (this *NmdcProtocol) getUserMyINFO(u *UserInfo) string {
|
||
|
return fmt.Sprintf(
|
||
|
"$MyINFO $ALL %s %s<%s V:%s,M:%c,H:%d/%d/%d,S:%d>$ $%s%c$%s$%d$",
|
||
|
u.Nick,
|
||
|
u.Description,
|
||
|
u.ClientTag,
|
||
|
strings.Replace(u.ClientVersion, ",", "-", -1), // just in case
|
||
|
u.ConnectionMode,
|
||
|
u.HubsUnregistered,
|
||
|
u.HubsRegistered,
|
||
|
u.HubsOperator,
|
||
|
u.Slots,
|
||
|
u.Speed,
|
||
|
u.Flag,
|
||
|
u.Email,
|
||
|
u.ShareSize,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) ProtoMessageSeparator() string {
|
||
|
return "|"
|
||
|
}
|
||
|
|
||
|
func (this *NmdcProtocol) unlock(lock []byte) string {
|
||
|
|
||
|
nibble_swap := func(b byte) byte {
|
||
|
return ((b << 4) & 0xF0) | ((b >> 4) & 0x0F)
|
||
|
}
|
||
|
|
||
|
chr := func(b byte) string {
|
||
|
if b == 0 || b == 5 || b == 36 || b == 96 || b == 124 || b == 126 {
|
||
|
return fmt.Sprintf("/%%DCN%04d%%/", b)
|
||
|
} else {
|
||
|
return string(b)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
key := chr(nibble_swap(lock[0] ^ lock[len(lock)-2] ^ lock[len(lock)-3] ^ 5))
|
||
|
for i := 1; i < len(lock); i += 1 {
|
||
|
key += chr(nibble_swap(lock[i] ^ lock[i-1]))
|
||
|
}
|
||
|
|
||
|
return key
|
||
|
}
|