Browse Source

improvements to message replay code

tags/v1.1.0-rc1
Shivaram Lingamneni 5 years ago
parent
commit
b11bf503e7
10 changed files with 336 additions and 163 deletions
  1. 6
    0
      gencapdefs.py
  2. 6
    1
      irc/caps/defs.go
  3. 105
    51
      irc/channel.go
  4. 32
    22
      irc/client.go
  5. 24
    25
      irc/handlers.go
  6. 43
    12
      irc/history/history.go
  7. 14
    28
      irc/history/history_test.go
  8. 20
    5
      irc/nickname.go
  9. 83
    19
      irc/responsebuffer.go
  10. 3
    0
      irc/utils/text.go

+ 6
- 0
gencapdefs.py View File

@@ -159,6 +159,12 @@ CAPDEFS = [
159 159
         url="https://wiki.znc.in/Query_buffers",
160 160
         standard="ZNC vendor",
161 161
     ),
162
+    CapDef(
163
+        identifier="EventPlayback",
164
+        name="draft/event-playback",
165
+        url="https://github.com/ircv3/ircv3-specifications/pull/362",
166
+        standard="Draft IRCv3",
167
+    ),
162 168
 ]
163 169
 
164 170
 def validate_defs():

+ 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 = 24
10
+	numCapabs = 25
11 11
 	// length of the uint64 array that represents the bitset:
12 12
 	bitsetLen = 1
13 13
 )
@@ -108,6 +108,10 @@ const (
108 108
 	// ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message":
109 109
 	// https://wiki.znc.in/Query_buffers
110 110
 	ZNCSelfMessage Capability = iota
111
+
112
+	// EventPlayback is the Draft IRCv3 capability named "draft/event-playback":
113
+	// https://github.com/ircv3/ircv3-specifications/pull/362
114
+	EventPlayback Capability = iota
111 115
 )
112 116
 
113 117
 // `capabilityNames[capab]` is the string name of the capability `capab`
@@ -137,5 +141,6 @@ var (
137 141
 		"userhost-in-names",
138 142
 		"oragono.io/bnc",
139 143
 		"znc.in/self-message",
144
+		"draft/event-playback",
140 145
 	}
141 146
 )

+ 105
- 51
irc/channel.go View File

@@ -536,6 +536,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
536 536
 
537 537
 	client.server.logger.Debug("join", fmt.Sprintf("%s joined channel %s", details.nick, chname))
538 538
 
539
+	var message utils.SplitMessage
540
+
539 541
 	givenMode := func() (givenMode modes.Mode) {
540 542
 		channel.joinPartMutex.Lock()
541 543
 		defer channel.joinPartMutex.Unlock()
@@ -559,14 +561,15 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
559 561
 
560 562
 		channel.regenerateMembersCache()
561 563
 
562
-		message := utils.SplitMessage{}
563
-		message.Msgid = details.realname
564
-		channel.history.Add(history.Item{
564
+		message = utils.MakeSplitMessage("", true)
565
+		histItem := history.Item{
565 566
 			Type:        history.Join,
566 567
 			Nick:        details.nickMask,
567 568
 			AccountName: details.accountName,
568 569
 			Message:     message,
569
-		})
570
+		}
571
+		histItem.Params[0] = details.realname
572
+		channel.history.Add(histItem)
570 573
 
571 574
 		return
572 575
 	}()
@@ -587,9 +590,10 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
587 590
 				continue
588 591
 			}
589 592
 			if session.capabilities.Has(caps.ExtendedJoin) {
590
-				session.Send(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname)
593
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname, details.accountName, details.realname)
591 594
 			} else {
592 595
 				session.Send(nil, details.nickMask, "JOIN", chname)
596
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname)
593 597
 			}
594 598
 			if givenMode != 0 {
595 599
 				session.Send(nil, client.server.name, "MODE", chname, modestr, details.nick)
@@ -598,9 +602,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
598 602
 	}
599 603
 
600 604
 	if rb.session.capabilities.Has(caps.ExtendedJoin) {
601
-		rb.Add(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname)
605
+		rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname, details.accountName, details.realname)
602 606
 	} else {
603
-		rb.Add(nil, details.nickMask, "JOIN", chname)
607
+		rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname)
604 608
 	}
605 609
 
606 610
 	if rb.session.client == client {
@@ -613,10 +617,13 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
613 617
 	rb.Flush(true)
614 618
 
615 619
 	replayLimit := channel.server.Config().History.AutoreplayOnJoin
616
-	if replayLimit > 0 {
620
+	if 0 < replayLimit {
621
+		// TODO don't replay the client's own JOIN line?
617 622
 		items := channel.history.Latest(replayLimit)
618
-		channel.replayHistoryItems(rb, items)
619
-		rb.Flush(true)
623
+		if 0 < len(items) {
624
+			channel.replayHistoryItems(rb, items, true)
625
+			rb.Flush(true)
626
+		}
620 627
 	}
621 628
 }
622 629
 
@@ -647,14 +654,16 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
647 654
 
648 655
 	channel.Quit(client)
649 656
 
657
+	splitMessage := utils.MakeSplitMessage(message, true)
658
+
650 659
 	details := client.Details()
651 660
 	for _, member := range channel.Members() {
652
-		member.Send(nil, details.nickMask, "PART", chname, message)
661
+		member.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", chname, message)
653 662
 	}
654
-	rb.Add(nil, details.nickMask, "PART", chname, message)
663
+	rb.AddFromClient(splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", chname, message)
655 664
 	for _, session := range client.Sessions() {
656 665
 		if session != rb.session {
657
-			session.Send(nil, details.nickMask, "PART", chname, message)
666
+			session.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", chname, message)
658 667
 		}
659 668
 	}
660 669
 
@@ -662,7 +671,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
662 671
 		Type:        history.Part,
663 672
 		Nick:        details.nickMask,
664 673
 		AccountName: details.accountName,
665
-		Message:     utils.MakeSplitMessage(message, true),
674
+		Message:     splitMessage,
666 675
 	})
