From 1efde964e102ab4f04172b88316fd7631587aa97 Mon Sep 17 00:00:00 2001
From: Shivaram Lingamneni <slingamn@cs.stanford.edu>
Date: Wed, 17 Mar 2021 14:36:52 -0400
Subject: [PATCH 1/2] Fix #1562

Implement the new bot mode spec:
https://github.com/ircv3/ircv3-specifications/pull/439
---
 irc/caps/constants.go  |  2 ++
 irc/channel.go         | 78 ++++++++++++++++++++++++------------------
 irc/chanserv.go        |  9 ++---
 irc/client.go          | 45 ++++++++++++------------
 irc/handlers.go        | 29 +++++++++-------
 irc/history/history.go |  1 +
 irc/message_cache.go   | 22 +++++++-----
 irc/nickname.go        |  6 ++--
 irc/responsebuffer.go  | 26 ++++++++------
 irc/roleplay.go        |  8 +++--
 10 files changed, 130 insertions(+), 96 deletions(-)

diff --git a/irc/caps/constants.go b/irc/caps/constants.go
index f8a3d5d1..2383e5c0 100644
--- a/irc/caps/constants.go
+++ b/irc/caps/constants.go
@@ -60,6 +60,8 @@ const (
 	MultilineConcatTag = "draft/multiline-concat"
 	// draft/relaymsg:
 	RelaymsgTagName = "draft/relaymsg"
+	// BOT mode: https://github.com/ircv3/ircv3-specifications/pull/439
+	BotTagName = "draft/bot"
 )
 
 func init() {
diff --git a/irc/channel.go b/irc/channel.go
index 99dde88e..c32785dd 100644
--- a/irc/channel.go
+++ b/irc/channel.go
@@ -729,6 +729,7 @@ func (channel *Channel) AddHistoryItem(item history.Item, account string) (err e
 // Join joins the given client to this channel (if they can be joined).
 func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *ResponseBuffer) (joinErr error, forward string) {
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 
 	channel.stateMutex.RLock()
 	chname := channel.name
@@ -824,6 +825,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
 			Nick:        details.nickMask,
 			AccountName: details.accountName,
 			Message:     message,
+			IsBot:       isBot,
 		}
 		histItem.Params[0] = details.realname
 		channel.AddHistoryItem(histItem, details.account)
@@ -840,7 +842,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
 
 	// cache the most common case (JOIN without extended-join)
 	var cache MessageCache
-	cache.Initialize(channel.server, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname)
+	cache.Initialize(channel.server, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname)
 	isAway, awayMessage := client.Away()
 	for _, member := range channel.Members() {
 		if respectAuditorium {
@@ -859,7 +861,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
 				continue
 			}
 			if session.capabilities.Has(caps.ExtendedJoin) {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname, details.accountName, details.realname)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname, details.accountName, details.realname)
 			} else {
 				cache.Send(session)
 			}
@@ -867,15 +869,15 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
 				session.Send(nil, client.server.name, "MODE", chname, modestr, details.nick)
 			}
 			if isAway && session.capabilities.Has(caps.AwayNotify) {
-				session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, nil, "AWAY", awayMessage)
+				session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY", awayMessage)
 			}
 		}
 	}
 
 	if rb.session.capabilities.Has(caps.ExtendedJoin) {
-		rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname, details.accountName, details.realname)
+		rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname, details.accountName, details.realname)
 	} else {
-		rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname)
+		rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname)
 	}
 
 	if rb.session.client == client {
@@ -988,6 +990,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
 	splitMessage := utils.MakeMessage(message)
 
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 	params := make([]string, 1, 2)
 	params[0] = chname
 	if message != "" {
@@ -996,7 +999,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
 	respectAuditorium := channel.flags.HasMode(modes.Auditorium) &&
 		clientData.modes.HighestChannelUserMode() == modes.Mode(0)
 	var cache MessageCache
-	cache.Initialize(channel.server, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", params...)
+	cache.Initialize(channel.server, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "PART", params...)
 	for _, member := range channel.Members() {
 		if respectAuditorium {
 			channel.stateMutex.RLock()
@@ -1010,10 +1013,10 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
 			cache.Send(session)
 		}
 	}
-	rb.AddFromClient(splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", params...)
+	rb.AddFromClient(splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "PART", params...)
 	for _, session := range client.Sessions() {
 		if session != rb.session {
-			session.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", params...)
+			session.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "PART", params...)
 		}
 	}
 
@@ -1023,6 +1026,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
 			Nick:        details.nickMask,
 			AccountName: details.accountName,
 			Message:     splitMessage,
+			IsBot:       isBot,
 		}, details.account)
 	}
 
