summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-25 16:34:59 -0300
committerEuAndreh <eu@euandre.org>2026-04-25 16:34:59 -0300
commitc8e495f45e6b038d55d14c744151e10bfae03757 (patch)
tree05f31b1694e8635b0484c378719af6ea1f791edb
parentSupport comma-separated PRIVMSG and NAMES targets (diff)
downloadpapod-c8e495f45e6b038d55d14c744151e10bfae03757.tar.gz
papod-c8e495f45e6b038d55d14c744151e10bfae03757.tar.xz
WHOWAS now returns history of recently-disconnected clients
QUIT and socket cleanup paths push a small (last 32) ring of {nick, username, realname, host, ts} entries into a new :whowas atom on the components map. WHOWAS reads that ring case- insensitively and answers with RPL_WHOWASUSER (314) + RPL_WHOISSERVER (312) for each match, terminated with RPL_ENDOFWHOWAS (369), or falls back to ERR_WASNOSUCHNICK (406) when nothing is found.
-rw-r--r--src/papod.clj67
-rw-r--r--tests/integration.clj3
-rw-r--r--tests/unit.clj39
3 files changed, 100 insertions, 9 deletions
diff --git a/src/papod.clj b/src/papod.clj
index eb4d6fa..21999e9 100644
--- a/src/papod.clj
+++ b/src/papod.clj
@@ -2746,6 +2746,19 @@
(let [nick (client-target client)
reason (or (first params) "Client quit")
{:keys [clients channels]} components]
+ ;; Record WHOWAS entry for registered clients
+ (when (and (:registered? @client)
+ (:whowas components))
+ (let [u (:user @client)]
+ (swap! (:whowas components)
+ (fn [hist]
+ (let [entry
+ {:nick nick
+ :username (:username u)
+ :realname (:realname u)
+ :host "localhost"
+ :ts (System/currentTimeMillis)}]
+ (vec (take 32 (cons entry hist))))))))
;; Notify channel members
(when (and clients channels)
(let [quit-line (str ":" nick " QUIT :Quit: " reason)]
@@ -3164,13 +3177,39 @@
[(numeric-reply client "422" ":MOTD File is missing")]
(= command "WHOWAS")
- (if (empty? params)
+ (cond
+ (empty? params)
[(numeric-reply client "431" ":No nickname given")]
- (let [target (first params)]
- [(numeric-reply client "406"
- (str target " :There was no such nickname"))
- (numeric-reply client "369"
- (str target " :End of WHOWAS"))]))
+ :else
+ (let [target (first params)
+ t-lower (string/lower-case target)
+ hist (when (:whowas components)
+ @(:whowas components))
+ matches (when hist
+ (filter
+ #(= (string/lower-case (:nick %))
+ t-lower)
+ hist))]
+ (if (seq matches)
+ (-> (vec
+ (mapcat
+ (fn [{:keys [nick username realname host]}]
+ [(numeric-reply client "314"
+ (str nick " " (or username nick)
+ " " (or host "localhost")
+ " * :"
+ (or realname nick)))
+ (numeric-reply client "312"
+ (str nick " " +server-name+
+ " :papod"))])
+ matches))
+ (conj (numeric-reply client "369"
+ (str target
+ " :End of WHOWAS"))))
+ [(numeric-reply client "406"
+ (str target " :There was no such nickname"))
+ (numeric-reply client "369"
+ (str target " :End of WHOWAS"))])))
(= command "INFO")
[(numeric-reply client "371"
@@ -3420,7 +3459,8 @@
:chan-modes (atom {})
:chan-keys (atom {})
:chan-limits (atom {})
- :invites (atom {})}))
+ :invites (atom {})
+ :whowas (atom [])}))
(defconst- +idle-timeout-ms+
(if-let [t (System/getenv "PAPOD_IDLE_TIMEOUT")]
@@ -3462,6 +3502,19 @@
(finally
;; Clean up in-memory state FIRST (before slow I/O)
(when-let [nick (:nick @client)]
+ ;; Record WHOWAS history for registered clients
+ (when (and (:registered? @client)
+ (:whowas components))
+ (let [u (:user @client)]
+ (swap! (:whowas components)
+ (fn [hist]
+ (let [entry
+ {:nick nick
+ :username (:username u)
+ :realname (:realname u)
+ :host "localhost"
+ :ts (System/currentTimeMillis)}]
+ (vec (take 32 (cons entry hist))))))))
(when (:clients components)
(swap! (:clients components) dissoc nick))
(when (:channels components)
diff --git a/tests/integration.clj b/tests/integration.clj
index 39993dc..84f8647 100644
--- a/tests/integration.clj
+++ b/tests/integration.clj
@@ -49,7 +49,8 @@
:chan-modes (atom {})
:chan-keys (atom {})
:chan-limits (atom {})
- :invites (atom {})}))
+ :invites (atom {})
+ :whowas (atom [])}))
(defn- make-client
"Creates a simulated client connection using piped streams.
diff --git a/tests/unit.clj b/tests/unit.clj
index 5439c71..b8fc57f 100644
--- a/tests/unit.clj
+++ b/tests/unit.clj
@@ -115,7 +115,8 @@
:chan-modes (atom {})
:chan-keys (atom {})
:chan-limits (atom {})
- :invites (atom {})})))
+ :invites (atom {})
+ :whowas (atom [])})))
(defn test-network!
[conn]
@@ -1870,6 +1871,42 @@
(swap! (:chan-limits comp) assoc "#small" 1)
(let [replies (handle-join ["#small"] bob comp)]
(is (string/includes? (first replies) "471")))))
+ (testing "WHOWAS returns history after QUIT"
+ (let [c (registered-client "alice"
+ (java.io.ByteArrayOutputStream.))
+ components (test-components)
+ comp (assoc components
+ :clients (atom {"bob" {:client-atom
+ (atom
+ {:nick "bob"
+ :registered? true
+ :user
+ {:username "bobu"
+ :realname "Bob"}})
+ :w
+ (java.io.ByteArrayOutputStream.)}}))]
+ ;; Bob quits
+ (let [bob (:client-atom (get @(:clients comp) "bob"))]
+ (replies-for! {:command "QUIT" :params [":bye"]}
+ bob comp))
+ ;; Alice WHOWAS bob — gets history
+ (let [replies (replies-for!
+ {:command "WHOWAS" :params ["" "bob"]}
+ c comp)]
+ (is (>= (count replies) 2))
+ (is (some #(re-find #" 314 " %) replies))
+ (is (some #(re-find #" 369 " %) replies))
+ (is (not (some #(re-find #" 406 " %) replies))))))
+ (testing "WHOWAS for unknown nick returns 406"
+ (let [c (registered-client "alice"
+ (java.io.ByteArrayOutputStream.))
+ components (test-components)]
+ (let [replies (replies-for!
+ {:command "WHOWAS"
+ :params ["" "neverexisted"]}
+ c components)]
+ (is (some #(re-find #" 406 " %) replies))
+ (is (some #(re-find #" 369 " %) replies)))))
(testing "+k key blocks JOIN with 475"
(let [{:keys [test-network-id] :as components}
(test-components-with-network)