Subject: bug#68861: 30.0.50; ERC 5.x: Introduce a modern message-insertion API

From: "J.P." <[email-masked]>
Date: Mon, 04 Mar 2024 19:03:29 -0800

"J.P." <[email-masked]> writes:

> In terms of proposals, I think a successful candidate for adoption
> should provide at least two working demos, preferably among the
> following:
>
>   - Bidirectional input and display
>   - Headline style messages w. speaker on a separate line
>   - Text substitution (language translation, encryption, etc.)
>   - Display names (e.g., for bridges, perhaps reversible)
>
> My guess is this feature won't be ready for ERC 5.6, especially without
> explicit interest from others. As such, proposals (from me) may be based
> atop work that includes changes not currently on HEAD, such as those
> from #49860.

In the absence of a coherent proposal for a revamped message preparation
and insertion API, I believe it may be wise to collect counterexamples
exhibiting some of the desperate and ugly measures taken to access the
various components of a message and influence its assembly for display.
The objective here is to emphasize the considerable shortcomings
currently on offer in this regard.

To that end, I've attached an adapted version of a POC from another bug
(#49860) as a working, practical example of the problem. It attempts to
introduce speaker display names for bridge bots by way of a new module.
To be clear, this is not being proposed for inclusion in ERC 5.6 (or any
subsequent version). Rather, its purpose is in keeping with the
aforementioned goal of underscoring the deficiencies of the current
situation. Of particular note is the fourth patch and the terrible
lengths it goes to in order to access and manipulate the content portion
of chat messages before they're fused to their leading "<speaker> "
prefixes.

Folks at home: even if you don't try this patch set, please look at the
module setup code and the interfaces it consumes. And please do post
your own scenarios here. They need not come in the form of patches or
include any working code.

Thanks.

>From 6adc6c808367ba34ce72e9d80be67db9c8a62349 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Sat, 6 Jan 2024 12:57:27 -0800
Subject: [PATCH 1/5] [5.x] Add predicate for logical ERC module activation

* lisp/erc/erc.el (erc--module-active-p): New function, a utility for
simplifying the loading of module dependencies.
* test/lisp/erc/erc-tests.el (erc--module-active-p):
Add test.
---
 lisp/erc/erc.el            |  9 +++++++++
 test/lisp/erc/erc-tests.el | 33 +++++++++++++++++++++++++++++++++
 2 files changed, 42 insertions(+)

diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index cce3b2508fb..e0c0d1a4828 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2340,6 +2340,15 @@ erc--find-mode
          (fboundp mode)
          mode)))
 
+(defun erc--module-active-p (symbol &optional ensurep)
+  "Return non-nil if module SYMBOL has been loaded or will be.
+With ENSUREP, enable the module's minor mode before returning."
+  (let ((mode-var (intern-soft (concat "erc-" (symbol-name symbol) "-mode"))))
+    (and (or (and mode-var (boundp mode-var) (symbol-value mode-var))
+             (and (memq symbol erc-modules)
+                  (setq mode-var (erc--find-mode symbol))))
+         (or (not ensurep) (always (funcall mode-var +1))))))
+
 (defun erc--update-modules (modules)
   (let (local-modes)
     (dolist (module modules local-modes)
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 085b063bdb2..36e6fcd949e 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -3287,6 +3287,39 @@ erc--find-mode
                              (string< (symbol-name a) (symbol-name b))))))
    erc-tests--modules))
 