@@ -1133,19 +1137,19 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
 		nick := NUHToNick(item.Nick)
 		switch item.Type {
 		case history.Privmsg:
-			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "PRIVMSG", chname, item.Message)
+			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, item.Tags, "PRIVMSG", chname, item.Message)
 		case history.Notice:
-			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "NOTICE", chname, item.Message)
+			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, item.Tags, "NOTICE", chname, item.Message)
 		case history.Tagmsg:
 			if eventPlayback {
-				rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "TAGMSG", chname, item.Message)
+				rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, item.Tags, "TAGMSG", chname, item.Message)
 			}
 		case history.Join:
 			if eventPlayback {
 				if extendedJoin {
-					rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "JOIN", chname, item.AccountName, item.Params[0])
+					rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "JOIN", chname, item.AccountName, item.Params[0])
 				} else {
-					rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "JOIN", chname)
+					rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "JOIN", chname)
 				}
 			} else {
 				if !playJoinsAsPrivmsg {
@@ -1157,48 +1161,48 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
 				} else {
 					message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName)
 				}
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		case history.Part:
 			if eventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "PART", chname, item.Message.Message)
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "PART", chname, item.Message.Message)
 			} else {
 				if !playJoinsAsPrivmsg {
 					continue // #474
 				}
 				message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message)
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		case history.Kick:
 			if eventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "KICK", chname, item.Params[0], item.Message.Message)
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "KICK", chname, item.Params[0], item.Message.Message)
 			} else {
 				message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message)
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		case history.Quit:
 			if eventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "QUIT", item.Message.Message)
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "QUIT", item.Message.Message)
 			} else {
 				if !playJoinsAsPrivmsg {
 					continue // #474
 				}
 				message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message)
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		case history.Nick:
 			if eventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "NICK", item.Params[0])
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "NICK", item.Params[0])
 			} else {
 				message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0])
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		case history.Topic:
 			if eventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "TOPIC", chname, item.Message.Message)
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "TOPIC", chname, item.Message.Message)
 			} else {
 				message := fmt.Sprintf(client.t("%[1]s set the channel topic to: %[2]s"), nick, item.Message.Message)
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		case history.Mode:
 			params := make([]string, len(item.Message.Split)+1)
@@ -1207,10 +1211,10 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
 				params[i+1] = pair.Message
 			}
 			if eventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "MODE", params...)
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "MODE", params...)
 			} else {
 				message := fmt.Sprintf(client.t("%[1]s set channel modes: %[2]s"), nick, strings.Join(params[1:], " "))
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", chname, message)
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
 			}
 		}
 	}
@@ -1268,12 +1272,13 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
 	channel.stateMutex.Unlock()
 
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 	message := utils.MakeMessage(topic)
-	rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "TOPIC", chname, topic)
+	rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "TOPIC", chname, topic)
 	for _, member := range channel.Members() {
 		for _, session := range member.Sessions() {
 			if session != rb.session {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "TOPIC", chname, topic)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "TOPIC", chname, topic)
 			}
 		}
 	}
@@ -1397,7 +1402,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 	rb.addEchoMessage(clientOnlyTags, details.nickMask, details.accountName, command, chname, message)
 
 	var cache MessageCache
-	cache.InitializeSplitMessage(channel.server, details.nickMask, details.accountName, clientOnlyTags, command, chname, message)
+	cache.InitializeSplitMessage(channel.server, details.nickMask, details.accountName, client.HasMode(modes.Bot), clientOnlyTags, command, chname, message)
 	for _, member := range channel.Members() {
 		if minPrefixMode != modes.Mode(0) && !channel.ClientIsAtLeast(member, minPrefixMode) {
 			// STATUSMSG or OpModerated
@@ -1519,23 +1524,25 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
 
 	message := utils.MakeMessage(comment)
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 
 	targetNick := target.Nick()
 	chname := channel.Name()
 	for _, member := range channel.Members() {
 		for _, session := range member.Sessions() {
 			if session != rb.session {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "KICK", chname, targetNick, comment)
 			}
 		}
 	}
-	rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
+	rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "KICK", chname, targetNick, comment)
 
 	histItem := history.Item{
 		Type:        history.Kick,
 		Nick:        details.nickMask,
 		AccountName: details.accountName,
 		Message:     message,
+		IsBot:       isBot,
 	}
 	histItem.Params[0] = targetNick
 	channel.AddHistoryItem(histItem, details.account)