667 676
 
668 677
 	client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", details.nick, chname))
@@ -748,7 +757,7 @@ func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) {
748 757
 func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Time, before time.Time) {
749 758
 	items, complete := channel.history.Between(after, before, false, 0)
750 759
 	rb := NewResponseBuffer(newClient.Sessions()[0])
751
-	channel.replayHistoryItems(rb, items)
760
+	channel.replayHistoryItems(rb, items, false)
752 761
 	if !complete && !newClient.resumeDetails.HistoryIncomplete {
753 762
 		// warn here if we didn't warn already
754 763
 		rb.Add(nil, "HistServ", "NOTICE", channel.Name(), newClient.t("Some additional message history may have been lost"))
@@ -759,50 +768,93 @@ func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Tim
759 768
 func stripMaskFromNick(nickMask string) (nick string) {
760 769
 	index := strings.Index(nickMask, "!")
761 770
 	if index == -1 {
762
-		return
771
+		return nickMask
763 772
 	}
764 773
 	return nickMask[0:index]
765 774
 }
766 775
 
767
-func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item) {
776
+// munge the msgid corresponding to a replayable event,
777
+// yielding a consistent msgid for the fake PRIVMSG from HistServ
778
+func mungeMsgidForHistserv(token string) (result string) {
779
+	return fmt.Sprintf("_%s", token)
780
+}
781
+
782
+func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) {
768 783
 	chname := channel.Name()
769 784
 	client := rb.target
770
-	serverTime := rb.session.capabilities.Has(caps.ServerTime)
785
+	eventPlayback := rb.session.capabilities.Has(caps.EventPlayback)
786
+	extendedJoin := rb.session.capabilities.Has(caps.ExtendedJoin)
771 787
 
772
-	for _, item := range items {
773
-		var tags map[string]string
774
-		if serverTime {
775
-			tags = map[string]string{"time": item.Time.Format(IRCv3TimestampFormat)}
776
-		}
788
+	if len(items) == 0 {
789
+		return
790
+	}
791
+	batchID := rb.StartNestedHistoryBatch(chname)
792
+	defer rb.EndNestedBatch(batchID)
777 793
 
778
-		// TODO(#437) support history.Tagmsg
794
+	for _, item := range items {
795
+		nick := stripMaskFromNick(item.Nick)
779 796
 		switch item.Type {
780 797
 		case history.Privmsg:
781
-			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, "PRIVMSG", chname, item.Message)
798
+			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "PRIVMSG", chname, item.Message)
782 799
 		case history.Notice:
783
-			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, "NOTICE", chname, item.Message)
800
+			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "NOTICE", chname, item.Message)
801
+		case history.Tagmsg:
802
+			if rb.session.capabilities.Has(caps.MessageTags) {
803
+				rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "TAGMSG", chname, item.Message)
804
+			}
784 805
 		case history.Join:
785
-			nick := stripMaskFromNick(item.Nick)
786
-			var message string
787
-			if item.AccountName == "*" {
788
-				message = fmt.Sprintf(client.t("%s joined the channel"), nick)
806
+			if eventPlayback {
807
+				if extendedJoin {
808
+					rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname, item.AccountName, item.Params[0])
809
+				} else {
810
+					rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname)
811
+				}
789 812
 			} else {
790
-				message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName)
813
+				if autoreplay {
814
+					continue // #474
815
+				}
816
+				var message string
817
+				if item.AccountName == "*" {
818
+					message = fmt.Sprintf(client.t("%s joined the channel"), nick)
819
+				} else {
820
+					message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName)
821
+				}
822
+				rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
791 823
 			}
792
-			rb.Add(tags, "HistServ", "PRIVMSG", chname, message)
793 824
 		case history.Part:
794
-			nick := stripMaskFromNick(item.Nick)
795
-			message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message)
796
-			rb.Add(tags, "HistServ", "PRIVMSG", chname, message)
797
-		case history.Quit:
798
-			nick := stripMaskFromNick(item.Nick)
799
-			message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message)
800
-			rb.Add(tags, "HistServ", "PRIVMSG", chname, message)
825
+			if eventPlayback {
826
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "PART", chname, item.Message.Message)
827
+			} else {
828
+				if autoreplay {
829
+					continue // #474
830
+				}
831
+				message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message)
832
+				rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
833
+			}
801 834
 		case history.Kick:
802
-			nick := stripMaskFromNick(item.Nick)
803
-			// XXX Msgid is the kick target
804
-			message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Message.Msgid, item.Message.Message)
805
-			rb.Add(tags, "HistServ", "PRIVMSG", chname, message)
835
+			if eventPlayback {
836
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "KICK", chname, item.Params[0], item.Message.Message)
837
+			} else {
838
+				message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message)
839
+				rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
840
+			}
841
+		case history.Quit:
842
+			if eventPlayback {
843
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "QUIT", item.Message.Message)
844
+			} else {
845
+				if autoreplay {
846
+					continue // #474
847
+				}
848
+				message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message)
849
+				rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
850
+			}
851
+		case history.Nick:
852
+			if eventPlayback {
853
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "NICK", item.Params[0])
854
+			} else {
855
+				message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0])
856
+				rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
857
+			}
806 858
 		}
807 859
 	}
808 860
 }
@@ -934,7 +986,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
934 986
 			tagsToUse = clientOnlyTags
935 987
 		}
936 988
 		if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
937
-			rb.AddFromClient(message.Msgid, nickmask, account, tagsToUse, command, chname)
989
+			rb.AddFromClient(message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
938 990
 		} else {
939 991
 			rb.AddSplitMessageFromClient(nickmask, account, tagsToUse, command, chname, message)
940 992
 		}