+(ert-deftest erc--module-active-p ()
+  (should-not (erc--module-active-p 'fake))
+  (should-not (erc--module-active-p 'fake :ensurep))
+
+  (should (featurep 'erc-networks))
+
+  (let (after-load-alist calls)
+
+    (cl-letf (((symbol-function 'erc-networks-mode)
+               (lambda (&rest r) (push r calls))))
+
+      (let (erc-networks-mode erc-modules)
+        (should-not (erc--module-active-p 'networks))
+        (should-not (erc--module-active-p 'networks :ensurep))
+        (should-not calls))
+
+      (let ((erc-networks-mode t)
+            erc-modules)
+        (should (erc--module-active-p 'networks))
+        (should-not calls)
+        (should (erc--module-active-p 'networks :ensurep))
+        (should (equal calls '((1))))
+        (setq calls nil))
+
+      (let ((erc-networks-mode nil)
+            (erc-modules '(networks)))
+        (should (erc--module-active-p 'networks))
+        (should-not calls)
+
+        (should (erc--module-active-p 'networks :ensurep))
+        (should (equal calls '((1))))
+        (setq calls nil)))))
+
 (ert-deftest erc--essential-hook-ordering ()
   (erc-tests--assert-printed-in-subprocess
    '(progn
-- 
2.44.0

>From 767f776587af39a7751d9528eaf28142fcc1bf42 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Thu, 8 Feb 2024 20:28:56 -0800
Subject: [PATCH 2/5] [5.x] Indirectly call erc-fill--wrap-continued-message-p

* lisp/erc/erc-fill.el (erc-fill--wrap-continued-predicate): Add
function-valued variable for other modules to influence the behavior
of `erc-fill--wrap-continued-message-p', which was originally
introduced as part of bug#60936.
(erc-fill-wrap): Use `erc-fill--wrap-continued-predicate'.
---
 lisp/erc/erc-fill.el | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/lisp/erc/erc-fill.el b/lisp/erc/erc-fill.el
index aa12b807fbc..e4e73846fc4 100644
--- a/lisp/erc/erc-fill.el
+++ b/lisp/erc/erc-fill.el
@@ -659,6 +659,9 @@ erc-fill--wrap-insert-merged-pre
       (cdr (setq erc-fill--wrap-merge-indicator-pre
                  (cons s (erc-fill--wrap-measure (point-min) (point))))))))
 
+(defvar erc-fill--wrap-continued-predicate #'erc-fill--wrap-continued-message-p
+  "Function called with no args to detect a continued speaker.")
+
 (defun erc-fill-wrap ()
   "Use text props to mimic the effect of `erc-fill-static'.
 See `erc-fill-wrap-mode' for details."
@@ -689,7 +692,7 @@ erc-fill-wrap
                                 (prog1 (erc-fill--wrap-measure beg (point))
                                   (delete-region (1- (point)) (point))))))
                            ((and erc-fill-wrap-merge
-                                 (erc-fill--wrap-continued-message-p))
+                                 (funcall erc-fill--wrap-continued-predicate))
                             (put-text-property (point-min) (point)
                                                'display "")
                             (if erc-fill-wrap-merge-indicator
-- 
2.44.0

>From 7e8ce6ba936cbb7739ceac569404263073e23c83 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Mon, 4 Mar 2024 17:36:25 -0800
Subject: [PATCH 3/5] [5.x] Expose erc-response object as dynamic variable

* lisp/erc/erc-backend.el (erc--parsed-response): New variable.
(erc-call-hooks): Bind `erc--parsed-response' to the parsed
`erc-response' object for the duration of handler hook execution.
* lisp/erc/erc.el (erc-message-parsed): Alias to `erc--parsed-message'
and deprecate.
---
 lisp/erc/erc-backend.el | 5 ++++-
 lisp/erc/erc.el         | 3 ++-
 2 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 9fc8a4d29f4..00ea4e13788 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1529,11 +1529,14 @@ erc-get-hook
   (gethash (format (if (numberp command) "%03i" "%s") command)
            erc-server-responses))
 
+(defvar erc--parsed-response nil)
+
 (defun erc-call-hooks (process message)
   "Call hooks associated with MESSAGE in PROCESS.
 
 Finds hooks by looking in the `erc-server-responses' hash table."
-  (let ((hook (or (erc-get-hook (erc-response.command message))
+  (let ((erc--parsed-response message)
+        (hook (or (erc-get-hook (erc-response.command message))
                   'erc-default-server-functions)))
     (run-hook-with-args-until-success hook process message)
     (erc-with-server-buffer
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index e0c0d1a4828..8b8787f8a55 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -142,7 +142,8 @@ erc-scripts
 ;;;###autoload                   erc-imenu erc-nicks)) ; 30
 ;;;###autoload  (custom-add-load symbol symbol))
 
-(defvar erc-message-parsed) ; only known to this file
+(define-obsolete-variable-alias 'erc-message-parsed 'erc--parsed-response
+  "30.1")
 
 (defvar erc--msg-props nil
   "Hash table containing metadata properties for current message.
-- 
2.44.0

>From 8f8ecbc2aec388f35d8c76a516f6383c2afb2d1e Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Wed, 7 Feb 2024 05:03:55 -0800
Subject: [PATCH 4/5] [POC] Replace erc--cmem-from-nick-function args with
 struct

* lisp/erc/erc-backend.el (erc-server-PRIVMSG): Create
`erc--msg-parts' object as input for `erc--cmem-from-nick-function'.
Assign contents of `msg' slot to static `msg' variable after the
latter runs.
* lisp/erc/erc-button.el (erc-button--add-phantom-speaker): Update to
expect `erc--msg-parts' object.
* lisp/erc/erc-common.el (erc--nonempty-str): New helper function.
(erc--msg-parts): New struct.
* lisp/erc/erc.el (erc--cmem-from-nick-function): Replace loose
parameters with single struct.
(erc--cmem-get-existing): Update function to expect `erc--msg-parts'
object.
---
 lisp/erc/erc-backend.el |  5 +++--
 lisp/erc/erc-button.el  |  8 ++++----
 lisp/erc/erc-common.el  | 20 ++++++++++++++++++++
 lisp/erc/erc.el         | 20 +++++++++++---------
 4 files changed, 38 insertions(+), 15 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 00ea4e13788..227c2ab2aac 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -2049,10 +2049,11 @@ erc--speaker-status-prefix-wanted-p
             (defvar erc-format-nick-function)
             (defvar erc-show-speaker-membership-status)
             (defvar erc-speaker-from-channel-member-function)
-            (let ((cdata (funcall erc--cmem-from-nick-function
-                                  (erc-downcase nick) sndr parsed)))
+            (let* ((parts (erc--msg-parts-from-nuh :nuh sndr :parsed parsed))
+                   (cdata (funcall erc--cmem-from-nick-function parts)))
               (setq fnick (funcall erc-speaker-from-channel-member-function
                                    (car cdata) (cdr cdata))
+                    msg (erc--msg-parts-msg parts)
                     cmem-prefix (and (or erc--speaker-status-prefix-wanted-p
                                          erc-show-speaker-membership-status
                                          inputp)
diff --git a/lisp/erc/erc-button.el b/lisp/erc/erc-button.el
index 6b78e451b54..b5a8a211448 100644
--- a/lisp/erc/erc-button.el
+++ b/lisp/erc/erc-button.el
@@ -415,9 +415,9 @@ erc-button--get-user-from-spkr-prop
 (cl-defstruct (erc--phantom-channel-user (:include erc-channel-user)))
 (cl-defstruct (erc--phantom-server-user (:include erc-server-user)))
 
-(defun erc-button--add-phantom-speaker (downcased nuh _parsed)
-  (pcase-let* ((`(,nick ,login ,host) nuh)
-               (cmem (gethash downcased erc-button--phantom-cmems))
+(defun erc-button--add-phantom-speaker (parts)
+  (pcase-let* (((cl-struct erc--msg-parts nick login host key) parts)
+               (cmem (gethash key erc-button--phantom-cmems))
                (user (or (car cmem)
                          (make-erc--phantom-server-user
                           :nickname nick
@@ -426,7 +426,7 @@ erc-button--add-phantom-speaker
                (cuser (or (cdr cmem)
                           (make-erc--phantom-channel-user
                            :last-message-time (current-time)))))
-    (puthash downcased (cons user cuser) erc-button--phantom-cmems)
+    (puthash key (cons user cuser) erc-button--phantom-cmems)
     (cons user cuser)))
 
 (defun erc-button--get-phantom-cmem (down _word _bounds _count)
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index 8388efe062c..dec79ff6acd 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -169,6 +169,26 @@ erc--isupport-data
   (table (make-char-table 'erc--channel-mode-types) :type char-table)
   (shortargs (make-hash-table :test #'equal)))
 
+(cl-defstruct (erc--msg-parts
+               (:constructor erc--msg-parts-from-nuh
+                             (&key nuh parsed &aux
+                                   (nick (nth 0 nuh))
+                                   (msg (erc-response.contents parsed))
+                                   (login (erc--nonempty-str (nth 1 nuh)))
+                                   (host (erc--nonempty-str (nth 2 nuh)))
+                                   (key (erc-downcase nick)))))
+  (key "" :type string :documentation "Downcased nick.")
+  (msg "" :type string :documentation "Message body.")
+  (nick "" :type string)
+  (login nil :type (or null string))
+  (host nil :type (or null string))
+  (parsed (make-erc-response) :type erc-response))
+
+(define-inline erc--nonempty-str (string)
+  "Evaluate and return nonempty STRING if it's well and truly thus."
+  (inline-letevals (string)
+    (inline-quote (and ,string (not (string-empty-p ,string)) ,string))))
+
 ;; After dropping 28, we can use prefixed "erc-autoload" cookies.
 (defun erc--normalize-module-symbol (symbol)
   "Return preferred SYMBOL for `erc--module'."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 8b8787f8a55..5aa9343fa20 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -5954,15 +5954,17 @@ erc--get-speaker-bounds
     (cons beg (next-single-property-change beg 'erc--speaker))))
 
 (defvar erc--cmem-from-nick-function #'erc--cmem-get-existing
-  "Function maybe returning a \"channel member\" cons from a nick.
-Must return either nil or a cons of an `erc-server-user' and an
-`erc-channel-user' (see `erc-channel-users') for use in
-formatting a user's nick prior to insertion.  Called in the
-appropriate target buffer with the downcased nick, the parsed
-NUH, and the current `erc-response' object.")
-
-(defun erc--cmem-get-existing (downcased _nuh _parsed)
-  (and erc-channel-users (gethash downcased erc-channel-users)))
+  "Function returning a \"channel member\" cons cell.
+Called with an `erc--msg-parts' object derived from sender info.  Must
+return either nil or a \"cmem\" cons cell consisting of an
+`erc-server-user' and an `erc-channel-user' (see `erc-channel-members')
+for use in formatting a nick for insertion.  Runs after the sender's
+`erc-channel-members' entry has been created or updated.")
+
+(defun erc--cmem-get-existing (parts)
+  "Return `erc-channel-members' entry from `erc--msg-parts' PARTS."
+  (and erc-channel-members
+       (gethash (erc--msg-parts-key parts) erc-channel-members)))
 
 (defun erc-format-privmessage (nick msg privp msgp)
   "Format a PRIVMSG in an insertable fashion."
-- 
2.44.0

>From 027b6ef23178832ffd9e29233d0ac5161b7b953c Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Sun, 17 Jul 2022 02:21:42 -0700
Subject: [PATCH 5/5] [POC] Add general display-name support to ERC

* lisp/erc/erc-masquerade.el: New file.
* test/lisp/erc/erc-masquerade-tests.el: New file.
* test/lisp/erc/erc-scenarios-masquerade.el: New file.
* test/lisp/erc/resources/masquerade/batch.eld: New file.
---
 lisp/erc/erc-masquerade.el                   | 444 +++++++++++++++++++
 test/lisp/erc/erc-masquerade-tests.el        | 178 ++++++++
 test/lisp/erc/erc-scenarios-masquerade.el    | 164 +++++++
 test/lisp/erc/resources/masquerade/batch.eld |  86 ++++
 4 files changed, 872 insertions(+)
 create mode 100644 lisp/erc/erc-masquerade.el
 create mode 100644 test/lisp/erc/erc-masquerade-tests.el
 create mode 100644 test/lisp/erc/erc-scenarios-masquerade.el
 create mode 100644 test/lisp/erc/resources/masquerade/batch.eld

diff --git a/lisp/erc/erc-masquerade.el b/lisp/erc/erc-masquerade.el
new file mode 100644
index 00000000000..ec0d95f5922
--- /dev/null
+++ b/lisp/erc/erc-masquerade.el
@@ -0,0 +1,444 @@
+;;; erc-masquerade.el --- display-names, relaymsg, etc. -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2022-2024 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published
+;; by the Free Software Foundation, either version 3 of the License,
+;; or (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+;; General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; *Update 2024*: This is currently a placeholder for a demo module
+;; whose purpose will be to extol the virtues of whatever next-gen API
+;; emerges from bug#68861.  For a more practical, minimal "bridge bot"
+;; module actually compatible with the current ERC release, see
+;; <https://git.sr.ht/~technomancy/erc-bridge-nicks>.
+;;
+;; Some channels are "bridged" to communities on other protocols, like
+;; Matrix and XMPP or commercial chat platforms.  Sometimes,
+;; individual remote presences aren't "puppeted" on the local IRC
+;; side.  Instead, a so-called "relay bot" preforms a degraded type of
+;; forwarding by sending messages prefixed with the names of speakers.
+;; This results in a frustrating user experience:
+;;
+;;   <relaybot> <alice@telegram> telegram is bad
+;;   <relaybot> <[email-masked]> matrix is slwo
+;;   <relaybot> <[email-masked]> matrix is slow
+;;
+;; In this example, only relaybot's nick is ever highlighted or
+;; offered as a completion suggestion, and bob's last message is isn't
+;; marked as being an edit.  This module addresses the first problem
+;; to varying degrees but does so at the expense of making it
+;; immediately obvious to users when they're dealing with a bot.
+;;
+;; To get started, set `erc-masquerade-bots' to something like
+;;
+;;   ((Libera.Chat ("#chan" "MyBot" "\\`<\\(.+\\)> " "")
+;;                 ("#spam" "UrBot" "\\`<\\(.+\\):\\(.+\\)> " "\\2")))
+;;
+;; And add `masquerade' to `erc-modules' before connecting.
+
+;;; Background:
+
+;; Right now (2022), there are two draft specs from IRCv3 that touch
+;; on various c2s issues involving relay bots and display-only names:
+;;
+;;   https://github.com/ircv3/ircv3-specifications/pull/417
+;;   https://github.com/ircv3/ircv3-specifications/pull/452
+;;
+;; Word is that neither is poised to take off.  If that remains true,
+;; something addressing similar problems will likely emerge.  In the
+;; meantime, we can still try to improve the experience by minimizing
+;; the distraction of untreated relay-bot messages while still clearly
+;; indicating when spoofing is taking place.  To that end, this file
+;; provides a module called `masquerade' that fake-implements
+;; `display-name' for bots declared a priori.  This is preferable to
+;; rewriting relayed messages as being native.
+
+;;; Code:
+(require 'erc-button)
+(require 'erc-goodies)
+
+(defgroup erc-masquerade nil
+  "Display-names for \"bridged\" users from other platforms."
+  :group 'erc
+  :package-version '(ERC . "5.7")) ; FIXME sync on release
+
+(define-obsolete-variable-alias 'erc-masquerade-relay-bots
+  'erc-masquerade-bots "30.1")
+(defcustom erc-masquerade-bots nil
+  "Network nicks to treat as relay bots.
+More precisely, an alist of (NETWORK CHANSPEC1 CHANSPEC2 ...) where
+NETWORK is a symbol and CHANSPEC is a list containing (CHAN BOTNICK
+SPEAKER REPLACER).  Here, the first three items in a CHANSPEC are regexp
+patterns.  When matching, ERC surrounds the first two with
+`string-start' and `string-end' zero-width assertions (\\\\=` and \\\\='
+respectively).  ERC uses the entire matched substring, sans surrounding
+whitespace and angle brackets as a unique identifier for the virtual
+user, which may be relevant when bridging communities from many chat
+platforms.  The first matched group becomes the display name.
+Additional groups may exists and be referenced by REPLACER, which
+specifies text to substitute for the entire matched portion.  It can
+include specifiers known to `replace-match'.
+
+REPLACER can also be a function that accepts two arguments: the
+message body and the above mentioned SPEAKER regexp, which they may
+choose to ignore.  It must either return nil, to signal that the message
+should instead be processed normally, or a list of these items:
+
+  1. a canonical key, ideally unique among virtual nicks in the channel
+  2. a display name to be shown as the speaker in place of the robot's
+     nick; typically identical to 2 or a truncated version
+  3. a replacement for the matched portion of the message
+  4. a replacement for the remainder of the message body
+  5. an optional face for colorizing the speaker when `nicks' is enabled
+
+This latter function form mainly exists for ERC itself to provide ready
+made handlers for well known bots of a specific make, model, and
+configuration.
+
+If you tinker with this option during a live IRC session, cycle the
+module's minor mode for the changes to take effect."
+  :type (let ((pats `(choice :tag "Speaker display name pattern"
+                             (regexp :tag "Full: <{Name}> {Msg}"
+                                     ,(rx "<" (group (+? nonl)) "> "))
+                             (regexp :tag "Partitioned: <{Name:Host}> {Msg}"
+                                     ,(rx "<" (group (+? nonl)) ":"
+                                          (group (+? nonl)) "> "))
+                             (regexp :tag "Speaker pattern")))
+              (reps '(choice :tag "Replacer"
+                             (string :tag "Partitioned" "\\2 ")
+                             (string :tag "User-provided string")
+                             (function-item
+                              :tag "Mattermost bot with platform prefix"
+                              erc-masquerade-parse-fallback-mattermost)
+                             (function :tag "User-provided function"))))
+          `(alist :key-type (symbol :tag "Network")
+                  :value-type (repeat (list (regexp :tag "Channel pattern")
+                                            (regexp :tag "Bot nick pattern")
+                                            ,pats ,reps)))))
+
+(defcustom erc-masquerade-prefer-mirc-colors-for-nicks t
+  "Whether to prefer faces derived from MIRC control-sequences.
+This only matters when the `nicks' module is enabled.  Some bots enclose
+the entire fallback prefix in control chars, such as
+\"\\C-c02<{DisplayName}> \\C-o{MessageContent}\".  When enabled, this
+options tells ERC to retain the interpreted face rather than let `nicks'
+choose one.  See also `erc-controls-interpret'."
+  :type 'boolean)
+
+(defface erc-masquerade-alt-prefix-face
+  '((t :inherit (erc-notice-face italic)))
+  "Face for text that replaces the \"fallback\" prefix.
+For example, if an original message \"<bot> <nick@host> msg\" becomes
+\"<nick> host msg\", then use this face for the \"host \" portion."
+  :group 'erc-faces)
+
+(defvar-local erc-masquerade--match-replacer nil)
+(defvar-local erc-masquerade--botnick-regexp nil)
+(defvar-local erc-masquerade--speaker-regexp nil)
+;; FIXME rename to `erc-masquerade--frag-to-nonirc'
+(defvar-local erc-masquerade--partial-to-nonstandard nil)
+
+(defvar-local erc-masquerade--display-names nil
+  "Hash table mapping visible displayed name to full, given display name.
+For example, if the original display name from the bot's message
+was \"<joe:example.com>\", and we see the speaker as \"<joe>\",
+then this has an item mapping \"joe\" to \"joe:example.com\".")
+
+(defvar erc-masquerade--canonicalize-prefix #'erc-masquerade--strip-chars)
+(defvar erc-masquerade--detect-edit-function nil)
+(defvar erc-masquerade--expanded-syntax-table nil)
+(defvar erc-masquerade--extra-word-chars ".")
+
+(define-erc-module masquerade nil
+  "Replace speakers with \"display names\".
+
+This is a local module whose minor mode is non-nil in target buffers.
+In-session changes to `erc-masquerade-bots' will not be detected.  If
+reconnecting is impossible, try cycling the minor mode off and then on
+again in the appropriate buffer."
+  ((cond
+    ((and erc--target (erc-masquerade--determine-channel-info))
+     (add-hook 'erc-insert-pre-hook 'erc-masquerade--override-spkr-prop -50 t)
+     (add-function :around (local 'erc--cmem-from-nick-function)
+                   #'erc-masquerade--adorn '((depth . 30)))
+     (when (erc--module-active-p 'fill)
+       (add-function :after-while (local 'erc-fill--wrap-continued-predicate)
+                     #'erc-masquerade--fill-wrap-on-continued-message-p
+                     '((depth . -50))))
+     (when (erc--module-active-p 'nicks)
+       (add-function :filter-args (local 'erc-button--modify-nick-function)
+                     #'erc-masquerade--massage-nick '((depth . -80))))
+     (setq
+      erc-masquerade--display-names (make-hash-table :test #'equal)
+      erc-masquerade--partial-to-nonstandard (make-hash-table :test #'equal)))
+    (t (erc-masquerade-mode -1))))
+  ((remove-hook 'erc-insert-pre-hook 'erc-masquerade--override-spkr-prop t)
+   (remove-function (local 'erc-fill--wrap-continued-predicate)
+                    #'erc-masquerade--fill-wrap-on-continued-message-p)
+   (remove-function (local 'erc--cmem-from-nick-function)
+                    #'erc-masquerade--adorn)
+   (remove-function (local 'erc-button--modify-nick-function)
+                    #'erc-masquerade--massage-nick)
+   (kill-local-variable 'erc-masquerade--botnick-regexp)
+   (kill-local-variable 'erc-masquerade--speaker-regexp)
+   (kill-local-variable 'erc-masquerade--match-replacer)
+   (kill-local-variable 'erc-masquerade--partial-to-nonstandard)
+   (kill-local-variable 'erc-masquerade--display-names))
+  'local)
+
+(defun erc-masquerade--strip-chars (text)
+  "Helper for relay-bot functions to clean puppet names."
+  (string-replace "\u200b" ""
+                  (if erc-masquerade-prefer-mirc-colors-for-nicks
+                      (let ((erc-interpret-mirc-color t))
+                        (erc-controls-interpret text))
+                    (erc-controls-strip text))))
+
+(defun erc-masquerade--override-spkr-prop (string)
+  "Replace real `erc--spkr' with bridged user.
+Also swap out placeholder face property in prefix portion of STRING."
+  (when-let ((beg (text-property-not-all
+                   0 (length string) 'erc-masquerade--pfx-face nil string))
+             (end (next-single-property-change
+                   beg 'erc-masquerade--pfx-face string))
+             (val (get-text-property beg 'erc-masquerade--pfx-face string)))
+    (remove-list-of-text-properties beg end
+                                    '(erc-masquerade--pfx-face string) string)
+    (put-text-property beg end 'font-lock-face val string))
+  (when-let ((bridged (erc--check-msg-prop 'erc--masquerade-key)))
+    (puthash 'erc--spkr bridged erc--msg-props)
+    (remhash 'erc--masquerade-key erc--msg-props)))
+
+(define-obsolete-function-alias 'erc-masquerade-parse-fallback-host
+  'erc-masquerade--preform-legacy-fallback "30.1")
+(defun erc-masquerade--preform-legacy-fallback (msg &rest _)
+  (erc-masquerade--match-nick-default
+   msg (rx "<" (group (+? nonl)) ":" (group (+? nonl)) "> ") "\\2 "))
+
+(defun erc-masquerade--canonicalize-prefix-mattermost (fallback)
+  (let (user platform)
+    (if-let ((stripped (erc-masquerade--strip-chars fallback))
+             (proto (string-search "] " stripped)))
+        (setq user (substring stripped (+ proto 3) -2)
+              platform (substring stripped 1 proto))
+      (setq user (substring stripped 1 -2)))
+    (when (and platform (string-suffix-p "-" platform))
+      (setq platform (substring platform 0 -1)))
+    (concat user (and platform ":") platform)))
+
+(defun erc-masquerade-parse-fallback-mattermost (msg _)
+  "Example prefix parser for Mattermost bot (as it behaved in 2022).
+Process MSG, ignoring the `erc-masquerade-bot' option's REGEXP.  Only
+handle bridges configured with `RemoteNickFormat' set to either
+\"[{PROTOCOL}] <{NICK}> \" or \"<{NICK}> \".  Otherwise behave like
+`erc-masquerade--match-nick-default' with the replacer parameter set to
+sub-expression 2."
+  (let ((erc-masquerade--canonicalize-prefix
+         #'erc-masquerade--canonicalize-prefix-mattermost))
+    (erc-masquerade--preform-legacy-fallback msg)))
+
+(defun erc-masquerade--match-nick-default (msg regexp subst)
+  "Return MSG parts partitioned via REGEXP after replacing with SUBST."
+  (when-let (((string-match regexp msg))
+             (msg (funcall erc-masquerade--canonicalize-prefix msg))
+             ((string-match regexp msg))
+             (display (substring-no-properties (match-string 1 msg)))
+             (trimmed (substring-no-properties msg (match-end 0)))
+             (prefix (substring msg 0 (match-end 0)))
+             (replaced (substring-no-properties
+                        (replace-match subst nil nil prefix)))
+             (canon (substring-no-properties
+                     (string-trim prefix "[ <]+" "[ >]+"))))
+    (let ((face (and-let* ((erc-masquerade-prefer-mirc-colors-for-nicks)
+                           (val (get-text-property 0 'font-lock-face prefix))
+                           (face (car-safe val))
+                           ((symbolp face))
+                           ((string-prefix-p "fg:erc-" (symbol-name face))))
+                  face)))
+      (list canon display replaced trimmed face))))
+
+(defun erc-masquerade--determine-channel-info ()
+  "Set local variables for function and bot-nick, returning the latter.
+If not found, return nil.  For compatibility, allow the function
+to be nil."
+  (when-let ((entries (alist-get (erc-network) erc-masquerade-bots))
+             (entry (alist-get (erc-default-target) entries nil nil
+                               (lambda (pattern target)
+                                 (string-match (concat "\\`" pattern "\\'")
+                                               target)))))
+    (when (functionp (nth 1 entry))
+      (setq entry (list (car entry) (rx unmatchable) (nth 1 entry)))
+      (erc-button--display-error-notice-with-keys
+       (erc-server-buffer)
+       "The shape of `erc-masquerade-bots's expected value has changed."
+       " Please take corrective action."))
+    (pcase-exhaustive entry
+      (`(,botpat ,spkrpat ,replacement)
+       (when (and erc-masquerade-prefer-mirc-colors-for-nicks
+                  (string-suffix-p " " spkrpat))
+         (setq spkrpat (concat spkrpat (rx (? ?\C-o)))))
+       (setq erc-masquerade--match-replacer replacement
+             erc-masquerade--botnick-regexp (concat "\\`" botpat "\\'")
+             erc-masquerade--speaker-regexp spkrpat)))))
+
+(defun erc-masquerade--add-speaker-to-phantom-users (downcased name)
+  "Add speaker to `erc-button--phantom-users' and return bot user.
+Also map speaker to bot data in `erc-button--nicks-to-data'."
+  (cl-assert erc-button-mode)
+  (cl-assert erc-button--phantom-users-mode)
+  (or (gethash downcased erc-button--phantom-cmems)
+      (and-let* ((bot-nick (erc-extract-nick
+                            (erc-response.sender erc--parsed-response)))
+                 (bot-down (erc-downcase bot-nick))
+                 (cmem (cons
+                        (make-erc--phantom-server-user :nickname name)
+                        (make-erc--phantom-channel-user :last-message-time
+                                                        (current-time)))))
+        ;; Handle names containing nonstandard chars.
+        (when-let ((found (string-match
+                           (concat "[" erc-masquerade--extra-word-chars "]")
+                           name))
+                   (partial (substring name 0 found)))
+          (puthash (erc-downcase partial) cmem erc-button--phantom-cmems)
+          (puthash partial name erc-masquerade--partial-to-nonstandard))
+        (puthash downcased cmem erc-button--phantom-cmems))))
+
+(defun erc-masquerade--adorn (orig parts)
+  "Stylize message to impersonate real channel members.
+Defer to `erc-masquerade--detect-edit-function' to handle edit
+detection and styling."
+  (if-let ((botnick-downcased (erc--msg-parts-key parts))
+           ((string-match erc-masquerade--botnick-regexp botnick-downcased))
+           (msg (erc--msg-parts-msg parts))
+           (pat erc-masquerade--speaker-regexp)
+           (rep erc-masquerade--match-replacer)
+           (result (if (functionp rep)
+                       (funcall rep msg pat)
+                     (erc-masquerade--match-nick-default msg pat rep))))
+      (pcase-let* ((`(,canon ,display ,prefix ,emsg ,face) result)
+                   ((cl-struct erc--msg-parts login host) parts)
+                   ;; File phony cmem under key "<downcased> <display-name>".
+                   (key (concat botnick-downcased " " canon))
+                   (cmem (or (gethash key erc-channel-members)
+                             (cons (make-erc--phantom-server-user
+                                    :nickname display :host host :login login)
+                                   (make-erc--phantom-channel-user)))))
+        (unless emsg (setq emsg (erc--msg-parts-msg parts)))
+        (setf (erc--msg-parts-key parts) key
+              ;;
+              (erc--msg-parts-msg parts)
+              (concat (and prefix (not (string-empty-p prefix))
+                           (propertize prefix
+                                       'erc-masquerade--pfx-face
+                                       'erc-masquerade-alt-prefix-face))
+                      (or (and erc-masquerade--detect-edit-function
+                               (funcall erc-masquerade--detect-edit-function
+                                        canon emsg))
+                          emsg)))
+        (when erc-button--phantom-users-mode
+          (erc-masquerade--add-speaker-to-phantom-users (erc-downcase display)
+                                                        display))
+        (puthash display canon erc-masquerade--display-names)
+        (puthash key cmem erc-channel-members)
+        (when face
+          (push (cons 'erc--masquerade-face face) erc--msg-prop-overrides))
+        (push (cons 'erc--masquerade-key key) erc--msg-prop-overrides)
+        (push (cons 'erc--masquerade canon) erc--msg-prop-overrides)
+        cmem)
+    (funcall orig parts)))
+
+
+;;;; `fill-wrap' integration
+
+(defun erc-masquerade--fill-wrap-on-continued-message-p ()
+  (when-let ((beg (text-property-any (point-min) (point-max) 'font-lock-face
+                                     'erc-masquerade-alt-prefix-face))
+             (end (next-single-property-change beg 'face)))
+    (goto-char end))
+  t)
+
+;;;; `nicks' integration
+
+(defun erc-masquerade--bounds-of-word-at-point ()
+  "Return more liberal bounds than allowed by `erc-button-syntax-table'."
+  ;; FIXME move this into a public setter to allow user-defined bot
+  ;; functions to alter the table.
+  (unless erc-masquerade--expanded-syntax-table
+    (let ((table (setq erc-masquerade--expanded-syntax-table
+                       (copy-syntax-table erc-button-syntax-table))))
+      (erc--doarray (c erc-masquerade--extra-word-chars)
+        (modify-syntax-entry c "w" table))))
+  (with-syntax-table erc-masquerade--expanded-syntax-table
+    (erc-bounds-of-word-at-point)))
+
+(defun erc-masquerade--resolve-partial (name nick-object)
+  "Rewrite bounds of NICK-OBJECT if NAME resides in expanded word at point."
+  (when-let ((expanded (erc-masquerade--bounds-of-word-at-point))
+             ((save-excursion (goto-char (car expanded))
+                              (search-forward name (cdr expanded) 'noerror))))
+    (setf (erc-button--nick-bounds nick-object)
+          (cons (match-beginning 0) (match-end 0)))
+    (cl-assert (equal name (match-string 0)))
+    (let ((found (gethash name erc-masquerade--display-names)))
+      (cl-assert found t)
+      found)))
+
+;; FIXME don't highlight over `erc-masquerade-alt-prefix-face'.
+(defun erc-masquerade--massage-nick (args)
+  (cl-assert (erc-button--nick-user (car args)))
+  (when-let ((nick-object (car args))
+             (erc--target)
+             (server-user (erc-button--nick-user nick-object))
+             (bounds (erc-button--nick-bounds nick-object)))
+    ;; Set the button-nick object's `downcased' slot to the full
+    ;; display name, so `erc-nicks--highlight' uses it for its "key".
+    ;; Known speakers, like <bob>, have this in a `erc--masquerade'
+    ;; msg prop.  Otherwise, look in `erc-masquerade--display-names'.
+    (let ((masq (erc--check-msg-prop 'erc--masquerade))
+          (alt-face (erc--check-msg-prop 'erc--masquerade-face)))
+      (when-let
+          (((not (and masq (get-text-property (car bounds) 'erc--speaker))))
+           (str (buffer-substring-no-properties (car bounds) (cdr bounds)))
+           (tab erc-masquerade--partial-to-nonstandard)
+           (seen (or (gethash str erc-masquerade--display-names)
+                     (and-let* ((name (gethash str tab))
+                                (alt (erc-masquerade--resolve-partial
+                                      name nick-object)))
+                       (setq str name)
+                       alt))))
+        (setq masq seen))
+      (when masq
+        (setf
+         (erc-button--nick-downcased nick-object) (erc-downcase masq)
+         (erc-button--nick-user nick-object) (copy-erc-server-user server-user)
+         (erc-server-user-nickname (erc-button--nick-user nick-object)) masq)
+        ;; Replace natural color with assigned `irccontrols' face.
+        ;; FIXME provide internal API to accomplish this painlessly.
+        (when alt-face
+          (defvar erc-nicks-colors)
+          (defvar erc-nicks--colors-pool)
+          (defvar erc-nicks--colors-len)
+          (declare-function erc-nicks--get-face "erc-nicks" (nick key))
+          (erc-with-server-buffer
+            (let ((erc-nicks-colors)
+                  (erc-nicks--colors-pool (list (face-foreground alt-face)))
+                  (erc-nicks--colors-len 1))
+              (erc-nicks--get-face (erc-downcase masq) "key")))))))
+  args)
+
+(provide 'erc-masquerade)
+
+;;; erc-masquerade.el ends here
diff --git a/test/lisp/erc/erc-masquerade-tests.el b/test/lisp/erc/erc-masquerade-tests.el
new file mode 100644
index 00000000000..a2bf2b79728
--- /dev/null
+++ b/test/lisp/erc/erc-masquerade-tests.el
@@ -0,0 +1,178 @@
+;;; erc-masquerade-tests.el --- Tests for erc-masquerade.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2020-2022 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+;;
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published
+;; by the Free Software Foundation, either version 3 of the License,
+;; or (at your option) any later version.
+;;
+;; GNU Emacs is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+;; General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+(require 'ert-x)
+(require 'erc-masquerade)
+
+
+(ert-deftest erc-masquerade--canonicalize-prefix-mattermost ()
+  (should (string= (erc-masquerade--canonicalize-prefix-mattermost
+                    "<\C-]b\u200bob\C-]> ")
+                   "bob"))
+  (should (string= (erc-masquerade--canonicalize-prefix-mattermost
+                    "<b\u200bob> ")
+                   "bob"))
+  (should (string= (erc-masquerade--canonicalize-prefix-mattermost
+                    "[t\u200belegram] <b\u200bob> ")
+                   "bob:telegram"))
+  (should (string= (erc-masquerade--canonicalize-prefix-mattermost
+                    "[telegram-] <bob> ")
+                   "bob:telegram")))
+
+(ert-deftest erc-masquerade--match-nick-default ()
+  ;; Baseline.
+  (should (equal (erc-masquerade--match-nick-default
+                  "<ABC> 123"
+                  (rx bot "<" (group (+ nonl)) "> ")
+                  "")
+                 '("ABC" "ABC" "" "123" nil)))
+
+  ;; Replacement.
+  (should (equal (erc-masquerade--match-nick-default
+                  "<ABC:gnu.org> 123"
+                  (rx bot "<" (group (+ nonl)) ":" (group (+ nonl)) "> ")
+                  "\\2 ")
+                 '("ABC:gnu.org" "ABC" "gnu.org " "123" nil))))
+
+(defun ert-masquerade-tests--perform-in-chan (setup test)
+  (let* ((pat (rx bot "<" (group (+ nonl)) "> "))
+         (erc-masquerade-bots `((foonet ("#tes." "spammer_?" ,pat "")
+                                        ("#chan" "frisbeeosbridge" ,pat ""))))
+         erc-kill-channel-hook erc-kill-server-hook erc-kill-buffer-hook)
+
+    (with-current-buffer (get-buffer-create "foonet")
+      (erc-mode)
+      (setq erc-server-process (start-process "true"
+                                              (current-buffer) "sleep" "1")
+            erc-server-current-nick "tester"
+            erc--isupport-params (make-hash-table)
+            erc-server-announced-name "foo.gnu.chat"
+            erc-network 'foonet
+            erc-networks--id (erc-networks--id-create nil)
+            erc-server-users (make-hash-table :test #'equal))
+      (puthash 'CHANTYPES '("&#") erc--isupport-params)
+      (set-process-query-on-exit-flag erc-server-process nil))
+
+    (dolist (name '("#test" "#chan"))
+      (with-current-buffer (get-buffer-create name)
+        (erc-mode)
+        (erc--initialize-markers (point) nil)
+        (setq erc-server-process (buffer-local-value 'erc-server-process
+                                                     (get-buffer "foonet"))
+              erc-default-recipients (list name)
+              erc--target (erc--target-from-string name)
+              erc-network 'foonet
+              erc-networks--id (buffer-local-value 'erc-networks--id
+                                                   (get-buffer "foonet"))
+              erc-channel-users (make-hash-table :test #'equal))
+        (erc-fill-mode +1)
+        (funcall setup)
+        (erc-masquerade-mode +1)))
+
+    (funcall test)
+
+    (when noninteractive
+      (kill-buffer "foonet")
+      (kill-buffer "#test")
+      (kill-buffer "#chan"))))
+
+(ert-deftest erc-masquerade--adorn ()
+  (ert-masquerade-tests--perform-in-chan
+   #'ignore
+   (lambda ()
+
+     (with-current-buffer "foonet"
+       (erc-parse-server-response
+        erc-server-process
+        (concat "@time=2022-07-18T09:19:34.604Z "
+                ":[email-masked] "
+                "PRIVMSG #test :<Bob:discord> "
+                "Blah..."))
+       (erc-parse-server-response
+        erc-server-process
+        (concat "@time=2022-07-18T09:19:34.604Z "
+                ":[email-masked] "
+                "PRIVMSG #chan :<Alice:telegram> "
+                "Receiving clients SHOULD check for the presence of...")))
+
+     (with-current-buffer "#test"
+       (goto-char (point-min))
+       (should (string= erc-masquerade--botnick-regexp "\\`spammer_?\\'"))
+       (should (search-forward "<Bob:discord> " nil t))
+       (should (string= (get-text-property (pos-bol) 'erc--masquerade)
+                        "Bob:discord"))
+       (should (looking-at-p "Blah")))
+
+     (with-current-buffer "#chan"
+       (goto-char (point-min))
+       (should (search-forward "<Alice:telegram> " nil t))
+       (should (string= (get-text-property (pos-bol) 'erc--masquerade)
+                        "Alice:telegram"))
+       (should (looking-at-p "Receiving"))))))
+
+(defvar erc-fill-function)
+(defvar erc-fill-static-center)
+(declare-function erc-hl-nicks-mode "ext:erc-hl-nicks" (&optional arg))
+(declare-function erc-fill-static "erc-fill" nil)
+
+(ert-deftest erc-masquerade--adorn/fill-static ()
+  (ert-masquerade-tests--perform-in-chan
+
+   (lambda ()
+     (when (string= (buffer-name) "#chan")
+       (setq-local erc-fill-function #'erc-fill-static)
+       (setq-local erc-fill-static-center 22)))
+
+   (lambda ()
+     (with-current-buffer "foonet"
+       (erc-parse-server-response
+        erc-server-process
+        (concat "@time=2022-07-18T09:19:34.604Z "
+                ":[email-masked] "
+                "PRIVMSG #chan :<Alice:telegram> "
+                "Receiving clients SHOULD check for the presence of..."))
+       (erc-parse-server-response
+        erc-server-process
+        (concat "@time=2022-07-18T09:19:35.604Z "
+                ":[email-masked] "
+                "PRIVMSG #chan :<BillyBob:matrix.org> "
+                "Consider formatting display names differently to nicknames")))
+
+     (with-current-buffer "#chan"
+       (goto-char (point-min))
+       (should (search-forward "<Alice:telegram> " nil t))
+       (goto-char (match-beginning 0))
+       (should (string= (get-text-property (pos-bol) 'erc--masquerade)
+                        "Alice:telegram"))
+       (should (= (- (point) (line-beginning-position)) 5))
+       (should
+        (string-prefix-p
+         "     <Alice:telegram> Receiving clients SHOULD"
+         (buffer-substring-no-properties (line-beginning-position)
+                                         (line-end-position))))
+       (goto-char (line-end-position))
+
+       (should (search-forward "<BillyBob:matrix.org> " nil t))
+       (goto-char (match-beginning 0))
+       (should (string= (get-text-property (pos-bol) 'erc--masquerade)
+                        "BillyBob:matrix.org"))
+       (should (= (point) (line-beginning-position)))))))
+
+;;; erc-masquerade-tests.el ends here
diff --git a/test/lisp/erc/erc-scenarios-masquerade.el b/test/lisp/erc/erc-scenarios-masquerade.el
new file mode 100644
index 00000000000..238e983b944
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-masquerade.el
@@ -0,0 +1,164 @@
+;;; erc-scenarios-masquerade.el --- masquerade scenarios -*- lexical-binding: t -*-
+
+;; Copyright (C) 2022 Free Software Foundation, Inc.
+;;
+;; This file is part of GNU Emacs.
+;;
+;; This program is free software: you can redistribute it and/or
+;; modify it under the terms of the GNU General Public License as
+;; published by the Free Software Foundation, either version 3 of the
+;; License, or (at your option) any later version.
+;;
+;; This program is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+;; General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see
+;; <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;; Code:
+(require 'ert-x)
+(eval-and-compile
+  (let ((load-path (cons (ert-resource-directory) load-path)))
+    (require 'erc-scenarios-common)))
+
+(require 'erc-scenarios-common)
+(require 'erc-masquerade)
+
+(defun erc-scenarios-common--tps (timeout text)
+  (save-restriction
+    (widen)
+    (let ((from (point)))
+      (erc-d-t-wait-for timeout (format "string: %s" text)
+        (goto-char from)
+        (text-property-search-forward 'display text
+                                      (lambda (v p)
+                                        (string-prefix-p v p)))))))
+
+(defun erc-scenarios-masqerade--assert-fill-button-ordering ()
+  ;; Ensure that our custom fill function can see all face
+  ;; modifications; otherwise its calculations will be off.
+  (ert-info ("Button follows fill in modification hooks")
+    (should (thread-last 'erc-insert-modify-hook
+                         (default-value)
+                         (memq 'erc-button-add-buttons)
+                         (memq 'erc-fill)))))
+
+(defun erc-scenarios-masquerade--batch (after-foo)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "masquerade")
+       (erc-d-linger-secs 0.5)
+       (erc-server-flood-penalty 0.1)
+       (erc-d-tmpl-vars
+        `((now . ,(lambda () (format-time-string "%FT%T.%3NZ" nil t)))))
+       (dumb-server (erc-d-run "localhost" t 'batch))
+       (port (process-contact dumb-server :service))
+       (erc-modules `(nicks masquerade ,@erc-modules))
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :password "password123"
+                                :full-name "tester")
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+    (erc-d-t-wait-for 10 "server buffer ready" (get-buffer "ExampleOrg"))
+
+    (erc-scenarios-masqerade--assert-fill-button-ordering)
+
+    (ert-info ("Notices received")
+      (with-current-buffer "ExampleOrg"
+        (funcall expect 10 "Setting your virtual host")))
+
+    (erc-d-t-wait-for 10 "buffer #foo ready" (get-buffer "#foo"))
+
+    (ert-info ("Chan buffer #foo populated")
+      (with-current-buffer "#foo"
+        (funcall expect 10 "You have joined channel #foo")
+        (funcall expect 5 "<focused-ne")
+        (funcall expect 20 "#frllklw-klwjlsrs")
+        (funcall expect 5 "#foo modes:")
+        (funcall expect 5 "#foo was")
+        (funcall expect 5 "<gallant")
+        (funcall expect 5 "According")
+        (funcall expect 5 "<focused-ne")
+        (funcall expect 5 "Or never")
+        (funcall expect 5 erc-prompt)
+        (funcall after-foo)))))
+
+(defvar erc-fill-function)
+(declare-function erc-fill-static "erc-fill" nil)
+(declare-function erc-fill-wrap "erc-fill" nil)
+
+(defun erc-scenarios-masquerade--assert-completion ()
+  (ert-info ("Completion at point works")
+    (save-excursion
+      (goto-char erc-input-marker)
+      (insert "gall")
+      (call-interactively #'erc-tab)
+      (should (looking-back "gallant: "))
+      (goto-char erc-input-marker)
+      (delete-region erc-input-marker (point-max)))))
+
+;; This asserts baseline behavior without fallback substitutions.
+(ert-deftest erc-scenarios-masquerade-batch--normal ()
+  :tags '(:expensive-test)
+  (let ((erc-masquerade-bots '((ExampleOrg
+                                ("#test" "spammer" "" "")
+                                ("#foo" "botman" "<\\(.+?\\)> " ""))))
+        ;; This style includes a trailing ":platform" portion on
+        ;; display names, so make ERC recognize ?: as a word char.
+        (erc-masquerade--extra-word-chars ".:")
+        (erc-timestamp-use-align-to nil))
+    (erc-scenarios-masquerade--batch
+     (lambda ()
+       (ert-info ("Completion at point works for local names")
+         (save-excursion
+           (goto-char erc-input-marker)
+           (insert "some")
+           (call-interactively #'erc-tab)
+           (should (looking-back "someone: "))
+           (goto-char erc-input-marker)
+           (delete-region erc-input-marker (point-max))
+           ;; SHA<tab> won't work because they were only present
+           ;; during playback.
+           (insert "TR")
+           (call-interactively #'erc-tab)
+           (should (looking-back "TRUST:dvdovt.org: "))
+           (delete-region erc-input-marker (point-max))))))))
+
+;; This demos a user-provided custom parsing function.
+(ert-deftest erc-scenarios-masquerade-batch--custom-parser ()
+  :tags '(:expensive-test)
+  (require 'erc-fill)
+  (let ((erc-masquerade-bots
+         '((ExampleOrg
+            ("fake" . ignore) ; legacy :type of (nick . func)
+            ("#foo" "botman"
+             erc-masquerade--preform-legacy-fallback))))
+        (erc-interpret-mirc-color t)
+        (erc-fill-wrap-merge-indicator
+         '(pre #xb7 erc-fill-wrap-merge-indicator-face))
+        (erc-fill-function #'erc-fill-wrap))
+    (erc-scenarios-masquerade--batch
+     (lambda ()
+       (erc-scenarios-masquerade--assert-completion)
+       (goto-char (point-min))
+       (should-not (search-forward "<botman>" nil t))
+       (should (search-forward "<focused-ne>" nil t))
+       (goto-char (1+ (match-beginning 0)))
+       (should (string= (get-text-property (pos-bol) 'erc--masquerade)
+                        "focused-ne:telegram"))
+       (should (search-forward "<gallant>" nil t))
+       (goto-char (1+ (match-beginning 0)))
+       (should (string= (get-text-property (pos-bol) 'erc--masquerade)
+                        "gallant:xsxpvtson.net"))
+       (should (string= (get-text-property (pos-bol) 'erc--spkr)
+                        "botman gallant:xsxpvtson.net"))))))
+
+;;; erc-scenarios-masquerade.el ends here
diff --git a/test/lisp/erc/resources/masquerade/batch.eld b/test/lisp/erc/resources/masquerade/batch.eld
new file mode 100644
index 00000000000..58d915e95ea
--- /dev/null
+++ b/test/lisp/erc/resources/masquerade/batch.eld
@@ -0,0 +1,86 @@
+;;; -*- mode: lisp-data -*-
+
+((pass 0.2 "PASS :password123"))
+((nick 0.2 "NICK tester"))
+((user 0.2 "USER user 0 * :tester")
+ (0.0 "@time=" now " :irc.example.org NOTICE tester :*** Found your ident, 'tester'")
+ (0.0 "@time=" now " :irc.example.org 001 tester :Welcome to the example.org IRC Network [email-masked]")
+ (0.0 "@time=" now " :irc.example.org 002 tester :Your host is irc.example.org, running version InspIRCd-3")
+ (0.0 "@time=" now " :irc.example.org 003 tester :This server was created 23:41:47 Nov 17 2020")
+ (0.0 "@time=" now " :irc.example.org 004 tester irc.example.org InspIRCd-3 BILRSWcghiorswz ABEFHIJLMNOQRSTXYabcefghijklmnopqrstuvz :BEFHIJLXYabefghjkloqv")
+ (0.0 "@time=" now " :irc.example.org 005 tester ACCEPT=30 AWAYLEN=200 BOT=B CALLERID=g CASEMAPPING=rfc1459 CHANLIMIT=#:120 CHANMODES=IXbeg,k,BEFHJLfjl,AMNOQRSTcimnprstuz CHANNELLEN=64 CHANTYPES=# ELIST=CMNTU ESILENCE=CcdiNnPpTtx EXCEPTS=e :are supported by this server")
+ (0.0 "@time=" now " :irc.example.org 005 tester EXTBAN=,ANOQRSTUacmnprz HOSTLEN=64 INVEX=I KEYLEN=32 KICKLEN=255 LINELEN=512 MAXLIST=I:100,X:100,b:100,e:100,g:100 MAXTARGETS=20 MODES=20 MONITOR=30 NAMELEN=128 NAMESX NETWORK=ExampleOrg :are supported by this server")
+ (0.0 "@time=" now " :irc.example.org 005 tester NICKLEN=31 PREFIX=(Yqaohv)!~&@%!R(MISSING)EMOVE SAFELIST SECURELIST=60 SILENCE=32 STATUSMSG=!~&@%!T(MISSING)OPICLEN=307 UHNAMES USERIP USERLEN=11 USERMODES=,,s,BILRSWcghiorwz WATCH=30 :are supported by this server")
+ (0.0 "@time=" now " :irc.example.org 005 tester :are supported by this server")
+ (0.0 "@time=" now " :irc.example.org 251 tester :There are 740 users and 105 invisible on 10 servers")
+ (0.0 "@time=" now " :irc.example.org 252 tester 9 :operator(s) online")
+ (0.0 "@time=" now " :irc.example.org 253 tester 1 :unknown connections")
+ (0.0 "@time=" now " :irc.example.org 254 tester 378 :channels formed")
+ (0.0 "@time=" now " :irc.example.org 255 tester :I have 223 clients and 1 servers")
+ (0.0 "@time=" now " :irc.example.org 265 tester :Current local users: 223  Max: 226")
+ (0.0 "@time=" now " :irc.example.org 266 tester :Current global users: 845  Max: 903")
+ (0.0 "@time=" now " :irc.example.org 375 tester :irc.example.org message of the day")
+ (0.0 "@time=" now " :irc.example.org 372 tester :   figlet 1")
+ (0.0 "@time=" now " :irc.example.org 372 tester :   figlet 2")
+ (0.0 "@time=" now " :irc.example.org 376 tester :End of message of the day."))
+
+((mode-user 20 "MODE tester +i")
+ (0.0 "@time=" now " :irc.example.org NOTICE tester :Setting your virtual host: irc.example.org")
+ (0.0 "@time=" now " :irc.example.org 396 tester irc.example.org :is now your displayed host")
+ ;; It doesn't make sense for a server to send 396 before 900 because the latter will clobber user/host.
+ ;; XXX verify that this is even possible.
+ (0.0 "@time=" now ";msgid=632~1605656507~232192;inspircd.org/service;inspircd.org/bot :[email-masked] NOTICE tester :Password accepted - you are now recognized.")
+ (0.0 "@time=" now " :irc.example.org 900 tester [email-masked] tester :You are now logged in as tester")
+ (0.0 "@time=" now ";inspircd.org/service;inspircd.org/bot :[email-masked] MODE tester :+r")
+
+ (0 "@time=" now " :[email-masked] JOIN #foo")
+ (0 "@time=" now " :irc.example.org 332 tester #foo :Topic for tester: ERC: the powerful, modular, and extensible Emacs IRC client | https://gnu.org/s/emacs/erc | Questions welcome! | See https://gnu.org/server/irc-rules and https://gnu.org/philosophy/kind-communication | Asynchronous (non blocking) erc connection patches - http://platypus.pepperfish.net/~vivek/erc/ | https://nitter.net/UniOulu/status/1032214470263812096")
+ (0 "@time=" now " :irc.example.org 333 tester #foo frostyE!~fros@inr/apmsns 1587065826")
+ (0 "@time=" now " :irc.example.org 353 tester = #foo :@bob [email-masked] ecstatic!~ecstatic@196-99-4-98 RELAX!~relax@unmxxzqzmhvj/jvvno/ooh/xsooh")
+ (0 "@time=" now " :irc.example.org 353 tester = #foo :[email-masked] iym_ irde dkm4 someone")
+ (0 "@time=" now " :irc.example.org 366 tester #foo :End of /NAMES list.")
+
+ ;; XXX yes, obviously these BATCH messages and tags don't make any
+ ;; sense without prior negotiation.  But this is just for demo
+ ;; purposes.  ERC on master (5.6) just ignores them.  See Bug#49860.
+ (0 "@time=" now " :irc.example.org BATCH +1 chathistory :#foo")
+ (0 "@batch=1;time=2021-01-01T04:35:31.906Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> He cannot be heard of. Out of doubt he is transported.")
+ (0 "@batch=1;time=2021-01-01T04:36:25.763Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> I can't you had send to someone. ~ i've not thsthr it bghbfghr a message in file ")
+ (0 "@batch=1;time=2021-01-01T05:33:17.150Z :[email-masked] PRIVMSG #foo :<gallant:xsxpvtson.net> More evident than this; for this was stol'n.")
+ (0 "@batch=1;time=2021-01-01T07:42:35.816Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> Worse if not wutjbpet dzuubnp yeztevej is up when I try to dwnnnedt to a")
+ (0 "@batch=1;time=2021-01-01T14:37:58.877Z :[email-masked] PRIVMSG #foo :<gallant:xsxpvtson.net> Tphsjiomjiheey funny! I hope that it would not send the iontvnts of the file.")
+ (0 "@batch=1;time=2021-01-02T15:00:49.413Z :[email-masked] PRIVMSG #foo :<wonderkinaolive.now:westlandia.org> Sell when you abcde.")
+ (0 "@batch=1;time=2021-01-02T15:00:49.511Z :[email-masked] PRIVMSG #foo :<TRUST:dvdovt.org> focused-ne: There. it's mostly vsixx spvm :P ah :-p lel, just axsierzrza that when I fire up a second network and join a second z.o. #aunozen-meaz")
+ (0 "@batch=1;time=2021-01-02T17:29:53.606Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> Their ahqwan of nwak. n_n hope i dwdnv just send them that sarhffc srhnahnr :P I get")
+ (0 "@batch=1;time=2021-01-02T17:36:25.763Z :[email-masked] PRIVMSG #foo :\00302<hell-cz:matrix.org> \17> <@mircat:plan9.org> you didn't complete the install but tried to lock or boot")
+ (0 "@batch=1;time=2021-01-02T17:37:25.763Z :[email-masked] PRIVMSG #foo :\00302<hell-cz:matrix.org> \17I completed the install over a year ago...")
+ (0 "@batch=1;time=2021-01-02T17:38:25.763Z :[email-masked] PRIVMSG #foo :\00307<mircat:plan9.org> \17> <@hell-cz:matrix.org> I completed the install over a year ago...")
+ (0 "@batch=1;time=2021-01-02T17:39:25.763Z :[email-masked] PRIVMSG #foo :\00307<mircat:plan9.org> \17it was unlocked since them?")
+ (0 "@batch=1;time=2021-01-02T18:23:02.472Z :[email-masked] PRIVMSG #foo :<gallant:xsxpvtson.net> that have sent that text to that owck? if i had hit uozuy? ~ someone pwckuc the same name as i'd given the bot i was writing, was about to cneprueuoz")
+ (0 "@batch=1;time=2021-01-02T20:14:00.040Z :[email-masked] PRIVMSG #foo :<SHA:kpvxmd.org> There's the fool hangs on your back already, wonderkinaolive.now.")
+ (0 "@batch=1;time=2021-01-02T20:14:04.597Z :[email-masked] PRIVMSG #foo :Something. Sounds hard. Or, maybe i'm nntl and this has been there since at start with a # such as #foo and tbtrtedrt it is nnliktla to be a name.")
+ (0 "@batch=1;time=2021-01-02T20:14:10.513Z :[email-masked] PRIVMSG #foo :<SHA:kpvxmd.org> The ape is dead, and I must conjure him.")
+ (0 "@batch=1;time=2021-01-03T05:09:40.611Z :[email-masked] PRIVMSG #foo :<TRUST:dvdovt.org> Prempc at the qeccem. i was too safrjw, i just closed it, but... would using really ahesj which one to send to i don't think, so much as maybe send to the")
+ (0 "@batch=1;time=2021-01-03T05:46:05.791Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> SHA: One of those ksys that because of the gwpx of gallant pnrtqrts to be hrqelnss pnreqrswtlnr and only new stuff wftqrdwru.")
+ (0 "@batch=1;time=2021-01-03T05:49:13.305Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> gallant: my liege; the youngest son of Sir Rowland de Boys.")
+ (0 "@batch=1;time=2021-01-03T06:04:06.661Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> Mean... zttist mode is right there. (That should probably be the horsvp's name for the buffer. That 4670-74/hsg67666.fthv seems like a different problem.")
+ (0 "@batch=1;time=2021-01-03T07:35:07.624Z :[email-masked] PRIVMSG #foo :<SHA:kpvxmd.org> And must advise the emperor for his good.")
+ (0 "@batch=1;time=2021-01-03T07:35:27.191Z :[email-masked] PRIVMSG #foo :<SHA:kpvxmd.org> Have open a buffer aksde gxwiaspfsb and then /qvdxh you it will use the same https://fists.yav.gxy/kxbcivd/ctsf/dskbs-edvdf/5614-15/ssy61666.ctsf This is probably cannot be easily fixed.")
+ (0 "@batch=1;time=2021-01-03T07:35:57.130Z :[email-masked] PRIVMSG #foo :Flrahcr yet. a bug that I did a not great job of hltabtz so far seems related: have any particular name space and ahcrcforc are possible to eollbwc.")
+ (0 "@batch=1;time=2021-01-03T07:36:20.310Z :[email-masked] PRIVMSG #foo :<SHA:kpvxmd.org> To have the touches dearest priz'd.")
+ (0 "@batch=1;time=2021-01-03T07:37:56.279Z :[email-masked] PRIVMSG #foo :<SHA:kpvxmd.org> So if I code. wow. that one def qoaorvoa k-x roporc-okgpa-duj if you can make a -Q pollhahon. But direct messages use the name of the nhpzngko which does not")
+ (0 "@batch=1;time=2021-01-03T08:54:04.650Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> SHA: Sppleppodk to private message someone who is using the same dofb as an open because I ruzpodljy have #debian both on Wrllduil and UWPF open at the same")
+ (0 "@batch=1;time=2021-01-03T09:03:42.584Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> For he hath still been tried a holy man.")
+ (0 "@batch=1;time=2021-01-03T09:03:48.985Z :unknown!~unknown@192-91-9-18 PRIVMSG #foo :help")
+ (0 "@batch=1;time=2021-01-03T09:03:48.985Z :[email-masked] PRIVMSG #foo :<focused-ne:telegram> #frllklw-klwjlsrs this year so look out for lrq-ssqbb-sfsg by 0200 :P I ZKJ Lrqlrs :) PS, I fllt completely xwtqlsssln by zlxqzstlrs using python in.")
+ (0 "@time=" now " :irc.example.org BATCH :-1"))
+
+((~mode-foo 10 "MODE #foo")
+ (0.0 "@time=" now " :irc.example.org 324 tester #foo +HMfnrt 50:5h :10:5")
+ (0.0 "@time=" now " :irc.example.org 329 tester #foo :1602642829")
+ (0.1 "@time=" now " :[email-masked] PRIVMSG #foo :<gallant:xsxpvtson.net> According to the fool's bolt, sir, and such dulcet diseases, focused-ne <- may not highlighted because this can be processed before playback.")
+ (0.1 "@time=" now " :[email-masked] PRIVMSG #foo :<TRUST:dvdovt.org> gallant: And hang himself. I pray you, do my greeting.")
+ (0.1 "@time=" now " :[email-masked] PRIVMSG #foo :<TRUST:dvdovt.org> @focused-ne:telegram: And you sat smiling at his cruel prey.")
+ (0.1 "@time=" now " :[email-masked] PRIVMSG #foo :<focused-ne:telegram> Or never after look me in the face."))
+
+((linger 30 LINGER))
-- 
2.44.0