@@ -1563,7 +1570,7 @@ func (channel *Channel) Purge(source string) {
 		tnick := member.Nick()
 		msgid := utils.GenerateSecretToken()
 		for _, session := range member.Sessions() {
-			session.sendFromClientInternal(false, now, msgid, source, "*", nil, "KICK", chname, tnick, member.t("This channel has been purged by the server administrators and cannot be used"))
+			session.sendFromClientInternal(false, now, msgid, source, "*", false, nil, "KICK", chname, tnick, member.t("This channel has been purged by the server administrators and cannot be used"))
 		}
 		member.removeChannel(channel)
 	}
@@ -1600,6 +1607,7 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf
 	}
 
 	details := inviter.Details()
+	isBot := inviter.HasMode(modes.Bot)
 	tDetails := invitee.Details()
 	tnick := invitee.Nick()
 	message := utils.MakeMessage(chname)
@@ -1614,13 +1622,15 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf
 		}
 		for _, session := range member.Sessions() {
 			if session.capabilities.Has(caps.InviteNotify) {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "INVITE", tnick, chname)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "INVITE", tnick, chname)
 			}
 		}
 	}
 
 	rb.Add(nil, inviter.server.name, RPL_INVITING, details.nick, tnick, chname)
-	invitee.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "INVITE", tnick, chname)
+	for _, iSession := range invitee.Sessions() {
+		iSession.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "INVITE", tnick, chname)
+	}
 	if away, awayMessage := invitee.Away(); away {
 		rb.Add(nil, inviter.server.name, RPL_AWAY, details.nick, tnick, awayMessage)
 	}
diff --git a/irc/chanserv.go b/irc/chanserv.go
index 5dfcf44e..13d9e9bf 100644
--- a/irc/chanserv.go
+++ b/irc/chanserv.go
@@ -271,7 +271,7 @@ func csAmodeHandler(service *ircService, server *Server, client *Client, command
 				if member.Account() == change.Arg {
 					applied, change := channel.applyModeToMember(client, change, rb)
 					if applied {
-						announceCmodeChanges(channel, modes.ModeChanges{change}, server.name, "*", "", rb)
+						announceCmodeChanges(channel, modes.ModeChanges{change}, server.name, "*", "", false, rb)
 					}
 				}
 			}
@@ -334,7 +334,7 @@ func csOpHandler(service *ircService, server *Server, client *Client, command st
 		},
 		rb)
 	if applied {
-		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, server.name, "*", "", rb)
+		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, server.name, "*", "", false, rb)
 	}
 
 	service.Notice(rb, client.t("Successfully granted operator privileges"))
@@ -386,7 +386,8 @@ func csDeopHandler(service *ircService, server *Server, client *Client, command
 	// the changes as coming from chanserv
 	applied := channel.ApplyChannelModeChanges(client, false, modeChanges, rb)
 	details := client.Details()
-	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, rb)
+	isBot := client.HasMode(modes.Bot)
+	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, isBot, rb)
 
 	if len(applied) == 0 {
 		return
@@ -437,7 +438,7 @@ func csRegisterHandler(service *ircService, server *Server, client *Client, comm
 		},
 		rb)
 	if applied {
-		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, service.prefix, "*", "", rb)
+		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, service.prefix, "*", "", false, rb)
 	}
 }
 
diff --git a/irc/client.go b/irc/client.go
index 0d275dcd..f88d4a83 100644
--- a/irc/client.go
+++ b/irc/client.go
@@ -1095,9 +1095,9 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
 				continue
 			}
 			if hasEventPlayback {
-				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "INVITE", nick, item.Message.Message)
+				rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "INVITE", nick, item.Message.Message)
 			} else {
-				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", nil, "PRIVMSG", fmt.Sprintf(client.t("%[1]s invited you to channel %[2]s"), NUHToNick(item.Nick), item.Message.Message))
+				rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", fmt.Sprintf(client.t("%[1]s invited you to channel %[2]s"), NUHToNick(item.Nick), item.Message.Message))
 			}
 			continue
 		case history.Privmsg:
@@ -1118,11 +1118,11 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
 			tags = item.Tags
 		}
 		if !isSelfMessage(&item) {
-			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message)
+			rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, tags, command, nick, item.Message)
 		} else {
 			// this message was sent *from* the client to another nick; the target is item.Params[0]
 			// substitute client's current nickmask in case client changed nick
-			rb.AddSplitMessageFromClient(details.nickMask, item.AccountName, tags, command, item.Params[0], item.Message)
+			rb.AddSplitMessageFromClient(details.nickMask, item.AccountName, item.IsBot, tags, command, item.Params[0], item.Message)
 		}
 	}
 