@@ -986,7 +1038,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
986 1038
 		Message:     message,
987 1039
 		Nick:        nickmask,
988 1040
 		AccountName: account,
989
-		Time:        now,
1041
+		Tags:        clientOnlyTags,
990 1042
 	})
991 1043
 }
992 1044
 
@@ -1110,27 +1162,29 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
1110 1162
 		comment = comment[:kicklimit]
1111 1163
 	}
1112 1164
 
1165
+	message := utils.MakeSplitMessage(comment, true)
1113 1166
 	clientMask := client.NickMaskString()
1167
+	clientAccount := client.AccountName()
1168
+
1114 1169
 	targetNick := target.Nick()
1115 1170
 	chname := channel.Name()
1116 1171
 	for _, member := range channel.Members() {
1117 1172
 		for _, session := range member.Sessions() {
1118 1173
 			if session != rb.session {
1119
-				session.Send(nil, clientMask, "KICK", chname, targetNick, comment)
1174
+				session.sendFromClientInternal(false, message.Time, message.Msgid, clientMask, clientAccount, nil, "KICK", chname, targetNick, comment)
1120 1175
 			}
1121 1176
 		}
1122 1177
 	}
1123 1178
 	rb.Add(nil, clientMask, "KICK", chname, targetNick, comment)
1124 1179
 
1125
-	message := utils.SplitMessage{}
1126
-	message.Message = comment
1127
-	message.Msgid = targetNick // XXX abuse this field
1128
-	channel.history.Add(history.Item{
1180
+	histItem := history.Item{
1129 1181
 		Type:        history.Kick,
1130 1182
 		Nick:        clientMask,
1131 1183
 		AccountName: target.AccountName(),
1132 1184
 		Message:     message,
1133
-	})
1185
+	}
1186
+	histItem.Params[0] = targetNick
1187
+	channel.history.Add(histItem)
1134 1188
 
1135 1189
 	channel.Quit(target)
1136 1190
 }

+ 32
- 22
irc/client.go View File

@@ -487,7 +487,7 @@ func (client *Client) tryResume() (success bool) {
487 487
 		}
488 488
 	}
489 489
 	privmsgMatcher := func(item history.Item) bool {
490
-		return item.Type == history.Privmsg || item.Type == history.Notice
490
+		return item.Type == history.Privmsg || item.Type == history.Notice || item.Type == history.Tagmsg
491 491
 	}
492 492
 	privmsgHistory := oldClient.history.Match(privmsgMatcher, false, 0)
493 493
 	lastDiscarded := oldClient.history.LastDiscarded()
@@ -495,8 +495,7 @@ func (client *Client) tryResume() (success bool) {
495 495
 		oldestLostMessage = lastDiscarded
496 496
 	}
497 497
 	for _, item := range privmsgHistory {
498
-		// TODO this is the nickmask, fix that
499
-		sender := server.clients.Get(item.Nick)
498
+		sender := server.clients.Get(stripMaskFromNick(item.Nick))
500 499
 		if sender != nil {
501 500
 			friends.Add(sender)
502 501
 		}
@@ -561,8 +560,13 @@ func (client *Client) tryResumeChannels() {
561 560
 }
562 561
 
563 562
 func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) {
563
+	var batchID string
564 564
 	nick := client.Nick()
565
-	serverTime := rb.session.capabilities.Has(caps.ServerTime)
565
+	if 0 < len(items) {
566
+		batchID = rb.StartNestedHistoryBatch(nick)
567
+	}
568
+
569
+	allowTags := rb.session.capabilities.Has(caps.MessageTags)
566 570
 	for _, item := range items {
567 571
 		var command string
568 572
 		switch item.Type {
@@ -570,15 +574,23 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
570 574
 			command = "PRIVMSG"
571 575
 		case history.Notice:
572 576
 			command = "NOTICE"
577
+		case history.Tagmsg:
578
+			if allowTags {
579
+				command = "TAGMSG"
580
+			} else {
581
+				continue
582
+			}
573 583
 		default:
574 584
 			continue
575 585
 		}
576 586
 		var tags map[string]string
577
-		if serverTime {
578
-			tags = map[string]string{"time": item.Time.Format(IRCv3TimestampFormat)}
587
+		if allowTags {
588
+			tags = item.Tags
579 589
 		}
580 590
 		rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message)
581 591
 	}
592
+
593
+	rb.EndNestedBatch(batchID)
582 594
 	if !complete {
583 595
 		rb.Add(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost"))
584 596
 	}
@@ -934,19 +946,21 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
934 946
 		return
935 947
 	}
936 948
 
949
+	details := client.Details()
950
+
937 951
 	// see #235: deduplicating the list of PART recipients uses (comparatively speaking)
938 952
 	// a lot of RAM, so limit concurrency to avoid thrashing
939 953
 	client.server.semaphores.ClientDestroy.Acquire()
940 954
 	defer client.server.semaphores.ClientDestroy.Release()
941 955
 
942 956
 	if beingResumed {
943
-		client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", client.nick))
957
+		client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", details.nick))
944 958
 	} else {
945
-		client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", client.nick))
959
+		client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", details.nick))
946 960
 	}
947 961
 
948 962
 	if !beingResumed {
949
-		client.server.whoWas.Append(client.WhoWas())
963
+		client.server.whoWas.Append(details.WhoWas)
950 964
 	}
951 965
 
952 966
 	// remove from connection limits
@@ -963,6 +977,7 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
963 977
 	// clean up monitor state
964 978
 	client.server.monitorManager.RemoveAll(client)
965 979
 
980
+	splitQuitMessage := utils.MakeSplitMessage(quitMessage, true)
966 981
 	// clean up channels
967 982
 	friends := make(ClientSet)
968 983
 	for _, channel := range client.Channels() {
@@ -972,7 +987,7 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
972 987
 				Type:        history.Quit,
973 988
 				Nick:        nickMaskString,
974 989
 				AccountName: accountName,
975
-				Message:     utils.MakeSplitMessage(quitMessage, true),
990
+				Message:     splitQuitMessage,
976 991
 			})
