Browse Source

implement draft/multiline

tags/v2.0.0-rc1
Shivaram Lingamneni 4 years ago
parent
commit
358c4b7d81
14 changed files with 519 additions and 180 deletions
  1. 6
    0
      gencapdefs.py
  2. 4
    0
      irc/caps/constants.go
  3. 6
    1
      irc/caps/defs.go
  4. 13
    3
      irc/channel.go
  5. 69
    3
      irc/client.go
  6. 29
    0
      irc/client_test.go
  7. 17
    4
      irc/commands.go
  8. 60
    0
      irc/config.go
  9. 178
    98
      irc/handlers.go
  10. 6
    0
      irc/help.go
  11. 54
    35
      irc/responsebuffer.go
  12. 1
    34
      irc/server.go
  13. 71
    2
      irc/utils/text.go
  14. 5
    0
      oragono.yaml

+ 6
- 0
gencapdefs.py View File

@@ -177,6 +177,12 @@ CAPDEFS = [
177 177
         url="https://oragono.io/nope",
178 178
         standard="Oragono vendor",
179 179
     ),
180
+    CapDef(
181
+        identifier="Multiline",
182
+        name="draft/multiline",
183
+        url="https://github.com/ircv3/ircv3-specifications/pull/398",
184
+        standard="Proposed IRCv3",
185
+    ),
180 186
 ]
181 187
 
182 188
 def validate_defs():

+ 4
- 0
irc/caps/constants.go View File

@@ -55,6 +55,10 @@ const (
55 55
 	// LabelTagName is the tag name used for the labeled-response spec.
56 56
 	// https://ircv3.net/specs/extensions/labeled-response.html
57 57
 	LabelTagName = "draft/label"
58
+	// More draft names associated with draft/multiline:
59
+	MultilineBatchType = "draft/multiline"
60
+	MultilineConcatTag = "draft/multiline-concat"
61
+	MultilineFmsgidTag = "draft/fmsgid"
58 62
 )
59 63
 