@@ -1244,8 +1244,9 @@ func (client *Client) SetOper(oper *Oper) {
 // this is annoying to do correctly
 func (client *Client) sendChghost(oldNickMask string, vhost string) {
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 	for fClient := range client.Friends(caps.ChgHost) {
-		fClient.sendFromClientInternal(false, time.Time{}, "", oldNickMask, details.accountName, nil, "CHGHOST", details.username, vhost)
+		fClient.sendFromClientInternal(false, time.Time{}, "", oldNickMask, details.accountName, isBot, nil, "CHGHOST", details.username, vhost)
 	}
 }
 
@@ -1594,14 +1595,16 @@ func (client *Client) destroy(session *Session) {
 		quitMessage = "Exited"
 	}
 	splitQuitMessage := utils.MakeMessage(quitMessage)
+	isBot := client.HasMode(modes.Bot)
 	quitItem = history.Item{
 		Type:        history.Quit,
 		Nick:        details.nickMask,
 		AccountName: details.accountName,
 		Message:     splitQuitMessage,
+		IsBot:       isBot,
 	}
 	var cache MessageCache
-	cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage)
+	cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "QUIT", quitMessage)
 	for friend := range friends {
 		for _, session := range friend.Sessions() {
 			cache.Send(session)
@@ -1615,12 +1618,12 @@ func (client *Client) destroy(session *Session) {
 
 // SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client.
 // Adds account-tag to the line as well.
-func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask, accountName string, tags map[string]string, command, target string, message utils.SplitMessage) {
+func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask, accountName string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) {
 	if message.Is512() {
-		session.sendFromClientInternal(blocking, message.Time, message.Msgid, nickmask, accountName, tags, command, target, message.Message)
+		session.sendFromClientInternal(blocking, message.Time, message.Msgid, nickmask, accountName, isBot, tags, command, target, message.Message)
 	} else {
 		if session.capabilities.Has(caps.Multiline) {
-			for _, msg := range composeMultilineBatch(session.generateBatchID(), nickmask, accountName, tags, command, target, message) {
+			for _, msg := range composeMultilineBatch(session.generateBatchID(), nickmask, accountName, isBot, tags, command, target, message) {
 				session.SendRawMessage(msg, blocking)
 			}
 		} else {
@@ -1634,24 +1637,13 @@ func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask,
 					msgidSent = true
 					msgid = message.Msgid
 				}
-				session.sendFromClientInternal(blocking, message.Time, msgid, nickmask, accountName, tags, command, target, messagePair.Message)
+				session.sendFromClientInternal(blocking, message.Time, msgid, nickmask, accountName, isBot, tags, command, target, messagePair.Message)
 			}
 		}
 	}
 }
 
-// Sends a line with `nickmask` as the prefix, adding `time` and `account` tags if supported
-func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags map[string]string, command string, params ...string) (err error) {
-	for _, session := range client.Sessions() {
-		err_ := session.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, params...)
-		if err_ != nil {
-			err = err_
-		}
-	}
-	return
-}
-
-func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags map[string]string, command string, params ...string) (err error) {
+func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, isBot bool, tags map[string]string, command string, params ...string) (err error) {
 	msg := ircmsg.MakeMessage(tags, nickmask, command, params...)
 	// attach account-tag
 	if session.capabilities.Has(caps.AccountTag) && accountName != "*" {
@@ -1663,17 +1655,24 @@ func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Ti
 	}
 	// attach server-time
 	session.setTimeTag(&msg, serverTime)
+	// attach bot tag
+	if isBot && session.capabilities.Has(caps.MessageTags) {
+		msg.SetTag(caps.BotTagName, "")
+	}
 
 	return session.SendRawMessage(msg, blocking)
 }
 
-func composeMultilineBatch(batchID, fromNickMask, fromAccount string, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) {
+func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) {
 	batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType, target)
 	batchStart.SetTag("time", message.Time.Format(IRCv3TimestampFormat))
 	batchStart.SetTag("msgid", message.Msgid)
 	if fromAccount != "*" {
 		batchStart.SetTag("account", fromAccount)
 	}
+	if isBot {
+		batchStart.SetTag(caps.BotTagName, "")
+	}
 	result = append(result, batchStart)
 
 	for _, msg := range message.Split {
diff --git a/irc/handlers.go b/irc/handlers.go
index ac417a6d..ddd4d9c1 100644
--- a/irc/handlers.go
+++ b/irc/handlers.go
@@ -367,11 +367,12 @@ func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
 func dispatchAwayNotify(client *Client, isAway bool, awayMessage string) {
 	// dispatch away-notify
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 	for session := range client.Friends(caps.AwayNotify) {
 		if isAway {
-			session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, nil, "AWAY", awayMessage)
+			session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY", awayMessage)
 		} else {
-			session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, nil, "AWAY")
+			session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY")
 		}
 	}
 }