977 992
 		}
978 993
 		for _, member := range channel.Members() {
@@ -1007,14 +1022,14 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
1007 1022
 			if quitMessage == "" {
1008 1023
 				quitMessage = "Exited"
1009 1024
 			}
1010
-			friend.Send(nil, client.nickMaskString, "QUIT", quitMessage)
1025
+			friend.sendFromClientInternal(false, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage)
1011 1026
 		}
1012 1027
 	}
1013 1028
 	if !client.exitedSnomaskSent {
1014 1029
 		if beingResumed {
1015 1030
 			client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r is resuming their connection, old client has been destroyed"), client.nick))
1016 1031
 		} else {
1017
-			client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), client.nick))
1032
+			client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), details.nick))
1018 1033
 		}
1019 1034
 	}
1020 1035
 }
@@ -1031,15 +1046,7 @@ func (session *Session) sendSplitMsgFromClientInternal(blocking bool, serverTime
1031 1046
 	}
1032 1047
 }
1033 1048
 
1034
-// SendFromClient sends an IRC line coming from a specific client.
1035
-// Adds account-tag to the line as well.
1036
-func (client *Client) SendFromClient(msgid string, from *Client, tags map[string]string, command string, params ...string) error {
1037
-	return client.sendFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, params...)
1038
-}
1039
-
1040
-// this is SendFromClient, but directly exposing nickmask and accountName,
1041
-// for things like history replay and CHGHOST where they no longer (necessarily)
1042
-// correspond to the current state of a client
1049
+// Sends a line with `nickmask` as the prefix, adding `time` and `account` tags if supported
1043 1050
 func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags map[string]string, command string, params ...string) (err error) {
1044 1051
 	for _, session := range client.Sessions() {
1045 1052
 		err_ := session.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, params...)
@@ -1062,7 +1069,10 @@ func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Ti
1062 1069
 	}
1063 1070
 	// attach server-time
1064 1071
 	if session.capabilities.Has(caps.ServerTime) {
1065
-		msg.SetTag("time", time.Now().UTC().Format(IRCv3TimestampFormat))
1072
+		if serverTime.IsZero() {
1073
+			serverTime = time.Now().UTC()
1074
+		}
1075
+		msg.SetTag("time", serverTime.Format(IRCv3TimestampFormat))
1066 1076
 	}
1067 1077
 
1068 1078
 	return session.SendRawMessage(msg, blocking)

+ 24
- 25
irc/handlers.go View File

@@ -585,36 +585,36 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
585 585
 // e.g., CHATHISTORY #ircv3 BETWEEN timestamp=YYYY-MM-DDThh:mm:ss.sssZ timestamp=YYYY-MM-DDThh:mm:ss.sssZ + 100
586 586
 func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (exiting bool) {
587 587
 	config := server.Config()
588
-	// batch type is chathistory; send an empty batch if necessary
589
-	rb.InitializeBatch("chathistory", true)
590 588
 
591 589
 	var items []history.Item
592 590
 	success := false
593 591
 	var hist *history.Buffer
594 592
 	var channel *Channel
595 593
 	defer func() {
596
-		if success {
594
+		// successful responses are sent as a chathistory or history batch
595
+		if success && 0 < len(items) {
596
+			batchType := "chathistory"
597
+			if rb.session.capabilities.Has(caps.EventPlayback) {
598
+				batchType = "history"
599
+			}
600
+			rb.ForceBatchStart(batchType, true)
597 601
 			if channel == nil {
598 602
 				client.replayPrivmsgHistory(rb, items, true)
599 603
 			} else {
600
-				channel.replayHistoryItems(rb, items)
604
+				channel.replayHistoryItems(rb, items, false)
601 605
 			}
602
-		}
603
-		rb.Send(true) // terminate the chathistory batch
604
-		if success && len(items) > 0 {
605 606
 			return
606 607
 		}
607
-		newRb := NewResponseBuffer(rb.session)
608
-		newRb.Label = rb.Label // same label, new batch
608
+
609
+		// errors are sent either without a batch, or in a draft/labeled-response batch as usual
609 610
 		// TODO: send `WARN CHATHISTORY MAX_MESSAGES_EXCEEDED` when appropriate
610 611
 		if hist == nil {
611
-			newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_SUCH_CHANNEL")
612
+			rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_SUCH_CHANNEL")
612 613
 		} else if len(items) == 0 {
613
-			newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_TEXT_TO_SEND")
614
+			rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_TEXT_TO_SEND")
614 615
 		} else if !success {
615
-			newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NEED_MORE_PARAMS")
616
+			rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NEED_MORE_PARAMS")
616 617
 		}
617
-		newRb.Send(true)
618 618
 	}()
619 619
 
620 620
 	target := msg.Params[0]
@@ -744,7 +744,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
744 744
 			}
745 745
 		} else {
746 746
 			matches = func(item history.Item) bool {
747
-				return before == item.Time.Before(timestamp)
747
+				return before == item.Message.Time.Before(timestamp)
748 748
 			}
749 749
 		}
750 750
 		items = hist.Match(matches, !before, limit)
@@ -767,7 +767,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
767 767
 				}
768 768
 			} else {
769 769
 				matches = func(item history.Item) bool {
770
-					return item.Time.After(timestamp)
770
+					return item.Message.Time.After(timestamp)
771 771
 				}
772 772
 			}
773 773
 			items = hist.Match(matches, false, limit)
@@ -790,16 +790,16 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
790 790
 			}
