source: code/trunk/irc.go@ 660

Last change on this file since 660 was 660, checked in by contact, 4 years ago

Add support for WHOX

This adds support for WHOX, without bothering about flags and mask2
because Solanum and Ergo [1] don't support it either.

The motivation is to allow clients to reliably query account names.

It's not possible to use WHOX tokens to route replies to the right
client, because RPL_ENDOFWHO doesn't contain it.

[1]: https://github.com/ergochat/ergo/pull/1184

Closes: https://todo.sr.ht/~emersion/soju/135

File size: 17.2 KB
RevLine 
[98]1package soju
[20]2
3import (
4 "fmt"
[350]5 "sort"
[20]6 "strings"
[516]7 "time"
[498]8 "unicode"
9 "unicode/utf8"
[43]10
11 "gopkg.in/irc.v3"
[20]12)
13
14const (
[108]15 rpl_statsping = "246"
16 rpl_localusers = "265"
17 rpl_globalusers = "266"
[162]18 rpl_creationtime = "329"
[108]19 rpl_topicwhotime = "333"
[660]20 rpl_whospcrpl = "354"
[108]21 err_invalidcapcmd = "410"
[20]22)
23
[463]24const (
25 maxMessageLength = 512
26 maxMessageParams = 15
27)
[346]28
[350]29// The server-time layout, as defined in the IRCv3 spec.
30const serverTimeLayout = "2006-01-02T15:04:05.000Z"
31
[139]32type userModes string
[20]33
[139]34func (ms userModes) Has(c byte) bool {
[20]35 return strings.IndexByte(string(ms), c) >= 0
36}
37
[139]38func (ms *userModes) Add(c byte) {
[20]39 if !ms.Has(c) {
[139]40 *ms += userModes(c)
[20]41 }
42}
43
[139]44func (ms *userModes) Del(c byte) {
[20]45 i := strings.IndexByte(string(*ms), c)
46 if i >= 0 {
47 *ms = (*ms)[:i] + (*ms)[i+1:]
48 }
49}
50
[139]51func (ms *userModes) Apply(s string) error {
[20]52 var plusMinus byte
53 for i := 0; i < len(s); i++ {
54 switch c := s[i]; c {
55 case '+', '-':
56 plusMinus = c
57 default:
58 switch plusMinus {
59 case '+':
60 ms.Add(c)
61 case '-':
62 ms.Del(c)
63 default:
64 return fmt.Errorf("malformed modestring %q: missing plus/minus", s)
65 }
66 }
67 }
68 return nil
69}
70
[139]71type channelModeType byte
72
73// standard channel mode types, as explained in https://modern.ircdocs.horse/#mode-message
74const (
75 // modes that add or remove an address to or from a list
76 modeTypeA channelModeType = iota
77 // modes that change a setting on a channel, and must always have a parameter
78 modeTypeB
79 // modes that change a setting on a channel, and must have a parameter when being set, and no parameter when being unset
80 modeTypeC
81 // modes that change a setting on a channel, and must not have a parameter
82 modeTypeD
83)
84
85var stdChannelModes = map[byte]channelModeType{
86 'b': modeTypeA, // ban list
87 'e': modeTypeA, // ban exception list
88 'I': modeTypeA, // invite exception list
89 'k': modeTypeB, // channel key
90 'l': modeTypeC, // channel user limit
91 'i': modeTypeD, // channel is invite-only
92 'm': modeTypeD, // channel is moderated
93 'n': modeTypeD, // channel has no external messages
94 's': modeTypeD, // channel is secret
95 't': modeTypeD, // channel has protected topic
96}
97
98type channelModes map[byte]string
99
[293]100// applyChannelModes parses a mode string and mode arguments from a MODE message,
101// and applies the corresponding channel mode and user membership changes on that channel.
102//
103// If ch.modes is nil, channel modes are not updated.
104//
105// needMarshaling is a list of indexes of mode arguments that represent entities
106// that must be marshaled when sent downstream.
107func applyChannelModes(ch *upstreamChannel, modeStr string, arguments []string) (needMarshaling map[int]struct{}, err error) {
108 needMarshaling = make(map[int]struct{}, len(arguments))
[139]109 nextArgument := 0
110 var plusMinus byte
[293]111outer:
[139]112 for i := 0; i < len(modeStr); i++ {
113 mode := modeStr[i]
114 if mode == '+' || mode == '-' {
115 plusMinus = mode
116 continue
117 }
118 if plusMinus != '+' && plusMinus != '-' {
[293]119 return nil, fmt.Errorf("malformed modestring %q: missing plus/minus", modeStr)
[139]120 }
121
[293]122 for _, membership := range ch.conn.availableMemberships {
123 if membership.Mode == mode {
124 if nextArgument >= len(arguments) {
125 return nil, fmt.Errorf("malformed modestring %q: missing mode argument for %c%c", modeStr, plusMinus, mode)
126 }
127 member := arguments[nextArgument]
[478]128 m := ch.Members.Value(member)
129 if m != nil {
[293]130 if plusMinus == '+' {
[478]131 m.Add(ch.conn.availableMemberships, membership)
[293]132 } else {
133 // TODO: for upstreams without multi-prefix, query the user modes again
[478]134 m.Remove(membership)
[293]135 }
136 }
137 needMarshaling[nextArgument] = struct{}{}
138 nextArgument++
139 continue outer
140 }
141 }
142
143 mt, ok := ch.conn.availableChannelModes[mode]
[139]144 if !ok {
145 continue
146 }
147 if mt == modeTypeB || (mt == modeTypeC && plusMinus == '+') {
148 if plusMinus == '+' {
149 var argument string
150 // some sentitive arguments (such as channel keys) can be omitted for privacy
151 // (this will only happen for RPL_CHANNELMODEIS, never for MODE messages)
152 if nextArgument < len(arguments) {
153 argument = arguments[nextArgument]
154 }
[293]155 if ch.modes != nil {
156 ch.modes[mode] = argument
157 }
[139]158 } else {
[293]159 delete(ch.modes, mode)
[139]160 }
161 nextArgument++
162 } else if mt == modeTypeC || mt == modeTypeD {
163 if plusMinus == '+' {
[293]164 if ch.modes != nil {
165 ch.modes[mode] = ""
166 }
[139]167 } else {
[293]168 delete(ch.modes, mode)
[139]169 }
170 }
171 }
[293]172 return needMarshaling, nil
[139]173}
174
175func (cm channelModes) Format() (modeString string, parameters []string) {
176 var modesWithValues strings.Builder
177 var modesWithoutValues strings.Builder
178 parameters = make([]string, 0, 16)
179 for mode, value := range cm {
180 if value != "" {
181 modesWithValues.WriteString(string(mode))
182 parameters = append(parameters, value)
183 } else {
184 modesWithoutValues.WriteString(string(mode))
185 }
186 }
187 modeString = "+" + modesWithValues.String() + modesWithoutValues.String()
188 return
189}
190
191const stdChannelTypes = "#&+!"
192
[20]193type channelStatus byte
194
195const (
196 channelPublic channelStatus = '='
197 channelSecret channelStatus = '@'
198 channelPrivate channelStatus = '*'
199)
200
201func parseChannelStatus(s string) (channelStatus, error) {
202 if len(s) > 1 {
203 return 0, fmt.Errorf("invalid channel status %q: more than one character", s)
204 }
205 switch cs := channelStatus(s[0]); cs {
206 case channelPublic, channelSecret, channelPrivate:
207 return cs, nil
208 default:
209 return 0, fmt.Errorf("invalid channel status %q: unknown status", s)
210 }
211}
212
[139]213type membership struct {
214 Mode byte
215 Prefix byte
216}
[20]217
[139]218var stdMemberships = []membership{
219 {'q', '~'}, // founder
220 {'a', '&'}, // protected
221 {'o', '@'}, // operator
222 {'h', '%'}, // halfop
223 {'v', '+'}, // voice
224}
[20]225
[292]226// memberships always sorted by descending membership rank
227type memberships []membership
228
229func (m *memberships) Add(availableMemberships []membership, newMembership membership) {
230 l := *m
231 i := 0
232 for _, availableMembership := range availableMemberships {
233 if i >= len(l) {
234 break
235 }
236 if l[i] == availableMembership {
237 if availableMembership == newMembership {
238 // we already have this membership
239 return
240 }
241 i++
242 continue
243 }
244 if availableMembership == newMembership {
245 break
246 }
[128]247 }
[292]248 // insert newMembership at i
249 l = append(l, membership{})
250 copy(l[i+1:], l[i:])
251 l[i] = newMembership
252 *m = l
[128]253}
254
[292]255func (m *memberships) Remove(oldMembership membership) {
256 l := *m
257 for i, currentMembership := range l {
258 if currentMembership == oldMembership {
259 *m = append(l[:i], l[i+1:]...)
260 return
261 }
262 }
263}
264
265func (m memberships) Format(dc *downstreamConn) string {
266 if !dc.caps["multi-prefix"] {
267 if len(m) == 0 {
268 return ""
269 }
270 return string(m[0].Prefix)
271 }
272 prefixes := make([]byte, len(m))
273 for i, membership := range m {
274 prefixes[i] = membership.Prefix
275 }
276 return string(prefixes)
277}
278
[43]279func parseMessageParams(msg *irc.Message, out ...*string) error {
280 if len(msg.Params) < len(out) {
281 return newNeedMoreParamsError(msg.Command)
282 }
283 for i := range out {
284 if out[i] != nil {
285 *out[i] = msg.Params[i]
286 }
287 }
288 return nil
289}
[153]290
[303]291func copyClientTags(tags irc.Tags) irc.Tags {
292 t := make(irc.Tags, len(tags))
293 for k, v := range tags {
294 if strings.HasPrefix(k, "+") {
295 t[k] = v
296 }
297 }
298 return t
299}
300
[153]301type batch struct {
302 Type string
303 Params []string
304 Outer *batch // if not-nil, this batch is nested in Outer
[155]305 Label string
[153]306}
[193]307
[350]308func join(channels, keys []string) []*irc.Message {
309 // Put channels with a key first
310 js := joinSorter{channels, keys}
311 sort.Sort(&js)
312
313 // Two spaces because there are three words (JOIN, channels and keys)
314 maxLength := maxMessageLength - (len("JOIN") + 2)
315
316 var msgs []*irc.Message
317 var channelsBuf, keysBuf strings.Builder
318 for i, channel := range channels {
319 key := keys[i]
320
321 n := channelsBuf.Len() + keysBuf.Len() + 1 + len(channel)
322 if key != "" {
323 n += 1 + len(key)
324 }
325
326 if channelsBuf.Len() > 0 && n > maxLength {
327 // No room for the new channel in this message
328 params := []string{channelsBuf.String()}
329 if keysBuf.Len() > 0 {
330 params = append(params, keysBuf.String())
331 }
332 msgs = append(msgs, &irc.Message{Command: "JOIN", Params: params})
333 channelsBuf.Reset()
334 keysBuf.Reset()
335 }
336
337 if channelsBuf.Len() > 0 {
338 channelsBuf.WriteByte(',')
339 }
340 channelsBuf.WriteString(channel)
341 if key != "" {
342 if keysBuf.Len() > 0 {
343 keysBuf.WriteByte(',')
344 }
345 keysBuf.WriteString(key)
346 }
347 }
348 if channelsBuf.Len() > 0 {
349 params := []string{channelsBuf.String()}
350 if keysBuf.Len() > 0 {
351 params = append(params, keysBuf.String())
352 }
353 msgs = append(msgs, &irc.Message{Command: "JOIN", Params: params})
354 }
355
356 return msgs
357}
358
[463]359func generateIsupport(prefix *irc.Prefix, nick string, tokens []string) []*irc.Message {
360 maxTokens := maxMessageParams - 2 // 2 reserved params: nick + text
361
362 var msgs []*irc.Message
363 for len(tokens) > 0 {
364 var msgTokens []string
365 if len(tokens) > maxTokens {
366 msgTokens = tokens[:maxTokens]
367 tokens = tokens[maxTokens:]
368 } else {
369 msgTokens = tokens
370 tokens = nil
371 }
372
373 msgs = append(msgs, &irc.Message{
374 Prefix: prefix,
375 Command: irc.RPL_ISUPPORT,
376 Params: append(append([]string{nick}, msgTokens...), "are supported"),
377 })
378 }
379
380 return msgs
381}
382
[636]383func generateMOTD(prefix *irc.Prefix, nick string, motd string) []*irc.Message {
384 var msgs []*irc.Message
385 msgs = append(msgs, &irc.Message{
386 Prefix: prefix,
387 Command: irc.RPL_MOTDSTART,
388 Params: []string{nick, fmt.Sprintf("- Message of the Day -")},
389 })
390
391 for _, l := range strings.Split(motd, "\n") {
392 msgs = append(msgs, &irc.Message{
393 Prefix: prefix,
394 Command: irc.RPL_MOTD,
395 Params: []string{nick, l},
396 })
397 }
398
399 msgs = append(msgs, &irc.Message{
400 Prefix: prefix,
401 Command: irc.RPL_ENDOFMOTD,
402 Params: []string{nick, "End of /MOTD command."},
403 })
404
405 return msgs
406}
407
[350]408type joinSorter struct {
409 channels []string
410 keys []string
411}
412
413func (js *joinSorter) Len() int {
414 return len(js.channels)
415}
416
417func (js *joinSorter) Less(i, j int) bool {
418 if (js.keys[i] != "") != (js.keys[j] != "") {
419 // Only one of the channels has a key
420 return js.keys[i] != ""
421 }
422 return js.channels[i] < js.channels[j]
423}
424
425func (js *joinSorter) Swap(i, j int) {
426 js.channels[i], js.channels[j] = js.channels[j], js.channels[i]
427 js.keys[i], js.keys[j] = js.keys[j], js.keys[i]
428}
[392]429
430// parseCTCPMessage parses a CTCP message. CTCP is defined in
431// https://tools.ietf.org/html/draft-oakley-irc-ctcp-02
432func parseCTCPMessage(msg *irc.Message) (cmd string, params string, ok bool) {
433 if (msg.Command != "PRIVMSG" && msg.Command != "NOTICE") || len(msg.Params) < 2 {
434 return "", "", false
435 }
436 text := msg.Params[1]
437
438 if !strings.HasPrefix(text, "\x01") {
439 return "", "", false
440 }
441 text = strings.Trim(text, "\x01")
442
443 words := strings.SplitN(text, " ", 2)
444 cmd = strings.ToUpper(words[0])
445 if len(words) > 1 {
446 params = words[1]
447 }
448
449 return cmd, params, true
450}
[478]451
452type casemapping func(string) string
453
454func casemapNone(name string) string {
455 return name
456}
457
458// CasemapASCII of name is the canonical representation of name according to the
459// ascii casemapping.
460func casemapASCII(name string) string {
[492]461 nameBytes := []byte(name)
462 for i, r := range nameBytes {
[478]463 if 'A' <= r && r <= 'Z' {
[492]464 nameBytes[i] = r + 'a' - 'A'
[478]465 }
466 }
[492]467 return string(nameBytes)
[478]468}
469
470// casemapRFC1459 of name is the canonical representation of name according to the
471// rfc1459 casemapping.
472func casemapRFC1459(name string) string {
[492]473 nameBytes := []byte(name)
474 for i, r := range nameBytes {
[478]475 if 'A' <= r && r <= 'Z' {
[492]476 nameBytes[i] = r + 'a' - 'A'
[478]477 } else if r == '{' {
[492]478 nameBytes[i] = '['
[478]479 } else if r == '}' {
[492]480 nameBytes[i] = ']'
[478]481 } else if r == '\\' {
[492]482 nameBytes[i] = '|'
[478]483 } else if r == '~' {
[492]484 nameBytes[i] = '^'
[478]485 }
486 }
[492]487 return string(nameBytes)
[478]488}
489
490// casemapRFC1459Strict of name is the canonical representation of name
491// according to the rfc1459-strict casemapping.
492func casemapRFC1459Strict(name string) string {
[492]493 nameBytes := []byte(name)
494 for i, r := range nameBytes {
[478]495 if 'A' <= r && r <= 'Z' {
[492]496 nameBytes[i] = r + 'a' - 'A'
[478]497 } else if r == '{' {
[492]498 nameBytes[i] = '['
[478]499 } else if r == '}' {
[492]500 nameBytes[i] = ']'
[478]501 } else if r == '\\' {
[492]502 nameBytes[i] = '|'
[478]503 }
504 }
[492]505 return string(nameBytes)
[478]506}
507
508func parseCasemappingToken(tokenValue string) (casemap casemapping, ok bool) {
509 switch tokenValue {
510 case "ascii":
511 casemap = casemapASCII
512 case "rfc1459":
513 casemap = casemapRFC1459
514 case "rfc1459-strict":
515 casemap = casemapRFC1459Strict
516 default:
517 return nil, false
518 }
519 return casemap, true
520}
521
522func partialCasemap(higher casemapping, name string) string {
[492]523 nameFullyCM := []byte(higher(name))
524 nameBytes := []byte(name)
525 for i, r := range nameBytes {
526 if !('A' <= r && r <= 'Z') && !('a' <= r && r <= 'z') {
527 nameBytes[i] = nameFullyCM[i]
[478]528 }
529 }
[492]530 return string(nameBytes)
[478]531}
532
533type casemapMap struct {
534 innerMap map[string]casemapEntry
535 casemap casemapping
536}
537
538type casemapEntry struct {
539 originalKey string
540 value interface{}
541}
542
543func newCasemapMap(size int) casemapMap {
544 return casemapMap{
545 innerMap: make(map[string]casemapEntry, size),
546 casemap: casemapNone,
547 }
548}
549
550func (cm *casemapMap) OriginalKey(name string) (key string, ok bool) {
551 entry, ok := cm.innerMap[cm.casemap(name)]
552 if !ok {
553 return "", false
554 }
555 return entry.originalKey, true
556}
557
558func (cm *casemapMap) Has(name string) bool {
559 _, ok := cm.innerMap[cm.casemap(name)]
560 return ok
561}
562
563func (cm *casemapMap) Len() int {
564 return len(cm.innerMap)
565}
566
567func (cm *casemapMap) SetValue(name string, value interface{}) {
568 nameCM := cm.casemap(name)
569 entry, ok := cm.innerMap[nameCM]
570 if !ok {
571 cm.innerMap[nameCM] = casemapEntry{
572 originalKey: name,
573 value: value,
574 }
575 return
576 }
577 entry.value = value
578 cm.innerMap[nameCM] = entry
579}
580
581func (cm *casemapMap) Delete(name string) {
582 delete(cm.innerMap, cm.casemap(name))
583}
584
585func (cm *casemapMap) SetCasemapping(newCasemap casemapping) {
586 cm.casemap = newCasemap
587 newInnerMap := make(map[string]casemapEntry, len(cm.innerMap))
588 for _, entry := range cm.innerMap {
589 newInnerMap[cm.casemap(entry.originalKey)] = entry
590 }
591 cm.innerMap = newInnerMap
592}
593
594type upstreamChannelCasemapMap struct{ casemapMap }
595
596func (cm *upstreamChannelCasemapMap) Value(name string) *upstreamChannel {
597 entry, ok := cm.innerMap[cm.casemap(name)]
598 if !ok {
599 return nil
600 }
601 return entry.value.(*upstreamChannel)
602}
603
604type channelCasemapMap struct{ casemapMap }
605
606func (cm *channelCasemapMap) Value(name string) *Channel {
607 entry, ok := cm.innerMap[cm.casemap(name)]
608 if !ok {
609 return nil
610 }
611 return entry.value.(*Channel)
612}
613
614type membershipsCasemapMap struct{ casemapMap }
615
616func (cm *membershipsCasemapMap) Value(name string) *memberships {
617 entry, ok := cm.innerMap[cm.casemap(name)]
618 if !ok {
619 return nil
620 }
621 return entry.value.(*memberships)
622}
623
[480]624type deliveredCasemapMap struct{ casemapMap }
[478]625
[480]626func (cm *deliveredCasemapMap) Value(name string) deliveredClientMap {
[478]627 entry, ok := cm.innerMap[cm.casemap(name)]
628 if !ok {
629 return nil
630 }
[480]631 return entry.value.(deliveredClientMap)
[478]632}
[498]633
634func isWordBoundary(r rune) bool {
635 switch r {
636 case '-', '_', '|':
637 return false
638 case '\u00A0':
639 return true
640 default:
641 return !unicode.IsLetter(r) && !unicode.IsNumber(r)
642 }
643}
644
645func isHighlight(text, nick string) bool {
646 for {
647 i := strings.Index(text, nick)
648 if i < 0 {
649 return false
650 }
651
652 // Detect word boundaries
653 var left, right rune
654 if i > 0 {
655 left, _ = utf8.DecodeLastRuneInString(text[:i])
656 }
657 if i < len(text) {
658 right, _ = utf8.DecodeRuneInString(text[i+len(nick):])
659 }
660 if isWordBoundary(left) && isWordBoundary(right) {
661 return true
662 }
663
664 text = text[i+len(nick):]
665 }
666}
[516]667
668// parseChatHistoryBound parses the given CHATHISTORY parameter as a bound.
669// The zero time is returned on error.
670func parseChatHistoryBound(param string) time.Time {
671 parts := strings.SplitN(param, "=", 2)
672 if len(parts) != 2 {
673 return time.Time{}
674 }
675 switch parts[0] {
676 case "timestamp":
677 timestamp, err := time.Parse(serverTimeLayout, parts[1])
678 if err != nil {
679 return time.Time{}
680 }
681 return timestamp
682 default:
683 return time.Time{}
684 }
685}
[660]686
687type whoxInfo struct {
688 Token string
689 Username string
690 Hostname string
691 Server string
692 Nickname string
693 Flags string
694 Account string
695 Realname string
696}
697
698func generateWHOXReply(prefix *irc.Prefix, nick, fields string, info *whoxInfo) *irc.Message {
699 if fields == "" {
700 return &irc.Message{
701 Prefix: prefix,
702 Command: irc.RPL_WHOREPLY,
703 Params: []string{nick, "*", info.Username, info.Hostname, info.Server, info.Nickname, info.Flags, "0 " + info.Realname},
704 }
705 }
706
707 fieldSet := make(map[byte]bool)
708 for i := 0; i < len(fields); i++ {
709 fieldSet[fields[i]] = true
710 }
711
712 var params []string
713 if fieldSet['t'] {
714 params = append(params, info.Token)
715 }
716 if fieldSet['c'] {
717 params = append(params, "*")
718 }
719 if fieldSet['u'] {
720 params = append(params, info.Username)
721 }
722 if fieldSet['i'] {
723 params = append(params, "255.255.255.255")
724 }
725 if fieldSet['h'] {
726 params = append(params, info.Hostname)
727 }
728 if fieldSet['s'] {
729 params = append(params, info.Server)
730 }
731 if fieldSet['n'] {
732 params = append(params, info.Nickname)
733 }
734 if fieldSet['f'] {
735 params = append(params, info.Flags)
736 }
737 if fieldSet['d'] {
738 params = append(params, "0")
739 }
740 if fieldSet['l'] { // idle time
741 params = append(params, "0")
742 }
743 if fieldSet['a'] {
744 account := "0" // WHOX uses "0" to mean "no account"
745 if info.Account != "" && info.Account != "*" {
746 account = info.Account
747 }
748 params = append(params, account)
749 }
750 if fieldSet['o'] {
751 params = append(params, "0")
752 }
753 if fieldSet['r'] {
754 params = append(params, info.Realname)
755 }
756
757 return &irc.Message{
758 Prefix: prefix,
759 Command: rpl_whospcrpl,
760 Params: append([]string{nick}, params...),
761 }
762}
Note: See TracBrowser for help on using the repository browser.