@@ -1689,12 +1690,13 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
 	// process mode changes, include list operations (an empty set of changes does a list)
 	applied := channel.ApplyChannelModeChanges(client, msg.Command == "SAMODE", changes, rb)
 	details := client.Details()
-	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, rb)
+	isBot := client.HasMode(modes.Bot)
+	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, isBot, rb)
 
 	return false
 }
 
-func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName, account string, rb *ResponseBuffer) {
+func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName, account string, isBot bool, rb *ResponseBuffer) {
 	// send out changes
 	if len(applied) > 0 {
 		message := utils.MakeMessage("")
@@ -1703,11 +1705,11 @@ func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, a
 			message.Split = append(message.Split, utils.MessagePair{Message: changeString})
 		}
 		args := append([]string{channel.name}, changeStrings...)
-		rb.AddFromClient(message.Time, message.Msgid, source, accountName, nil, "MODE", args...)
+		rb.AddFromClient(message.Time, message.Msgid, source, accountName, isBot, nil, "MODE", args...)
 		for _, member := range channel.Members() {
 			for _, session := range member.Sessions() {
 				if session != rb.session {
-					session.sendFromClientInternal(false, message.Time, message.Msgid, source, accountName, nil, "MODE", args...)
+					session.sendFromClientInternal(false, message.Time, message.Msgid, source, accountName, isBot, nil, "MODE", args...)
 				}
 			}
 		}
@@ -1716,6 +1718,7 @@ func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, a
 			Nick:        source,
 			AccountName: accountName,
 			Message:     message,
+			IsBot:       isBot,
 		}, account)
 	}
 }
@@ -2204,17 +2207,18 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
 			}
 		}
 
+		isBot := client.HasMode(modes.Bot)
 		for _, session := range deliverySessions {
 			hasTagsCap := session.capabilities.Has(caps.MessageTags)
 			// don't send TAGMSG at all if they don't have the tags cap
 			if histType == history.Tagmsg && hasTagsCap {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, isBot, tags, command, tnick)
 			} else if histType != history.Tagmsg && !(session.isTor && message.IsRestrictedCTCPMessage()) {
 				tagsToSend := tags
 				if !hasTagsCap {
 					tagsToSend = nil
 				}
-				session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, tagsToSend, command, tnick, message)
+				session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, isBot, tagsToSend, command, tnick, message)
 			}
 		}
 
@@ -2674,9 +2678,9 @@ func relaymsgHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
 			}
 
 			if session == rb.session {
-				rb.AddSplitMessageFromClient(nick, "*", tagsToUse, "PRIVMSG", channelName, message)
+				rb.AddSplitMessageFromClient(nick, "*", false, tagsToUse, "PRIVMSG", channelName, message)
 			} else {
-				session.sendSplitMsgFromClientInternal(false, nick, "*", tagsToUse, "PRIVMSG", channelName, message)
+				session.sendSplitMsgFromClientInternal(false, nick, "*", false, tagsToUse, "PRIVMSG", channelName, message)
 			}
 		}
 	}
@@ -2835,11 +2839,12 @@ func setnameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp
 	now := time.Now().UTC()
 	friends := client.Friends(caps.SetName)
 	delete(friends, rb.session)
+	isBot := client.HasMode(modes.Bot)
 	for session := range friends {
-		session.sendFromClientInternal(false, now, "", details.nickMask, details.accountName, nil, "SETNAME", details.realname)
+		session.sendFromClientInternal(false, now, "", details.nickMask, details.accountName, isBot, nil, "SETNAME", details.realname)
 	}
 	// respond to the user unconditionally, even if they don't have the cap
-	rb.AddFromClient(now, "", details.nickMask, details.accountName, nil, "SETNAME", details.realname)
+	rb.AddFromClient(now, "", details.nickMask, details.accountName, isBot, nil, "SETNAME", details.realname)
 	return false
 }
 
diff --git a/irc/history/history.go b/irc/history/history.go
index 990f7302..981108d8 100644
--- a/irc/history/history.go
+++ b/irc/history/history.go
@@ -45,6 +45,7 @@ type Item struct {
 	// an incoming or outgoing message). this lets us emulate the "query buffer" functionality
 	// required by CHATHISTORY:
 	CfCorrespondent string
+	IsBot           bool `json:"IsBot,omitempty"`
 }
 
 // HasMsgid tests whether a message has the message id `msgid`.