791 791
 		} else {
792 792
 			initialMatcher = func(item history.Item) (result bool) {
793
-				return item.Time.Before(timestamp)
793
+				return item.Message.Time.Before(timestamp)
794 794
 			}
795 795
 		}
796 796
 		var halfLimit int
797 797
 		halfLimit = (limit + 1) / 2
798 798
 		firstPass := hist.Match(initialMatcher, false, halfLimit)
799 799
 		if len(firstPass) > 0 {
800
-			timeWindowStart := firstPass[0].Time
800
+			timeWindowStart := firstPass[0].Message.Time
801 801
 			items = hist.Match(func(item history.Item) bool {
802
-				return item.Time.Equal(timeWindowStart) || item.Time.After(timeWindowStart)
802
+				return item.Message.Time.Equal(timeWindowStart) || item.Message.Time.After(timeWindowStart)
803 803
 			}, true, limit)
804 804
 		}
805 805
 		success = true
@@ -1109,7 +1109,7 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
1109 1109
 	items := hist.Latest(limit)
1110 1110
 
1111 1111
 	if channel != nil {
1112
-		channel.replayHistoryItems(rb, items)
1112
+		channel.replayHistoryItems(rb, items, false)
1113 1113
 	} else {
1114 1114
 		client.replayPrivmsgHistory(rb, items, true)
1115 1115
 	}