60 64
 func init() {

+ 6
- 1
irc/caps/defs.go View File

@@ -7,7 +7,7 @@ package caps
7 7
 
8 8
 const (
9 9
 	// number of recognized capabilities:
10
-	numCapabs = 27
10
+	numCapabs = 28
11 11
 	// length of the uint64 array that represents the bitset:
12 12
 	bitsetLen = 1
13 13
 )
@@ -53,6 +53,10 @@ const (
53 53
 	// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
54 54
 	Languages Capability = iota
55 55
 
56
+	// Multiline is the Proposed IRCv3 capability named "draft/multiline":
57
+	// https://github.com/ircv3/ircv3-specifications/pull/398
58
+	Multiline Capability = iota
59
+
56 60
 	// Rename is the proposed IRCv3 capability named "draft/rename":
57 61
 	// https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md
58 62
 	Rename Capability = iota
@@ -135,6 +139,7 @@ var (
135 139
 		"draft/event-playback",
136 140
 		"draft/labeled-response-0.2",
137 141
 		"draft/languages",
142
+		"draft/multiline",
138 143
 		"draft/rename",
139 144
 		"draft/resume-0.5",
140 145
 		"draft/setname",

+ 13
- 3
irc/channel.go View File

@@ -1042,7 +1042,7 @@ func (channel *Channel) CanSpeak(client *Client) bool {
1042 1042
 	return true
1043 1043
 }
1044 1044
 
1045
-func msgCommandToHistType(server *Server, command string) (history.ItemType, error) {
1045
+func msgCommandToHistType(command string) (history.ItemType, error) {
1046 1046
 	switch command {
1047 1047
 	case "PRIVMSG":
1048 1048
 		return history.Privmsg, nil
@@ -1051,13 +1051,23 @@ func msgCommandToHistType(server *Server, command string) (history.ItemType, err
1051 1051
 	case "TAGMSG":
1052 1052
 		return history.Tagmsg, nil
1053 1053
 	default:
1054
-		server.logger.Error("internal", "unrecognized messaging command", command)
1055 1054
 		return history.ItemType(0), errInvalidParams
1056 1055
 	}
1057 1056
 }
1058 1057
 
1058
+func histTypeToMsgCommand(t history.ItemType) string {
1059
+	switch t {
1060
+	case history.Notice:
1061
+		return "NOTICE"
1062
+	case history.Tagmsg:
1063
+		return "TAGMSG"
1064
+	default:
1065
+		return "PRIVMSG"
1066
+	}
1067
+}
1068
+
1059 1069
 func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mode, clientOnlyTags map[string]string, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
1060
-	histType, err := msgCommandToHistType(channel.server, command)
1070
+	histType, err := msgCommandToHistType(command)
1061 1071
 	if err != nil {
1062 1072
 		return
1063 1073
 	}

+ 69
- 3
irc/client.go View File

@@ -107,6 +107,8 @@ type Session struct {
107 107
 	fakelag   Fakelag
108 108
 	destroyed uint32
109 109
 
110
+	batchCounter uint32
111
+
110 112
 	quitMessage string
111 113
 
112 114
 	capabilities caps.Set
@@ -119,6 +121,18 @@ type Session struct {
119 121
 	resumeID         string
120 122
 	resumeDetails    *ResumeDetails
121 123
 	zncPlaybackTimes *zncPlaybackTimes
124
+
125
+	batch MultilineBatch
126
+}
127
+
128
+// MultilineBatch tracks the state of a client-to-server multiline batch.
129
+type MultilineBatch struct {
130
+	label         string // this is the first param to BATCH (the "reference tag")
131
+	command       string
132
+	target        string
133
+	responseLabel string // this is the value of the labeled-response tag sent with BATCH
134
+	message       utils.SplitMessage
135
+	tags          map[string]string
122 136
 }
123 137
 
124 138
 // sets the session quit message, if there isn't one already
@@ -170,6 +184,15 @@ func (session *Session) HasHistoryCaps() bool {
170 184
 	return session.capabilities.Has(caps.ZNCPlayback)
171 185
 }
172 186
 
187
+// generates a batch ID. the uniqueness requirements for this are fairly weak:
188
+// any two batch IDs that are active concurrently (either through interleaving
189
+// or nesting) on an individual session connection need to be unique.
190
+// this allows ~4 billion such batches which should be fine.
191
+func (session *Session) generateBatchID() string {
192
+	id := atomic.AddUint32(&session.batchCounter, 1)
193
+	return strconv.Itoa(int(id))
194
+}
195
+
173 196
 // WhoWas is the subset of client details needed to answer a WHOWAS query
174 197
 type WhoWas struct {
175 198
 	nick           string
@@ -530,6 +553,19 @@ func (client *Client) run(session *Session, proxyLine string) {
530 553
 			break
531 554
 		}
532 555
 
556
+		// "Clients MUST NOT send messages other than PRIVMSG while a multiline batch is open."
557
+		// in future we might want to whitelist some commands that are allowed here, like PONG
558
+		if session.batch.label != "" && msg.Command != "BATCH" {
559
+			_, batchTag := msg.GetTag("batch")
560
+			if batchTag != session.batch.label {
561
+				if msg.Command != "NOTICE" {
562
+					session.Send(nil, client.server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Incorrect batch tag sent"))
563
+				}
564
+				session.batch = MultilineBatch{}
565
+				continue
566
+			}
567
+		}
568
+
533 569
 		cmd, exists := Commands[msg.Command]
534 570
 		if !exists {
535 571
 			if len(msg.Command) > 0 {
@@ -1186,11 +1222,17 @@ func (client *Client) destroy(session *Session) {
1186 1222
 // SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client.
1187 1223
 // Adds account-tag to the line as well.
1188 1224
 func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask, accountName string, tags map[string]string, command, target string, message utils.SplitMessage) {
1189
-	if session.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
1225
+	if message.Is512() || session.capabilities.Has(caps.MaxLine) {
1190 1226
 		session.sendFromClientInternal(blocking, message.Time, message.Msgid, nickmask, accountName, tags, command, target, message.Message)
1191 1227
 	} else {
1192
-		for _, messagePair := range message.Wrapped {
1193
-			session.sendFromClientInternal(blocking, message.Time, messagePair.Msgid, nickmask, accountName, tags, command, target, messagePair.Message)
1228
+		if message.IsMultiline() && session.capabilities.Has(caps.Multiline) {
1229
+			for _, msg := range session.composeMultilineBatch(nickmask, accountName, tags, command, target, message) {
1230
+				session.SendRawMessage(msg, blocking)
1231
+			}
1232
+		} else {
1233
+			for _, messagePair := range message.Wrapped {
1234
+				session.sendFromClientInternal(blocking, message.Time, messagePair.Msgid, nickmask, accountName, tags, command, target, messagePair.Message)
1235
+			}
1194 1236
 		}
1195 1237
 	}
1196 1238
 }
@@ -1222,6 +1264,30 @@ func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Ti
1222 1264
 	return session.SendRawMessage(msg, blocking)
1223 1265
 }
1224 1266
 
1267
+func (session *Session) composeMultilineBatch(fromNickMask, fromAccount string, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.IrcMessage) {
1268
+	batchID := session.generateBatchID()
1269
+	batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType)
1270
+	batchStart.SetTag("time", message.Time.Format(IRCv3TimestampFormat))
1271
+	batchStart.SetTag("msgid", message.Msgid)
1272
+	if session.capabilities.Has(caps.AccountTag) && fromAccount != "*" {
1273
+		batchStart.SetTag("account", fromAccount)
1274
+	}
1275
+	result = append(result, batchStart)
1276
+
1277
+	for _, msg := range message.Wrapped {
1278
+		message := ircmsg.MakeMessage(nil, fromNickMask, command, target, msg.Message)
1279
+		message.SetTag("batch", batchID)
1280
+		message.SetTag(caps.MultilineFmsgidTag, msg.Msgid)
1281
+		if msg.Concat {
1282
+			message.SetTag(caps.MultilineConcatTag, "")
1283
+		}
1284
+		result = append(result, message)
1285
+	}
1286
+
1287
+	result = append(result, ircmsg.MakeMessage(nil, fromNickMask, "BATCH", "-"+batchID))
1288
+	return
1289
+}
1290
+
1225 1291
 var (
1226 1292
 	// these are all the output commands that MUST have their last param be a trailing.
1227 1293
 	// this is needed because dumb clients like to treat trailing params separately from the

+ 29
- 0
irc/client_test.go View File

@@ -0,0 +1,29 @@
1
+// Copyright (c) 2019 Shivaram Lingamneni
2
+// released under the MIT license
3
+
4
+package irc
5
+
6
+import (
7
+	"testing"
8
+)
9
+
10
+func TestGenerateBatchID(t *testing.T) {
11
+	var session Session
12
+	s := make(StringSet)
13
+
14
+	count := 100000
15
+	for i := 0; i < count; i++ {
16
+		s.Add(session.generateBatchID())
17
+	}
18
+
19
+	if len(s) != count {
20
+		t.Error("duplicate batch ID detected")
21
+	}
22
+}
23
+
24
+func BenchmarkGenerateBatchID(b *testing.B) {
25
+	var session Session
26
+	for i := 0; i < b.N; i++ {
27
+		session.generateBatchID()
28
+	}
29
+}

+ 17
- 4
irc/commands.go View File

@@ -16,6 +16,7 @@ type Command struct {
16 16
 	oper            bool
17 17
 	usablePreReg    bool
18 18
 	leaveClientIdle bool // if true, leaves the client active time alone
19
+	allowedInBatch  bool // allowed in client-to-server batches
19 20
 	minParams       int
20 21
 	capabs          []string
21 22
 }
@@ -44,6 +45,11 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
44 45
 			rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, rb.target.t("Not enough parameters"))
45 46
 			return false
46 47
 		}
48
+		if session.batch.label != "" && !cmd.allowedInBatch {
49
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Command not allowed during a multiline batch"))
50
+			session.batch = MultilineBatch{}
51
+			return false
52
+		}
47 53
 
48 54
 		return cmd.handler(server, client, msg, rb)
49 55
 	}()
@@ -92,6 +98,11 @@ func init() {
92 98
 			handler:   awayHandler,
93 99
 			minParams: 0,
94 100
 		},
101
+		"BATCH": {
102
+			handler:        batchHandler,
103
+			minParams:      1,
104
+			allowedInBatch: true,
105
+		},
95 106
 		"BRB": {
96 107
 			handler:   brbHandler,
97 108
 			minParams: 0,
@@ -193,8 +204,9 @@ func init() {
193 204
 			minParams:    1,
194 205
 		},
195 206
 		"NOTICE": {
196
-			handler:   messageHandler,
197
-			minParams: 2,
207
+			handler:        messageHandler,
208
+			minParams:      2,
209
+			allowedInBatch: true,
198 210
 		},
199 211
 		"NPC": {
200 212
 			handler:   npcHandler,
@@ -230,8 +242,9 @@ func init() {
230 242
 			leaveClientIdle: true,
231 243
 		},
232 244
 		"PRIVMSG": {
233
-			handler:   messageHandler,
234
-			minParams: 2,
245
+			handler:        messageHandler,
246
+			minParams:      2,
247
+			allowedInBatch: true,
235 248
 		},
236 249
 		"RENAME": {
237 250
 			handler:   renameHandler,

+ 60
- 0
irc/config.go View File

@@ -234,6 +234,10 @@ type Limits struct {
234 234
 	TopicLen             int           `yaml:"topiclen"`
235 235
 	WhowasEntries        int           `yaml:"whowas-entries"`
236 236
 	RegistrationMessages int           `yaml:"registration-messages"`
237
+	Multiline            struct {
238
+		MaxBytes int `yaml:"max-bytes"`
239
+		MaxLines int `yaml:"max-lines"`
240
+	}
237 241
 }
238 242
 
239 243
 // STSConfig controls the STS configuration/
@@ -683,6 +687,18 @@ func LoadConfig(filename string) (config *Config, err error) {
683 687
 		config.Server.capValues[caps.MaxLine] = strconv.Itoa(config.Limits.LineLen.Rest)
684 688
 	}
685 689
 
690
+	if config.Limits.Multiline.MaxBytes <= 0 {
691
+		config.Server.supportedCaps.Disable(caps.Multiline)
692
+	} else {
693
+		var multilineCapValue string
694
+		if config.Limits.Multiline.MaxLines == 0 {
695
+			multilineCapValue = fmt.Sprintf("max-bytes=%d", config.Limits.Multiline.MaxBytes)
696
+		} else {
697
+			multilineCapValue = fmt.Sprintf("max-bytes=%d,max-lines=%d", config.Limits.Multiline.MaxBytes, config.Limits.Multiline.MaxLines)
698
+		}
699
+		config.Server.capValues[caps.Multiline] = multilineCapValue
700
+	}
701
+
686 702
 	if !config.Accounts.Bouncer.Enabled {
687 703
 		config.Server.supportedCaps.Disable(caps.Bouncer)
688 704
 	}
@@ -869,3 +885,47 @@ func LoadConfig(filename string) (config *Config, err error) {
869 885
 
870 886
 	return config, nil
871 887
 }
888
+
889
+// Diff returns changes in supported caps across a rehash.
890
+func (config *Config) Diff(oldConfig *Config) (addedCaps, removedCaps *caps.Set) {
891
+	addedCaps = caps.NewSet()
892
+	removedCaps = caps.NewSet()
893
+	if oldConfig == nil {
894
+		return
895
+	}
896
+
897
+	if oldConfig.Server.capValues[caps.Languages] != config.Server.capValues[caps.Languages] {
898
+		// XXX updated caps get a DEL line and then a NEW line with the new value
899
+		addedCaps.Add(caps.Languages)
900
+		removedCaps.Add(caps.Languages)
901
+	}
902
+
903
+	if !oldConfig.Accounts.AuthenticationEnabled && config.Accounts.AuthenticationEnabled {
904
+		addedCaps.Add(caps.SASL)
905
+	} else if oldConfig.Accounts.AuthenticationEnabled && !config.Accounts.AuthenticationEnabled {
906
+		removedCaps.Add(caps.SASL)
907
+	}
908
+
909
+	if !oldConfig.Accounts.Bouncer.Enabled && config.Accounts.Bouncer.Enabled {
910
+		addedCaps.Add(caps.Bouncer)
911
+	} else if oldConfig.Accounts.Bouncer.Enabled && !config.Accounts.Bouncer.Enabled {
912
+		removedCaps.Add(caps.Bouncer)
913
+	}
914
+
915
+	if oldConfig.Limits.Multiline.MaxBytes != 0 && config.Limits.Multiline.MaxBytes == 0 {
916
+		removedCaps.Add(caps.Multiline)
917
+	} else if oldConfig.Limits.Multiline.MaxBytes == 0 && config.Limits.Multiline.MaxBytes != 0 {
918
+		addedCaps.Add(caps.Multiline)
919
+	} else if oldConfig.Limits.Multiline != config.Limits.Multiline {
920
+		removedCaps.Add(caps.Multiline)
921
+		addedCaps.Add(caps.Multiline)
922
+	}
923
+
924
+	if oldConfig.Server.STS.Enabled != config.Server.STS.Enabled || oldConfig.Server.capValues[caps.STS] != config.Server.capValues[caps.STS] {
925
+		// XXX: STS is always removed by CAP NEW sts=duration=0, not CAP DEL
926
+		// so the appropriate notify is always a CAP NEW; put it in addedCaps for any change
927
+		addedCaps.Add(caps.STS)
928
+	}
929
+
930
+	return
931
+}

+ 178
- 98
irc/handlers.go View File

@@ -509,6 +509,59 @@ func awayHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
509 509
 	return false
510 510
 }
511 511
 
512
+// BATCH {+,-}reference-tag type [params...]
513
+func batchHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
514
+	tag := msg.Params[0]
515
+	fail := false
516
+	sendErrors := rb.session.batch.command != "NOTICE"
517
+	if len(tag) == 0 {
518
+		fail = true
519
+	} else if tag[0] == '+' {
520
+		if rb.session.batch.label != "" || msg.Params[1] != caps.MultilineBatchType {
521
+			fail = true
522
+		} else {
523
+			rb.session.batch.label = tag[1:]
524
+			rb.session.batch.tags = msg.ClientOnlyTags()
525
+			if len(msg.Params) == 2 {
526
+				fail = true
527
+			} else {
528
+				rb.session.batch.target = msg.Params[2]
529
+				// save the response label for later
530
+				// XXX changing the label inside a handler is a bit dodgy, but it works here
531
+				// because there's no way we could have triggered a flush up to this point
532
+				rb.session.batch.responseLabel = rb.Label
533
+				rb.Label = ""
534
+			}
535
+		}
536
+	} else if tag[0] == '-' {
537
+		if rb.session.batch.label == "" || rb.session.batch.label != tag[1:] {
538
+			fail = true
539
+		} else if rb.session.batch.message.LenLines() == 0 {
540
+			fail = true
541
+		} else {
542
+			batch := rb.session.batch
543
+			rb.session.batch = MultilineBatch{}
544
+			batch.message.Time = time.Now().UTC()
545
+			histType, err := msgCommandToHistType(batch.command)
546
+			if err != nil {
547
+				histType = history.Privmsg
548
+			}
549
+			// see previous caution about modifying ResponseBuffer.Label
550
+			rb.Label = batch.responseLabel
551
+			dispatchMessageToTarget(client, batch.tags, histType, batch.target, batch.message, rb)
552
+		}
553
+	}
554
+
555
+	if fail {
556
+		rb.session.batch = MultilineBatch{}
557
+		if sendErrors {
558
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Invalid multiline batch"))
559
+		}
560
+	}
561
+
562
+	return false
563
+}
564
+
512 565
 // BRB [message]
513 566
 func brbHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
514 567
 	success, duration := client.brbTimer.Enable()
@@ -665,11 +718,6 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
665 718
 	defer func() {
666 719
 		// successful responses are sent as a chathistory or history batch
667 720
 		if success && 0 < len(items) {
668
-			batchType := "chathistory"
669
-			if rb.session.capabilities.Has(caps.EventPlayback) {
670
-				batchType = "history"
671
-			}
672
-			rb.ForceBatchStart(batchType, true)
673 721
 			if channel == nil {
674 722
 				client.replayPrivmsgHistory(rb, items, true)
675 723
 			} else {
@@ -2019,15 +2067,44 @@ func nickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
2019 2067
 	return false
2020 2068
 }
2021 2069
 
2070
+// helper to store a batched PRIVMSG in the session object
2071
+func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.IrcMessage, batchTag string, histType history.ItemType, rb *ResponseBuffer) {
2072
+	// sanity checks. batch tag correctness was already checked and is redundant here
2073
+	// as a defensive measure. TAGMSG is checked without an error message: "don't eat paste"
2074
+	if batchTag != rb.session.batch.label || histType == history.Tagmsg || len(msg.Params) == 1 || msg.Params[1] == "" {
2075
+		return
2076
+	}
2077
+	rb.session.batch.command = msg.Command
2078
+	isConcat, _ := msg.GetTag(caps.MultilineConcatTag)
2079
+	rb.session.batch.message.Append(msg.Params[1], isConcat)
2080
+	config := server.Config()
2081
+	if config.Limits.Multiline.MaxBytes < rb.session.batch.message.LenBytes() {
2082
+		if histType != history.Notice {
2083
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_MAX_BYTES", strconv.Itoa(config.Limits.Multiline.MaxBytes))
2084
+		}
2085
+		rb.session.batch = MultilineBatch{}
2086
+	} else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() {
2087
+		if histType != history.Notice {
2088
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_MAX_LINES", strconv.Itoa(config.Limits.Multiline.MaxLines))
2089
+		}
2090
+		rb.session.batch = MultilineBatch{}
2091
+	}
2092
+}
2093
+
2022 2094
 // NOTICE <target>{,<target>} <message>
2023 2095
 // PRIVMSG <target>{,<target>} <message>
2024 2096
 // TAGMSG <target>{,<target>}
2025 2097
 func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2026
-	histType, err := msgCommandToHistType(server, msg.Command)
2098
+	histType, err := msgCommandToHistType(msg.Command)
2027 2099
 	if err != nil {
2028 2100
 		return false
2029 2101
 	}
2030 2102
 
2103
+	if isBatched, batchTag := msg.GetTag("batch"); isBatched {
2104
+		absorbBatchedMessage(server, client, msg, batchTag, histType, rb)
2105
+		return false
2106
+	}
2107
+
2031 2108
 	cnick := client.Nick()
2032 2109
 	clientOnlyTags := msg.ClientOnlyTags()
2033 2110
 	if histType == history.Tagmsg && len(clientOnlyTags) == 0 {
@@ -2040,116 +2117,125 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
2040 2117
 	if len(msg.Params) > 1 {
2041 2118
 		message = msg.Params[1]
2042 2119
 	}
2120
+	if histType != history.Tagmsg && message == "" {
2121
+		rb.Add(nil, server.name, ERR_NOTEXTTOSEND, cnick, client.t("No text to send"))
2122
+		return false
2123
+	}
2043 2124
 
2044
-	// note that error replies are never sent for NOTICE
2045
-
2046
-	if client.isTor && isRestrictedCTCPMessage(message) {
2125
+	if client.isTor && utils.IsRestrictedCTCPMessage(message) {
2126
+		// note that error replies are never sent for NOTICE
2047 2127
 		if histType != history.Notice {
2048
-			rb.Add(nil, server.name, "NOTICE", client.t("CTCP messages are disabled over Tor"))
2128
+			rb.Notice(client.t("CTCP messages are disabled over Tor"))
2049 2129
 		}
2050 2130
 		return false
2051 2131
 	}
2052 2132
 
2053 2133
 	for i, targetString := range targets {
2054
-		// each target gets distinct msgids
2055
-		splitMsg := utils.MakeSplitMessage(message, !rb.session.capabilities.Has(caps.MaxLine))
2056
-
2057 2134
 		// max of four targets per privmsg
2058
-		if i > maxTargets-1 {
2135
+		if i == maxTargets {
2059 2136
 			break
2060 2137
 		}
2061
-		prefixes, targetString := modes.SplitChannelMembershipPrefixes(targetString)
2062
-		lowestPrefix := modes.GetLowestChannelModePrefix(prefixes)
2138
+		// each target gets distinct msgids
2139
+		splitMsg := utils.MakeSplitMessage(message, !rb.session.capabilities.Has(caps.MaxLine))
2140
+		dispatchMessageToTarget(client, clientOnlyTags, histType, targetString, splitMsg, rb)
2141
+	}
2142
+	return false
2143
+}
2063 2144
 
2064
-		if len(targetString) == 0 {
2065
-			continue
2066
-		} else if targetString[0] == '#' {
2067
-			channel := server.channels.Get(targetString)
2068
-			if channel == nil {
2069
-				if histType != history.Notice {
2070
-					rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, cnick, utils.SafeErrorParam(targetString), client.t("No such channel"))
2071
-				}
2072
-				continue
2145
+func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, target string, message utils.SplitMessage, rb *ResponseBuffer) {
2146
+	server := client.server
2147
+	command := histTypeToMsgCommand(histType)
2148
+
2149
+	prefixes, target := modes.SplitChannelMembershipPrefixes(target)
2150
+	lowestPrefix := modes.GetLowestChannelModePrefix(prefixes)
2151
+
2152
+	if len(target) == 0 {
2153
+		return
2154
+	} else if target[0] == '#' {
2155
+		channel := server.channels.Get(target)
2156
+		if channel == nil {
2157
+			if histType != history.Notice {
2158
+				rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
2073 2159
 			}
2074
-			channel.SendSplitMessage(msg.Command, lowestPrefix, clientOnlyTags, client, splitMsg, rb)
2075
-		} else {
2076
-			// NOTICE and TAGMSG to services are ignored
2077
-			if histType == history.Privmsg {
2078
-				lowercaseTarget := strings.ToLower(targetString)
2079
-				if service, isService := OragonoServices[lowercaseTarget]; isService {
2080
-					servicePrivmsgHandler(service, server, client, message, rb)
2081
-					continue
2082
-				} else if _, isZNC := zncHandlers[lowercaseTarget]; isZNC {
2083
-					zncPrivmsgHandler(client, lowercaseTarget, message, rb)
2084
-					continue
2085
-				}
2160
+			return
2161
+		}
2162
+		channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb)
2163
+	} else {
2164
+		// NOTICE and TAGMSG to services are ignored
2165
+		if histType == history.Privmsg {
2166
+			lowercaseTarget := strings.ToLower(target)
2167
+			if service, isService := OragonoServices[lowercaseTarget]; isService {
2168
+				servicePrivmsgHandler(service, server, client, message.Message, rb)
2169
+				return
2170
+			} else if _, isZNC := zncHandlers[lowercaseTarget]; isZNC {
2171
+				zncPrivmsgHandler(client, lowercaseTarget, message.Message, rb)
2172
+				return
2086 2173
 			}
2174
+		}
2087 2175
 
2088
-			user := server.clients.Get(targetString)
2089
-			if user == nil {
2090
-				if histType != history.Notice {
2091
-					rb.Add(nil, server.name, ERR_NOSUCHNICK, cnick, targetString, "No such nick")
2092
-				}
2093
-				continue
2176
+		user := server.clients.Get(target)
2177
+		if user == nil {
2178
+			if histType != history.Notice {
2179
+				rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), target, "No such nick")
2094 2180
 			}
2095
-			tnick := user.Nick()
2096
-
2097
-			nickMaskString := client.NickMaskString()
2098
-			accountName := client.AccountName()
2099
-			// restrict messages appropriately when +R is set
2100
-			// intentionally make the sending user think the message went through fine
2101
-			allowedPlusR := !user.HasMode(modes.RegisteredOnly) || client.LoggedIntoAccount()
2102
-			allowedTor := !user.isTor || !isRestrictedCTCPMessage(message)
2103
-			if allowedPlusR && allowedTor {
2104
-				for _, session := range user.Sessions() {
2105
-					if histType == history.Tagmsg {
2106
-						// don't send TAGMSG at all if they don't have the tags cap
2107
-						if session.capabilities.Has(caps.MessageTags) {
2108
-							session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2109
-						}
2110
-					} else {
2111
-						session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2181
+			return
2182
+		}
2183
+		tnick := user.Nick()
2184
+
2185
+		nickMaskString := client.NickMaskString()
2186
+		accountName := client.AccountName()
2187
+		// restrict messages appropriately when +R is set
2188
+		// intentionally make the sending user think the message went through fine
2189
+		allowedPlusR := !user.HasMode(modes.RegisteredOnly) || client.LoggedIntoAccount()
2190
+		allowedTor := !user.isTor || !message.IsRestrictedCTCPMessage()
2191
+		if allowedPlusR && allowedTor {
2192
+			for _, session := range user.Sessions() {
2193
+				if histType == history.Tagmsg {
2194
+					// don't send TAGMSG at all if they don't have the tags cap
2195
+					if session.capabilities.Has(caps.MessageTags) {
2196
+						session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
2112 2197
 					}
2113
-				}
2114
-			}
2115
-			// an echo-message may need to be included in the response:
2116
-			if rb.session.capabilities.Has(caps.EchoMessage) {
2117
-				if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
2118
-					rb.AddFromClient(splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2119 2198
 				} else {
2120
-					rb.AddSplitMessageFromClient(nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2199
+					session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, tags, command, tnick, message)
2121 2200
 				}
2122 2201
 			}
2123
-			// an echo-message may need to go out to other client sessions:
2124
-			for _, session := range client.Sessions() {
2125
-				if session == rb.session {
2126
-					continue
2127
-				}
2128
-				if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
2129
-					session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2130
-				} else if histType != history.Tagmsg {
2131
-					session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2132
-				}
2202
+		}
2203
+		// an echo-message may need to be included in the response:
2204
+		if rb.session.capabilities.Has(caps.EchoMessage) {
2205
+			if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
2206
+				rb.AddFromClient(message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
2207
+			} else {
2208
+				rb.AddSplitMessageFromClient(nickMaskString, accountName, tags, command, tnick, message)
2133 2209
 			}
2134
-			if histType != history.Notice && user.Away() {
2135
-				//TODO(dan): possibly implement cooldown of away notifications to users
2136
-				rb.Add(nil, server.name, RPL_AWAY, cnick, tnick, user.AwayMessage())
2210
+		}
2211
+		// an echo-message may need to go out to other client sessions:
2212
+		for _, session := range client.Sessions() {
2213
+			if session == rb.session {
2214
+				continue
2137 2215
 			}
2138
-
2139
-			item := history.Item{
2140
-				Type:        histType,
2141
-				Message:     splitMsg,
2142
-				Nick:        nickMaskString,
2143
-				AccountName: accountName,
2216
+			if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
2217
+				session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
2218
+			} else if histType != history.Tagmsg {
2219
+				session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, tags, command, tnick, message)
2144 2220
 			}
2145
-			// add to the target's history:
2146
-			user.history.Add(item)
2147
-			// add this to the client's history as well, recording the target:
2148
-			item.Params[0] = tnick
2149
-			client.history.Add(item)
2150 2221
 		}
2222
+		if histType != history.Notice && user.Away() {
2223
+			//TODO(dan): possibly implement cooldown of away notifications to users
2224
+			rb.Add(nil, server.name, RPL_AWAY, client.Nick(), tnick, user.AwayMessage())
2225
+		}
2226
+
2227
+		item := history.Item{
2228
+			Type:        histType,
2229
+			Message:     message,
2230
+			Nick:        nickMaskString,
2231
+			AccountName: accountName,
2232
+		}
2233
+		// add to the target's history:
2234
+		user.history.Add(item)
2235
+		// add this to the client's history as well, recording the target:
2236
+		item.Params[0] = tnick
2237
+		client.history.Add(item)
2151 2238
 	}
2152
-	return false
2153 2239
 }
2154 2240
 
2155 2241
 // NPC <target> <sourcenick> <message>
@@ -2308,12 +2394,6 @@ func pongHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
2308 2394
 	return false
2309 2395
 }
2310 2396
 
2311
-func isRestrictedCTCPMessage(message string) bool {
2312
-	// block all CTCP privmsgs to Tor clients except for ACTION
2313
-	// DCC can potentially be used for deanonymization, the others for fingerprinting
2314
-	return strings.HasPrefix(message, "\x01") && !strings.HasPrefix(message, "\x01ACTION")
2315
-}
2316
-
2317 2397
 // QUIT [<reason>]
2318 2398
 func quitHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2319 2399
 	reason := "Quit"

+ 6
- 0
irc/help.go View File

@@ -120,6 +120,12 @@ http://ircv3.net/specs/extensions/sasl-3.1.html`,
120 120
 
121 121
 If [message] is sent, marks you away. If [message] is not sent, marks you no
122 122
 longer away.`,
123
+	},
124
+	"batch": {
125
+		text: `BATCH {+,-}reference-tag type [params...]
126
+
127
+BATCH initiates an IRCv3 client-to-server batch. You should never need to
128
+issue this command manually.`,
123 129
 	},
124 130
 	"brb": {
125 131
 		text: `BRB [message]

+ 54
- 35
irc/responsebuffer.go View File

@@ -66,10 +66,16 @@ func (rb *ResponseBuffer) AddMessage(msg ircmsg.IrcMessage) {
66 66
 		return
67 67
 	}
68 68
 
69
+	rb.session.setTimeTag(&msg, time.Time{})
70
+	rb.setNestedBatchTag(&msg)
71
+
72
+	rb.messages = append(rb.messages, msg)
73
+}
74
+
75
+func (rb *ResponseBuffer) setNestedBatchTag(msg *ircmsg.IrcMessage) {
69 76
 	if 0 < len(rb.nestedBatches) {
70 77
 		msg.SetTag("batch", rb.nestedBatches[len(rb.nestedBatches)-1])
71 78
 	}
72
-	rb.messages = append(rb.messages, msg)
73 79
 }
74 80
 
75 81
 // Add adds a standard new message to our queue.
@@ -112,31 +118,29 @@ func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMa
112 118
 
113 119
 // AddSplitMessageFromClient adds a new split message from a specific client to our queue.
114 120
 func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, tags map[string]string, command string, target string, message utils.SplitMessage) {
115
-	if rb.session.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
121
+	if message.Is512() || rb.session.capabilities.Has(caps.MaxLine) {
116 122
 		rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message)
117 123
 	} else {
118
-		for _, messagePair := range message.Wrapped {
119
-			rb.AddFromClient(message.Time, messagePair.Msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message)
124
+		if message.IsMultiline() && rb.session.capabilities.Has(caps.Multiline) {
125
+			batch := rb.session.composeMultilineBatch(fromNickMask, fromAccount, tags, command, target, message)
126
+			rb.setNestedBatchTag(&batch[0])
127
+			rb.setNestedBatchTag(&batch[len(batch)-1])
128
+			rb.messages = append(rb.messages, batch...)
129
+		} else {
130
+			for _, messagePair := range message.Wrapped {
131
+				rb.AddFromClient(message.Time, messagePair.Msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message)
132
+			}
120 133
 		}
121 134
 	}
122 135
 }
123 136
 
124
-// ForceBatchStart forcibly starts a batch of batch `batchType`.
125
-// Normally, Send/Flush will decide automatically whether to start a batch
126
-// of type draft/labeled-response. This allows changing the batch type
127
-// and forcing the creation of a possibly empty batch.
128
-func (rb *ResponseBuffer) ForceBatchStart(batchType string, blocking bool) {
129
-	rb.batchType = batchType
130
-	rb.sendBatchStart(blocking)
131
-}
132
-
133 137
 func (rb *ResponseBuffer) sendBatchStart(blocking bool) {
134 138
 	if rb.batchID != "" {
135 139
 		// batch already initialized
136 140
 		return
137 141
 	}
138 142
 
139
-	rb.batchID = utils.GenerateSecretToken()
143
+	rb.batchID = rb.session.generateBatchID()
140 144
 	message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, rb.batchType)
141 145
 	if rb.Label != "" {
142 146
 		message.SetTag(caps.LabelTagName, rb.Label)
@@ -157,7 +161,7 @@ func (rb *ResponseBuffer) sendBatchEnd(blocking bool) {
157 161
 // Starts a nested batch (see the ResponseBuffer struct definition for a description of
158 162
 // how this works)
159 163
 func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
160
-	batchID = utils.GenerateSecretToken()
164
+	batchID = rb.session.generateBatchID()
161 165
 	msgParams := make([]string, len(params)+2)
162 166
 	msgParams[0] = "+" + batchID
163 167
 	msgParams[1] = batchType
@@ -213,6 +217,23 @@ func (rb *ResponseBuffer) Flush(blocking bool) error {
213 217
 	return rb.flushInternal(false, blocking)
214 218
 }
215 219
 
220
+// detects whether the response buffer consists of a single, unflushed nested batch,
221
+// in which case it can be collapsed down to that batch
222
+func (rb *ResponseBuffer) isCollapsible() (result bool) {
223
+	// rb.batchID indicates that we already flushed some lines
224
+	if rb.batchID != "" || len(rb.messages) < 2 {
225
+		return false
226
+	}
227
+	first, last := rb.messages[0], rb.messages[len(rb.messages)-1]
228
+	if first.Command != "BATCH" || last.Command != "BATCH" {
229
+		return false
230
+	}
231
+	if len(first.Params) == 0 || len(first.Params[0]) == 0 || len(last.Params) == 0 || len(last.Params[0]) == 0 {
232
+		return false
233
+	}
234
+	return first.Params[0][1:] == last.Params[0][1:]
235
+}
236
+
216 237
 // flushInternal sends the contents of the buffer, either blocking or nonblocking
217 238
 // It sends the `BATCH +` message if the client supports it and it hasn't been sent already.
218 239
 // If `final` is true, it also sends `BATCH -` (if necessary).
@@ -221,30 +242,28 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
221 242
 		return nil
222 243
 	}
223 244
 
224
-	useLabel := rb.session.capabilities.Has(caps.LabeledResponse) && rb.Label != ""
225
-	// use a batch if we have a label, and we either currently have 2+ messages,
226
-	// or we are doing a Flush() and we have to assume that there will be more messages
227
-	// in the future.
228
-	startBatch := useLabel && (1 < len(rb.messages) || !final)
229
-
230
-	if startBatch {
231
-		rb.sendBatchStart(blocking)
232
-	} else if useLabel && len(rb.messages) == 0 && rb.batchID == "" && final {
233
-		// ACK message
234
-		message := ircmsg.MakeMessage(nil, rb.session.client.server.name, "ACK")
235
-		message.SetTag(caps.LabelTagName, rb.Label)
236
-		rb.session.setTimeTag(&message, time.Time{})
237
-		rb.session.SendRawMessage(message, blocking)
238
-	} else if useLabel && len(rb.messages) == 1 && rb.batchID == "" && final {
239
-		// single labeled message
240
-		rb.messages[0].SetTag(caps.LabelTagName, rb.Label)
245
+	if rb.session.capabilities.Has(caps.LabeledResponse) && rb.Label != "" {
246
+		if final && rb.isCollapsible() {
247
+			// collapse to the outermost nested batch
248
+			rb.messages[0].SetTag(caps.LabelTagName, rb.Label)
249
+		} else if !final || 2 <= len(rb.messages) {
250
+			// we either have 2+ messages, or we are doing a Flush() and have to assume
251
+			// there will be more messages in the future
252
+			rb.sendBatchStart(blocking)
253
+		} else if len(rb.messages) == 1 && rb.batchID == "" {
254
+			// single labeled message
255
+			rb.messages[0].SetTag(caps.LabelTagName, rb.Label)
256
+		} else if len(rb.messages) == 0 && rb.batchID == "" {
257
+			// ACK message
258
+			message := ircmsg.MakeMessage(nil, rb.session.client.server.name, "ACK")
259
+			message.SetTag(caps.LabelTagName, rb.Label)
260
+			rb.session.setTimeTag(&message, time.Time{})
261
+			rb.session.SendRawMessage(message, blocking)
262
+		}
241 263
 	}
242 264
 
243 265
 	// send each message out
244 266
 	for _, message := range rb.messages {
245
-		// attach server-time if needed
246
-		rb.session.setTimeTag(&message, time.Time{})
247
-
248 267
 		// attach batch ID, unless this message was part of a nested batch and is
249 268
 		// already tagged
250 269
 		if rb.batchID != "" && !message.HasTag("batch") {

+ 1
- 34
irc/server.go View File

@@ -629,39 +629,11 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
629 629
 	tlConf := &config.Server.TorListeners
630 630
 	server.torLimiter.Configure(tlConf.MaxConnections, tlConf.ThrottleDuration, tlConf.MaxConnectionsPerDuration)
631 631
 
632
-	// setup new and removed caps
633
-	addedCaps := caps.NewSet()
634
-	removedCaps := caps.NewSet()
635
-	updatedCaps := caps.NewSet()
636
-
637 632
 	// Translations
638 633
 	server.logger.Debug("server", "Regenerating HELP indexes for new languages")
639 634
 	server.helpIndexManager.GenerateIndices(config.languageManager)
640 635
 
641 636
 	if oldConfig != nil {
642
-		// cap changes
643
-		if oldConfig.Server.capValues[caps.Languages] != config.Server.capValues[caps.Languages] {
644
-			updatedCaps.Add(caps.Languages)
645
-		}
646
-
647
-		if !oldConfig.Accounts.AuthenticationEnabled && config.Accounts.AuthenticationEnabled {
648
-			addedCaps.Add(caps.SASL)
649
-		} else if oldConfig.Accounts.AuthenticationEnabled && !config.Accounts.AuthenticationEnabled {
650
-			removedCaps.Add(caps.SASL)
651
-		}
652
-
653
-		if !oldConfig.Accounts.Bouncer.Enabled && config.Accounts.Bouncer.Enabled {
654
-			addedCaps.Add(caps.Bouncer)
655
-		} else if oldConfig.Accounts.Bouncer.Enabled && !config.Accounts.Bouncer.Enabled {
656
-			removedCaps.Add(caps.Bouncer)
657
-		}
658
-
659
-		if oldConfig.Server.STS.Enabled != config.Server.STS.Enabled || oldConfig.Server.capValues[caps.STS] != config.Server.capValues[caps.STS] {
660
-			// XXX: STS is always removed by CAP NEW sts=duration=0, not CAP DEL
661
-			// so the appropriate notify is always a CAP NEW; put it in addedCaps for any change
662
-			addedCaps.Add(caps.STS)
663
-		}
664
-
665 637
 		// if certain features were enabled by rehash, we need to load the corresponding data
666 638
 		// from the store
667 639
 		if !oldConfig.Accounts.NickReservation.Enabled {
@@ -689,16 +661,11 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
689 661
 	server.SetConfig(config)
690 662
 
691 663
 	// burst new and removed caps
664
+	addedCaps, removedCaps := config.Diff(oldConfig)
692 665
 	var capBurstSessions []*Session
693 666
 	added := make(map[caps.Version][]string)
694 667
 	var removed []string
695 668
 
696
-	// updated caps get DEL'd and then NEW'd
697
-	// so, we can just add updated ones to both removed and added lists here and they'll be correctly handled
698
-	server.logger.Debug("server", "Updated Caps", strings.Join(updatedCaps.Strings(caps.Cap301, config.Server.capValues, 0), " "))
699
-	addedCaps.Union(updatedCaps)
700
-	removedCaps.Union(updatedCaps)
701
-
702 669
 	if !addedCaps.Empty() || !removedCaps.Empty() {
703 670
 		capBurstSessions = server.clients.AllWithCapsNotify()
704 671
 

+ 71
- 2
irc/utils/text.go View File

@@ -3,8 +3,17 @@
3 3
 
4 4
 package utils
5 5
 
6
-import "bytes"
7
-import "time"
6
+import (
7
+	"bytes"
8
+	"strings"
9
+	"time"
10
+)
11
+
12
+func IsRestrictedCTCPMessage(message string) bool {
13
+	// block all CTCP privmsgs to Tor clients except for ACTION
14
+	// DCC can potentially be used for deanonymization, the others for fingerprinting
15
+	return strings.HasPrefix(message, "\x01") && !strings.HasPrefix(message, "\x01ACTION")
16
+}
8 17
 
9 18
 // WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
10 19
 func WordWrap(text string, lineWidth int) []string {
@@ -54,9 +63,17 @@ func WordWrap(text string, lineWidth int) []string {
54 63
 type MessagePair struct {
55 64
 	Message string
56 65
 	Msgid   string
66
+	Concat  bool // should be relayed with the multiline-concat tag
57 67
 }
58 68
 
59 69
 // SplitMessage represents a message that's been split for sending.
70
+// Three possibilities:
71
+// (a) Standard message that can be relayed on a single 512-byte line
72
+//     (MessagePair contains the message, Wrapped == nil)
73
+// (b) oragono.io/maxline-2 message that was split on the server side
74
+//     (MessagePair contains the unsplit message, Wrapped contains the split lines)
75
+// (c) multiline message that was split on the client side
76
+//     (MessagePair is zero, Wrapped contains the split lines)
60 77
 type SplitMessage struct {
61 78
 	MessagePair
62 79
 	Wrapped []MessagePair // if this is nil, `Message` didn't need wrapping and can be sent to anyone
@@ -84,6 +101,58 @@ func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) {
84 101
 	return
85 102
 }
86 103
 
104
+func (sm *SplitMessage) Append(message string, concat bool) {
105
+	if sm.Msgid == "" {
106
+		sm.Msgid = GenerateSecretToken()
107
+	}
108
+	sm.Wrapped = append(sm.Wrapped, MessagePair{
109
+		Message: message,
110
+		Msgid:   GenerateSecretToken(),
111
+		Concat:  concat,
112
+	})
113
+}
114
+
115
+func (sm *SplitMessage) LenLines() int {
116
+	if sm.Wrapped == nil {
117
+		if (sm.MessagePair == MessagePair{}) {
118
+			return 0
119
+		} else {
120
+			return 1
121
+		}
122
+	}
123
+	return len(sm.Wrapped)
124
+}
125
+
126
+func (sm *SplitMessage) LenBytes() (result int) {
127
+	if sm.Wrapped == nil {
128
+		return len(sm.Message)
129
+	}
130
+	for i := 0; i < len(sm.Wrapped); i++ {
131
+		result += len(sm.Wrapped[i].Message)
132
+	}
133
+	return
134
+}
135
+
136
+func (sm *SplitMessage) IsRestrictedCTCPMessage() bool {
137
+	if IsRestrictedCTCPMessage(sm.Message) {
138
+		return true
139
+	}
140
+	for i := 0; i < len(sm.Wrapped); i++ {
141
+		if IsRestrictedCTCPMessage(sm.Wrapped[i].Message) {
142
+			return true
143
+		}
144
+	}
145
+	return false
146
+}
147
+
148
+func (sm *SplitMessage) IsMultiline() bool {
149
+	return sm.Message == "" && len(sm.Wrapped) != 0
150
+}
151
+
152
+func (sm *SplitMessage) Is512() bool {
153
+	return sm.Message != "" && sm.Wrapped == nil
154
+}
155
+
87 156
 // TokenLineBuilder is a helper for building IRC lines composed of delimited tokens,
88 157
 // with a maximum line length.
89 158
 type TokenLineBuilder struct {

+ 5
- 0
oragono.yaml View File

@@ -579,6 +579,11 @@ limits:
579 579
     # DoS / resource exhaustion attacks):
580 580
     registration-messages: 1024
581 581
 
582
+    # message length limits for the new multiline cap
583
+    multiline:
584
+        max-bytes: 4096 # 0 means disabled
585
+        max-lines: 24   # 0 means no limit
586
+
582 587
 # fakelag: prevents clients from spamming commands too rapidly
583 588
 fakelag:
584 589
     # whether to enforce fakelag

Loading…
Cancel
Save