diff --git a/irc/message_cache.go b/irc/message_cache.go
index 0bb8e2bd..4efe8234 100644
--- a/irc/message_cache.go
+++ b/irc/message_cache.go
@@ -35,6 +35,7 @@ type MessageCache struct {
 	tags        map[string]string
 	source      string
 	command     string
+	isBot       bool
 
 	params []string
 
@@ -42,7 +43,7 @@ type MessageCache struct {
 	splitMessage utils.SplitMessage
 }
 
-func addAllTags(msg *ircmsg.Message, tags map[string]string, serverTime time.Time, msgid, accountName string) {
+func addAllTags(msg *ircmsg.Message, tags map[string]string, serverTime time.Time, msgid, accountName string, isBot bool) {
 	msg.UpdateTags(tags)
 	msg.SetTag("time", serverTime.Format(IRCv3TimestampFormat))
 	if accountName != "*" {
@@ -51,6 +52,9 @@ func addAllTags(msg *ircmsg.Message, tags map[string]string, serverTime time.Tim
 	if msgid != "" {
 		msg.SetTag("msgid", msgid)
 	}
+	if isBot {
+		msg.SetTag(caps.BotTagName, "")
+	}
 }
 
 func (m *MessageCache) handleErr(server *Server, err error) bool {
@@ -64,11 +68,12 @@ func (m *MessageCache) handleErr(server *Server, err error) bool {
 	return false
 }
 
-func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid string, nickmask, accountName string, tags map[string]string, command string, params ...string) (err error) {
+func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid string, nickmask, accountName string, isBot bool, tags map[string]string, command string, params ...string) (err error) {
 	m.time = serverTime
 	m.msgid = msgid
 	m.source = nickmask
 	m.accountName = accountName
+	m.isBot = isBot
 	m.tags = tags
 	m.command = command
 	m.params = params
@@ -87,7 +92,7 @@ func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid st
 		return
 	}
 
-	addAllTags(&msg, tags, serverTime, msgid, accountName)
+	addAllTags(&msg, tags, serverTime, msgid, accountName, isBot)
 	m.fullTags, err = msg.LineBytesStrict(false, MaxLineLen)
 	if m.handleErr(server, err) {
 		return
@@ -95,11 +100,12 @@ func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid st
 	return
 }
 
-func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountName string, tags map[string]string, command, target string, message utils.SplitMessage) (err error) {
+func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountName string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (err error) {
 	m.time = message.Time
 	m.msgid = message.Msgid
 	m.source = nickmask
 	m.accountName = accountName
+	m.isBot = isBot
 	m.tags = tags
 	m.command = command
 	m.target = target
@@ -130,7 +136,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
 			}
 		}
 
-		addAllTags(&msg, tags, message.Time, message.Msgid, accountName)
+		addAllTags(&msg, tags, message.Time, message.Msgid, accountName, isBot)
 		m.fullTags, err = msg.LineBytesStrict(false, MaxLineLen)
 		if m.handleErr(server, err) {
 			return
@@ -158,7 +164,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
 		// so a collision isn't expected until there are on the order of 2**32
 		// concurrent batches being relayed:
 		batchID := utils.GenerateSecretToken()[:utils.SecretTokenLength/2]
-		batch := composeMultilineBatch(batchID, nickmask, accountName, tags, command, target, message)
+		batch := composeMultilineBatch(batchID, nickmask, accountName, isBot, tags, command, target, message)
 		m.fullTagsMultiline = make([][]byte, len(batch))
 		for i, msg := range batch {
 			if forceTrailing {
@@ -184,7 +190,7 @@ func (m *MessageCache) Send(session *Session) {
 				session.sendBytes(m.plain, false)
 			} else {
 				// slowpath
-				session.sendFromClientInternal(false, m.time, m.msgid, m.source, m.accountName, nil, m.command, m.params...)
+				session.sendFromClientInternal(false, m.time, m.msgid, m.source, m.accountName, m.isBot, nil, m.command, m.params...)
 			}
 		}
 	} else if m.fullTagsMultiline != nil {
@@ -199,7 +205,7 @@ func (m *MessageCache) Send(session *Session) {
 			}
 		} else {
 			// slowpath
-			session.sendSplitMsgFromClientInternal(false, m.source, m.accountName, m.tags, m.command, m.target, m.splitMessage)
+			session.sendSplitMsgFromClientInternal(false, m.source, m.accountName, m.isBot, m.tags, m.command, m.target, m.splitMessage)
 		}
 	}
 }
diff --git a/irc/nickname.go b/irc/nickname.go
index 180f9953..ef2d353d 100644
--- a/irc/nickname.go
+++ b/irc/nickname.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/goshuirc/irc-go/ircfmt"
 	"github.com/oragono/oragono/irc/history"
+	"github.com/oragono/oragono/irc/modes"
 	"github.com/oragono/oragono/irc/sno"
 	"github.com/oragono/oragono/irc/utils"
 )
@@ -101,10 +102,11 @@ func performNickChange(server *Server, client *Client, target *Client, session *
 			target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("Operator %s changed nickname of $%s$r to %s"), client.Nick(), details.nick, assignedNickname))
 		}
 		target.server.whoWas.Append(details.WhoWas)