@@ -1960,7 +1960,6 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
1960 1960
 	for i, targetString := range targets {
1961 1961
 		// each target gets distinct msgids
1962 1962
 		splitMsg := utils.MakeSplitMessage(message, !rb.session.capabilities.Has(caps.MaxLine))
1963
-		now := time.Now().UTC()
1964 1963
 
1965 1964
 		// max of four targets per privmsg
1966 1965
 		if i > maxTargets-1 {
@@ -2009,17 +2008,17 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
2009 2008
 					if histType == history.Tagmsg {
2010 2009
 						// don't send TAGMSG at all if they don't have the tags cap
2011 2010
 						if session.capabilities.Has(caps.MessageTags) {
2012
-							session.sendFromClientInternal(false, now, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2011
+							session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2013 2012
 						}
2014 2013
 					} else {
2015
-						session.sendSplitMsgFromClientInternal(false, now, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2014
+						session.sendSplitMsgFromClientInternal(false, splitMsg.Time, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2016 2015
 					}
2017 2016
 				}
2018 2017
 			}
2019 2018
 			// an echo-message may need to be included in the response:
2020 2019
 			if rb.session.capabilities.Has(caps.EchoMessage) {
2021 2020
 				if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
2022
-					rb.AddFromClient(splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2021
+					rb.AddFromClient(splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2023 2022
 				} else {
2024 2023
 					rb.AddSplitMessageFromClient(nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2025 2024
 				}
@@ -2030,9 +2029,9 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
2030 2029
 					continue
2031 2030
 				}
2032 2031
 				if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
2033
-					session.sendFromClientInternal(false, now, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2032
+					session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
2034 2033
 				} else {
2035
-					session.sendSplitMsgFromClientInternal(false, now, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2034
+					session.sendSplitMsgFromClientInternal(false, splitMsg.Time, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
2036 2035
 				}
2037 2036
 			}
2038 2037
 			if histType != history.Notice && user.Away() {

+ 43
- 12
irc/history/history.go View File

@@ -22,25 +22,52 @@ const (
22 22
 	Quit
23 23
 	Mode
24 24
 	Tagmsg
25
+	Nick
25 26
 )
26 27
 
28
+// a Tagmsg that consists entirely of junk tags is not stored
29
+var junkTags = map[string]bool{
30
+	"+draft/typing": true,
31
+	"+typing":       true, // future-proofing
32
+}
33
+
27 34
 // Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data
28 35
 type Item struct {
29 36
 	Type ItemType
30
-	Time time.Time
31 37
 
32 38
 	Nick string
33 39
 	// this is the uncasefolded account name, if there's no account it should be set to "*"
34 40
 	AccountName string
35
-	Message     utils.SplitMessage
36 41
 	// for non-privmsg items, we may stuff some other data in here
42
+	Message utils.SplitMessage
43
+	Tags    map[string]string
44
+	Params  [1]string
37 45
 }
38 46
 
39 47
 // HasMsgid tests whether a message has the message id `msgid`.
40 48
 func (item *Item) HasMsgid(msgid string) bool {
41
-	// XXX we stuff other data in the Msgid field sometimes,
42
-	// don't match it by accident
43
-	return (item.Type == Privmsg || item.Type == Notice) && item.Message.Msgid == msgid
49
+	if item.Message.Msgid == msgid {
50
+		return true
51
+	}
52
+	for _, pair := range item.Message.Wrapped {
53
+		if pair.Msgid == msgid {
54
+			return true
55
+		}
56
+	}
57
+	return false
58
+}
59
+
60
+func (item *Item) isStorable() bool {
61
+	if item.Type == Tagmsg {
62
+		for name := range item.Tags {
63
+			if !junkTags[name] {
64
+				return true
65
+			}
66
+		}
67
+		return false // all tags were blacklisted
68
+	} else {
69
+		return true
70
+	}
44 71
 }
45 72
 
46 73
 type Predicate func(item Item) (matches bool)
@@ -94,8 +121,12 @@ func (list *Buffer) Add(item Item) {
94 121
 		return
95 122
 	}
96 123
 
97
-	if item.Time.IsZero() {
98
-		item.Time = time.Now().UTC()
124
+	if !item.isStorable() {
125
+		return
126
+	}
127
+
128
+	if item.Message.Time.IsZero() {
129
+		item.Message.Time = time.Now().UTC()
99 130
 	}
100 131
 
101 132
 	list.Lock()
@@ -114,8 +145,8 @@ func (list *Buffer) Add(item Item) {
114 145
 		list.end = (list.end + 1) % len(list.buffer)
115 146
 		list.start = list.end // advance start as well, overwriting first entry
116 147
 		// record the timestamp of the overwritten item
117
-		if list.lastDiscarded.Before(list.buffer[pos].Time) {
118
-			list.lastDiscarded = list.buffer[pos].Time
148
+		if list.lastDiscarded.Before(list.buffer[pos].Message.Time) {
149
+			list.lastDiscarded = list.buffer[pos].Message.Time
119 150
 		}
120 151
 	}
121 152
 
@@ -144,7 +175,7 @@ func (list *Buffer) Between(after, before time.Time, ascending bool, limit int)
144 175
 	complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded)
145 176
 
146 177
 	satisfies := func(item Item) bool {
147
-		return (after.IsZero() || item.Time.After(after)) && (before.IsZero() || item.Time.Before(before))
178
+		return (after.IsZero() || item.Message.Time.After(after)) && (before.IsZero() || item.Message.Time.Before(before))
148 179
 	}
149 180
 
150 181
 	return list.matchInternal(satisfies, ascending, limit), complete
@@ -264,8 +295,8 @@ func (list *Buffer) Resize(size int) {
264 295
 			}
265 296
 			// update lastDiscarded for discarded entries
266 297
 			for i := list.start; i != start; i = (i + 1) % len(list.buffer) {
267
-				if list.lastDiscarded.Before(list.buffer[i].Time) {
268
-					list.lastDiscarded = list.buffer[i].Time
298
+				if list.lastDiscarded.Before(list.buffer[i].Message.Time) {
299
+					list.lastDiscarded = list.buffer[i].Message.Time
269 300
 				}
270 301
 			}
271 302
 		}

+ 14
- 28
irc/history/history_test.go View File

@@ -87,6 +87,12 @@ func easyParse(timestamp string) time.Time {
87 87
 	return result
88 88
 }
89 89
 
90
+func easyItem(nick string, timestamp string) (result Item) {
91
+	result.Message.Time = easyParse(timestamp)
92
+	result.Nick = nick
93
+	return
94
+}
95
+
90 96
 func assertEqual(supplied, expected interface{}, t *testing.T) {
91 97
 	if !reflect.DeepEqual(supplied, expected) {
92 98
 		t.Errorf("expected %v but got %v", expected, supplied)
@@ -97,30 +103,19 @@ func TestBuffer(t *testing.T) {
97 103
 	start := easyParse("2006-01-01 00:00:00Z")
98 104
 
99 105
 	buf := NewHistoryBuffer(3)
100
-	buf.Add(Item{
101
-		Nick: "testnick0",
102
-		Time: easyParse("2006-01-01 15:04:05Z"),
103
-	})
106
+	buf.Add(easyItem("testnick0", "2006-01-01 15:04:05Z"))
104 107
 
105
-	buf.Add(Item{
106
-		Nick: "testnick1",
107
-		Time: easyParse("2006-01-02 15:04:05Z"),
108
-	})
108
+	buf.Add(easyItem("testnick1", "2006-01-02 15:04:05Z"))
109 109
 
110
-	buf.Add(Item{
111
-		Nick: "testnick2",
112
-		Time: easyParse("2006-01-03 15:04:05Z"),
113
-	})
110
+	buf.Add(easyItem("testnick2", "2006-01-03 15:04:05Z"))
114 111
 
115 112
 	since, complete := buf.Between(start, time.Now(), false, 0)
116 113
 	assertEqual(complete, true, t)
117 114
 	assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
118 115
 
119 116
 	// add another item, evicting the first
120
-	buf.Add(Item{
121
-		Nick: "testnick3",
122
-		Time: easyParse("2006-01-04 15:04:05Z"),
123
-	})
117
+	buf.Add(easyItem("testnick3", "2006-01-04 15:04:05Z"))
118
+
124 119
 	since, complete = buf.Between(start, time.Now(), false, 0)
125 120
 	assertEqual(complete, false, t)
126 121
 	assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
@@ -139,18 +134,9 @@ func TestBuffer(t *testing.T) {
139 134
 	assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
140 135
 
141 136
 	buf.Resize(5)
142
-	buf.Add(Item{
143
-		Nick: "testnick4",
144
-		Time: easyParse("2006-01-05 15:04:05Z"),
145
-	})
146
-	buf.Add(Item{
147
-		Nick: "testnick5",
148
-		Time: easyParse("2006-01-06 15:04:05Z"),
149
-	})
150
-	buf.Add(Item{
151
-		Nick: "testnick6",
152
-		Time: easyParse("2006-01-07 15:04:05Z"),
153
-	})
137
+	buf.Add(easyItem("testnick4", "2006-01-05 15:04:05Z"))
138
+	buf.Add(easyItem("testnick5", "2006-01-06 15:04:05Z"))
139
+	buf.Add(easyItem("testnick6", "2006-01-07 15:04:05Z"))
154 140
 	since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), false, 0)
155 141
 	assertEqual(complete, true, t)
156 142
 	assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t)

+ 20
- 5
irc/nickname.go View File

@@ -11,7 +11,9 @@ import (
11 11
 	"strings"
12 12
 
13 13
 	"github.com/goshuirc/irc-go/ircfmt"
14
+	"github.com/oragono/oragono/irc/history"
14 15
 	"github.com/oragono/oragono/irc/sno"
16
+	"github.com/oragono/oragono/irc/utils"
15 17
 )
16 18
 
17 19
 var (
@@ -44,7 +46,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
44 46
 
45 47
 	hadNick := target.HasNick()
46 48
 	origNickMask := target.NickMaskString()
47
-	whowas := target.WhoWas()
49
+	details := target.Details()
48 50
 	err = client.server.clients.SetNick(target, session, nickname)
49 51
 	if err == errNicknameInUse {
50 52
 		rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, nickname, client.t("Nickname is already in use"))
@@ -57,18 +59,31 @@ func performNickChange(server *Server, client *Client, target *Client, session *
57 59
 		return false
58 60
 	}
59 61
 
62
+	message := utils.MakeSplitMessage("", true)
63
+	histItem := history.Item{
64
+		Type:        history.Nick,
65
+		Nick:        origNickMask,
66
+		AccountName: details.accountName,
67
+		Message:     message,
68
+	}
69
+	histItem.Params[0] = nickname
70
+
60 71
 	client.server.logger.Debug("nick", fmt.Sprintf("%s changed nickname to %s [%s]", origNickMask, nickname, cfnick))
61 72
 	if hadNick {
62
-		target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("$%s$r changed nickname to %s"), whowas.nick, nickname))
63
-		target.server.whoWas.Append(whowas)
64
-		rb.Add(nil, origNickMask, "NICK", nickname)
73
+		target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("$%s$r changed nickname to %s"), details.nick, nickname))
74
+		target.server.whoWas.Append(details.WhoWas)
75
+		rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", nickname)
65 76
 		for session := range target.Friends() {
66 77
 			if session != rb.session {
67
-				session.Send(nil, origNickMask, "NICK", nickname)
78
+				session.sendFromClientInternal(false, message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", nickname)
68 79
 			}
69 80
 		}
70 81
 	}
71 82
 
83
+	for _, channel := range client.Channels() {
84
+		channel.history.Add(histItem)
85
+	}
86
+
72 87
 	target.nickTimer.Touch(rb)
73 88
 
74 89
 	if target.Registered() {

+ 83
- 19
irc/responsebuffer.go View File

@@ -23,8 +23,19 @@ const (
23 23
 // buffer will silently create a batch if required and label the outgoing messages as
24 24
 // necessary (or leave it off and simply tag the outgoing message).
25 25
 type ResponseBuffer struct {
26
-	Label     string
27
-	batchID   string
26
+	Label     string // label if this is a labeled response batch
27
+	batchID   string // ID of the labeled response batch, if one has been initiated
28
+	batchType string // type of the labeled response batch (possibly `history` or `chathistory`)
29
+
30
+	// stack of batch IDs of nested batches, which are handled separately
31
+	// from the underlying labeled-response batch. starting a new nested batch
32
+	// unconditionally enqueues its batch start message; subsequent messages
33
+	// are tagged with the nested batch ID, until nested batch end.
34
+	// (the nested batch start itself may have no batch tag, or the batch tag of the
35
+	// underlying labeled-response batch, or the batch tag of the next outermost
36
+	// nested batch.)
37
+	nestedBatches []string
38
+
28 39
 	messages  []ircmsg.IrcMessage
29 40
 	finalized bool
30 41
 	target    *Client
@@ -40,8 +51,9 @@ func GetLabel(msg ircmsg.IrcMessage) string {
40 51
 // NewResponseBuffer returns a new ResponseBuffer.
41 52
 func NewResponseBuffer(session *Session) *ResponseBuffer {
42 53
 	return &ResponseBuffer{
43
-		session: session,
44
-		target:  session.client,
54
+		session:   session,
55
+		target:    session.client,
56
+		batchType: defaultBatchType,
45 57
 	}
46 58
 }
47 59
 
@@ -54,6 +66,9 @@ func (rb *ResponseBuffer) AddMessage(msg ircmsg.IrcMessage) {
54 66
 		return
55 67
 	}
56 68
 
69
+	if 0 < len(rb.nestedBatches) {
70
+		msg.SetTag("batch", rb.nestedBatches[len(rb.nestedBatches)-1])
71
+	}
57 72
 	rb.messages = append(rb.messages, msg)
58 73
 }
59 74
 
@@ -63,9 +78,11 @@ func (rb *ResponseBuffer) Add(tags map[string]string, prefix string, command str
63 78
 }
64 79
 
65 80
 // AddFromClient adds a new message from a specific client to our queue.
66
-func (rb *ResponseBuffer) AddFromClient(msgid string, fromNickMask string, fromAccount string, tags map[string]string, command string, params ...string) {
81
+func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMask string, fromAccount string, tags map[string]string, command string, params ...string) {
67 82
 	msg := ircmsg.MakeMessage(nil, fromNickMask, command, params...)
68
-	msg.UpdateTags(tags)
83
+	if rb.session.capabilities.Has(caps.MessageTags) {
84
+		msg.UpdateTags(tags)
85
+	}
69 86
 
70 87
 	// attach account-tag
71 88
 	if rb.session.capabilities.Has(caps.AccountTag) && fromAccount != "*" {
@@ -75,6 +92,10 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, fromNickMask string, fromA
75 92
 	if len(msgid) > 0 && rb.session.capabilities.Has(caps.MessageTags) {
76 93
 		msg.SetTag("draft/msgid", msgid)
77 94
 	}
95
+	// attach server-time
96
+	if rb.session.capabilities.Has(caps.ServerTime) && !msg.HasTag("time") {
97
+		msg.SetTag("time", time.UTC().Format(IRCv3TimestampFormat))
98
+	}
78 99
 
79 100
 	rb.AddMessage(msg)
80 101
 }
@@ -82,33 +103,31 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, fromNickMask string, fromA
82 103
 // AddSplitMessageFromClient adds a new split message from a specific client to our queue.
83 104
 func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, tags map[string]string, command string, target string, message utils.SplitMessage) {
84 105
 	if rb.session.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
85
-		rb.AddFromClient(message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message)
106
+		rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message)
86 107
 	} else {
87 108
 		for _, messagePair := range message.Wrapped {
88
-			rb.AddFromClient(messagePair.Msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message)
109
+			rb.AddFromClient(message.Time, messagePair.Msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message)
89 110
 		}
90 111
 	}
91 112
 }
92 113
 
93
-// InitializeBatch forcibly starts a batch of batch `batchType`.
114
+// ForceBatchStart forcibly starts a batch of batch `batchType`.
94 115
 // Normally, Send/Flush will decide automatically whether to start a batch
95 116
 // of type draft/labeled-response. This allows changing the batch type
96 117
 // and forcing the creation of a possibly empty batch.
97
-func (rb *ResponseBuffer) InitializeBatch(batchType string, blocking bool) {
98
-	rb.sendBatchStart(batchType, blocking)
118
+func (rb *ResponseBuffer) ForceBatchStart(batchType string, blocking bool) {
119
+	rb.batchType = batchType
120
+	rb.sendBatchStart(blocking)
99 121
 }
100 122
 
101
-func (rb *ResponseBuffer) sendBatchStart(batchType string, blocking bool) {
123
+func (rb *ResponseBuffer) sendBatchStart(blocking bool) {
102 124
 	if rb.batchID != "" {
103 125
 		// batch already initialized
104 126
 		return
105 127
 	}
106 128
 
107
-	// formerly this combined time.Now.UnixNano() in base 36 with an incrementing counter,
108
-	// also in base 36. but let's just use a uuidv4-alike (26 base32 characters):
109 129
 	rb.batchID = utils.GenerateSecretToken()
110
-
111
-	message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, batchType)
130
+	message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, rb.batchType)
112 131
 	if rb.Label != "" {
113 132
 		message.SetTag(caps.LabelTagName, rb.Label)
114 133
 	}
@@ -125,6 +144,50 @@ func (rb *ResponseBuffer) sendBatchEnd(blocking bool) {
125 144
 	rb.session.SendRawMessage(message, blocking)
126 145
 }
127 146
 
147
+// Starts a nested batch (see the ResponseBuffer struct definition for a description of
148
+// how this works)
149
+func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
150
+	batchID = utils.GenerateSecretToken()
151
+	msgParams := make([]string, len(params)+2)
152
+	msgParams[0] = "+" + batchID
153
+	msgParams[1] = batchType
154
+	copy(msgParams[2:], params)
155
+	rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", msgParams...))
156
+	rb.nestedBatches = append(rb.nestedBatches, batchID)
157
+	return
158
+}
159
+
160
+// Ends a nested batch
161
+func (rb *ResponseBuffer) EndNestedBatch(batchID string) {
162
+	if batchID == "" {
163
+		return
164
+	}
165
+
166
+	if 0 == len(rb.nestedBatches) || rb.nestedBatches[len(rb.nestedBatches)-1] != batchID {
167
+		rb.target.server.logger.Error("internal", "inconsistent batch nesting detected")
168
+		debug.PrintStack()
169
+		return
170
+	}
171
+
172
+	rb.nestedBatches = rb.nestedBatches[0 : len(rb.nestedBatches)-1]
173
+	rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID))
174
+}
175
+
176
+// Convenience to start a nested batch for history lines, at the highest level
177
+// supported by the client (`history`, `chathistory`, or no batch, in descending order).
178
+func (rb *ResponseBuffer) StartNestedHistoryBatch(params ...string) (batchID string) {
179
+	var batchType string
180
+	if rb.session.capabilities.Has(caps.EventPlayback) {
181
+		batchType = "history"
182
+	} else if rb.session.capabilities.Has(caps.Batch) {
183
+		batchType = "chathistory"
184
+	}
185
+	if batchType != "" {
186
+		batchID = rb.StartNestedBatch(batchType, params...)
187
+	}
188
+	return
189
+}
190
+
128 191
 // Send sends all messages in the buffer to the client.
129 192
 // Afterwards, the buffer is in an undefined state and MUST NOT be used further.
130 193
 // If `blocking` is true you MUST be sending to the client from its own goroutine.
@@ -158,7 +221,7 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
158 221
 	if useLabel && !useBatch && len(rb.messages) == 1 && rb.batchID == "" {
159 222
 		rb.messages[0].SetTag(caps.LabelTagName, rb.Label)
160 223
 	} else if useBatch {
161
-		rb.sendBatchStart(defaultBatchType, blocking)
224
+		rb.sendBatchStart(blocking)
162 225
 	}
163 226
 
164 227
 	// send each message out
@@ -168,8 +231,9 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
168 231
 			message.SetTag("time", time.Now().UTC().Format(IRCv3TimestampFormat))
169 232
 		}
170 233
 
171
-		// attach batch ID
172
-		if rb.batchID != "" {
234
+		// attach batch ID, unless this message was part of a nested batch and is
235
+		// already tagged
236
+		if rb.batchID != "" && !message.HasTag("batch") {
173 237
 			message.SetTag("batch", rb.batchID)
174 238
 		}
175 239
 

+ 3
- 0
irc/utils/text.go View File

@@ -4,6 +4,7 @@
4 4
 package utils
5 5
 
6 6
 import "bytes"
7
+import "time"
7 8
 
8 9
 // WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
9 10
 func WordWrap(text string, lineWidth int) []string {
@@ -59,6 +60,7 @@ type MessagePair struct {
59 60
 type SplitMessage struct {
60 61
 	MessagePair
61 62
 	Wrapped []MessagePair // if this is nil, `Message` didn't need wrapping and can be sent to anyone
63
+	Time    time.Time
62 64
 }
63 65
 
64 66
 const defaultLineWidth = 400
@@ -66,6 +68,7 @@ const defaultLineWidth = 400
66 68
 func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) {
67 69
 	result.Message = original
68 70
 	result.Msgid = GenerateSecretToken()
71
+	result.Time = time.Now().UTC()
69 72
 
70 73
 	if !origIs512 && defaultLineWidth < len(original) {
71 74
 		wrapped := WordWrap(original, defaultLineWidth)

Loading…
Cancel
Save