diff options
| author | EuAndreh <eu@euandre.org> | 2026-04-25 16:34:59 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-04-25 16:34:59 -0300 |
| commit | c8e495f45e6b038d55d14c744151e10bfae03757 (patch) | |
| tree | 05f31b1694e8635b0484c378719af6ea1f791edb | |
| parent | Support comma-separated PRIVMSG and NAMES targets (diff) | |
| download | papod-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.clj | 67 | ||||
| -rw-r--r-- | tests/integration.clj | 3 | ||||
| -rw-r--r-- | tests/unit.clj | 39 |
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) |