-		rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", assignedNickname)
+		isBot := !isSanick && client.HasMode(modes.Bot)
+		rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, isBot, nil, "NICK", assignedNickname)
 		for session := range target.Friends() {
 			if session != rb.session {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", assignedNickname)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, origNickMask, details.accountName, isBot, nil, "NICK", assignedNickname)
 			}
 		}
 	}
diff --git a/irc/responsebuffer.go b/irc/responsebuffer.go
index f7c7d228..7e071f99 100644
--- a/irc/responsebuffer.go
+++ b/irc/responsebuffer.go
@@ -96,7 +96,7 @@ func (rb *ResponseBuffer) Broadcast(tags map[string]string, prefix string, comma
 }
 
 // AddFromClient adds a new message from a specific client to our queue.
-func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMask string, fromAccount string, tags map[string]string, command string, params ...string) {
+func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, params ...string) {
 	msg := ircmsg.MakeMessage(nil, fromNickMask, command, params...)
 	if rb.session.capabilities.Has(caps.MessageTags) {
 		msg.UpdateTags(tags)
@@ -107,8 +107,13 @@ func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMa
 		msg.SetTag("account", fromAccount)
 	}
 	// attach message-id
-	if len(msgid) > 0 && rb.session.capabilities.Has(caps.MessageTags) {
-		msg.SetTag("msgid", msgid)
+	if rb.session.capabilities.Has(caps.MessageTags) {
+		if len(msgid) != 0 {
+			msg.SetTag("msgid", msgid)
+		}
+		if isBot {
+			msg.SetTag(caps.BotTagName, "")
+		}
 	}
 	// attach server-time
 	rb.session.setTimeTag(&msg, time)
@@ -117,17 +122,17 @@ func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMa
 }
 
 // AddSplitMessageFromClient adds a new split message from a specific client to our queue.
-func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, tags map[string]string, command string, target string, message utils.SplitMessage) {
+func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, target string, message utils.SplitMessage) {
 	if message.Is512() {
 		if message.Message == "" {
 			// XXX this is a TAGMSG
-			rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, tags, command, target)
+			rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, isBot, tags, command, target)
 		} else {
-			rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message)
+			rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, isBot, tags, command, target, message.Message)
 		}
 	} else {
 		if rb.session.capabilities.Has(caps.Multiline) {
-			batch := composeMultilineBatch(rb.session.generateBatchID(), fromNickMask, fromAccount, tags, command, target, message)
+			batch := composeMultilineBatch(rb.session.generateBatchID(), fromNickMask, fromAccount, isBot, tags, command, target, message)
 			rb.setNestedBatchTag(&batch[0])
 			rb.setNestedBatchTag(&batch[len(batch)-1])
 			rb.messages = append(rb.messages, batch...)
@@ -137,25 +142,26 @@ func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAcc
 				if i == 0 {
 					msgid = message.Msgid
 				}
-				rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message)
+				rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, isBot, tags, command, target, messagePair.Message)
 			}
 		}
 	}
 }
 
 func (rb *ResponseBuffer) addEchoMessage(tags map[string]string, nickMask, accountName, command, target string, message utils.SplitMessage) {
+	// TODO fix isBot here
 	if rb.session.capabilities.Has(caps.EchoMessage) {
 		hasTagsCap := rb.session.capabilities.Has(caps.MessageTags)
 		if command == "TAGMSG" {
 			if hasTagsCap {
-				rb.AddFromClient(message.Time, message.Msgid, nickMask, accountName, tags, command, target)
+				rb.AddFromClient(message.Time, message.Msgid, nickMask, accountName, false, tags, command, target)
 			}
 		} else {
 			tagsToSend := tags
 			if !hasTagsCap {
 				tagsToSend = nil
 			}
-			rb.AddSplitMessageFromClient(nickMask, accountName, tagsToSend, command, target, message)
+			rb.AddSplitMessageFromClient(nickMask, accountName, false, tagsToSend, command, target, message)
 		}
 	}
 }
diff --git a/irc/roleplay.go b/irc/roleplay.go
index ab22d28d..246112ab 100644
--- a/irc/roleplay.go
+++ b/irc/roleplay.go
@@ -92,15 +92,16 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
 			return
 		}
 
+		isBot := client.HasMode(modes.Bot)
 		for _, member := range channel.Members() {
 			for _, session := range member.Sessions() {
 				// see discussion on #865: clients do not understand how to do local echo
 				// of roleplay commands, so send them a copy whether they have echo-message
 				// or not
 				if rb.session == session {
-					rb.AddSplitMessageFromClient(sourceMask, "", nil, "PRIVMSG", targetString, splitMessage)
+					rb.AddSplitMessageFromClient(sourceMask, "*", isBot, nil, "PRIVMSG", targetString, splitMessage)
 				} else {
-					session.sendSplitMsgFromClientInternal(false, sourceMask, "*", nil, "PRIVMSG", targetString, splitMessage)
+					session.sendSplitMsgFromClientInternal(false, sourceMask, "*", isBot, nil, "PRIVMSG", targetString, splitMessage)
 				}
 			}
 		}
@@ -125,8 +126,9 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
 
 		cnick := client.Nick()
 		tnick := user.Nick()
+		isBot := client.HasMode(modes.Bot)
 		for _, session := range user.Sessions() {
-			session.sendSplitMsgFromClientInternal(false, sourceMask, "*", nil, "PRIVMSG", tnick, splitMessage)
+			session.sendSplitMsgFromClientInternal(false, sourceMask, "*", isBot, nil, "PRIVMSG", tnick, splitMessage)
 		}
 		if away, awayMessage := user.Away(); away {
 			//TODO(dan): possibly implement cooldown of away notifications to users

From 88b877fce409162b19dc3208223dccc83ed3362f Mon Sep 17 00:00:00 2001
From: Shivaram Lingamneni <slingamn@cs.stanford.edu>
Date: Wed, 17 Mar 2021 19:01:38 -0400
Subject: [PATCH 2/2] ensure IsBot gets propagated into history

---
 irc/channel.go  | 5 ++++-
 irc/nickname.go | 3 ++-
 2 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/irc/channel.go b/irc/channel.go
index c32785dd..21326977 100644
--- a/irc/channel.go
+++ b/irc/channel.go
@@ -1288,6 +1288,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
 		Nick:        details.nickMask,
 		AccountName: details.accountName,
 		Message:     message,
+		IsBot:       isBot,
 	}, details.account)
 
 	channel.MarkDirty(IncludeTopic)
@@ -1370,6 +1371,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 	}
 
 	details := client.Details()
+	isBot := client.HasMode(modes.Bot)
 	chname := channel.Name()
 
 	if !client.server.Config().Server.Compatibility.allowTruncation {
@@ -1402,7 +1404,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 	rb.addEchoMessage(clientOnlyTags, details.nickMask, details.accountName, command, chname, message)
 
 	var cache MessageCache
-	cache.InitializeSplitMessage(channel.server, details.nickMask, details.accountName, client.HasMode(modes.Bot), clientOnlyTags, command, chname, message)
+	cache.InitializeSplitMessage(channel.server, details.nickMask, details.accountName, isBot, clientOnlyTags, command, chname, message)
 	for _, member := range channel.Members() {
 		if minPrefixMode != modes.Mode(0) && !channel.ClientIsAtLeast(member, minPrefixMode) {
 			// STATUSMSG or OpModerated
@@ -1430,6 +1432,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 			Nick:        details.nickMask,
 			AccountName: details.accountName,
 			Tags:        clientOnlyTags,
+			IsBot:       isBot,
 		}, details.account)
 	}
 }
diff --git a/irc/nickname.go b/irc/nickname.go
index ef2d353d..33fdbda9 100644
--- a/irc/nickname.go
+++ b/irc/nickname.go
@@ -85,12 +85,14 @@ func performNickChange(server *Server, client *Client, target *Client, session *
 		return err
 	}
 
+	isBot := !isSanick && client.HasMode(modes.Bot)
 	message := utils.MakeMessage("")
 	histItem := history.Item{
 		Type:        history.Nick,
 		Nick:        origNickMask,
 		AccountName: details.accountName,
 		Message:     message,
+		IsBot:       isBot,
 	}
 	histItem.Params[0] = assignedNickname
 
@@ -102,7 +104,6 @@ func performNickChange(server *Server, client *Client, target *Client, session *
 			target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("Operator %s changed nickname of $%s$r to %s"), client.Nick(), details.nick, assignedNickname))
 		}
 		target.server.whoWas.Append(details.WhoWas)
-		isBot := !isSanick && client.HasMode(modes.Bot)
 		rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, isBot, nil, "NICK", assignedNickname)
 		for session := range target.Friends() {
 			if session != rb.session {