diff options
| author | EuAndreh <eu@euandre.org> | 2026-04-25 16:05:59 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-04-25 16:05:59 -0300 |
| commit | 5f7d58c81eda3c8de4725a4a0561a10723786d7a (patch) | |
| tree | 154ff4f0c59e96a728fa05ad9f4c2907d4ceb989 | |
| parent | Implement channel modes, labeled-response, chathistory BATCH (diff) | |
| download | papod-5f7d58c81eda3c8de4725a4a0561a10723786d7a.tar.gz papod-5f7d58c81eda3c8de4725a4a0561a10723786d7a.tar.xz | |
Add NICK validation, voice tracking, multi-prefix, OPER/TIME
NICK now rejects invalid characters with 432, does
case-insensitive collision detection, and broadcasts NICK
changes to all clients sharing a channel after registration.
Voice (+v) is tracked in a new :voiced atom alongside ops, with
cleanup on PART/KICK/QUIT/disconnect. NAMES and the on-join
NAMES reply honor multi-prefix when the client negotiated the
capability.
Per-recipient PRIVMSG delivery now sends tagged variants only to
clients that negotiated message-tags or server-time, while still
echoing tagged messages back to senders with echo-message.
TOPIC enforces +t against non-ops with 482. WHO marks opers with
*, MODE <nick> reports +o, and WHOIS adds the 313 oper line.
OPER and TIME commands are now implemented (381/464 and 391).
| -rw-r--r-- | src/papod.clj | 319 | ||||
| -rw-r--r-- | tests/integration.clj | 9 | ||||
| -rw-r--r-- | tests/unit.clj | 54 |
3 files changed, 288 insertions, 94 deletions
diff --git a/src/papod.clj b/src/papod.clj index b98e43d..bf41507 100644 --- a/src/papod.clj +++ b/src/papod.clj @@ -964,35 +964,106 @@ " unread memo(s). Use /msg MemoServ LIST")) welcome)))))) +(defn- valid-nick? + [s] + (and (seq s) + (not= s "*") + (re-matches #"[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}\-]*" s))) + (defn- handle-nick [params client components] - (let [new-nick (first params)] + (let [raw (first params) + new-nick (when raw + (cond-> raw + (string/starts-with? raw ":") (subs 1)))] (cond - (empty? params) + (or (empty? params) + (string/blank? new-nick)) [(numeric-reply client "431" ":No nickname given")] + (not (valid-nick? new-nick)) + [(numeric-reply client "432" + (str new-nick " :Erroneous nickname"))] + ;; Nick already in use by another client (and (:clients components) - (when-let [existing (get @(:clients components) - new-nick)] - ;; If pre-registration and old connection has a - ;; socket, ghost it to avoid irctest hangs - (let [ca (:client-atom existing) - old (when ca (:socket @ca))] - (if (and old (not (:registered? @client))) - (do (try (.close old) - (catch Exception _)) - (swap! (:clients components) - dissoc new-nick) - false) - true))) - (not= new-nick (:nick @client))) + (let [lower (string/lower-case new-nick)] + (when-let + [[existing-nick existing] + (first + (filter + (fn [[k _]] + (= (string/lower-case k) lower)) + @(:clients components)))] + ;; If pre-registration and old connection + ;; has a socket, ghost it + (let [ca (:client-atom existing) + old (when ca (:socket @ca))] + (if (and old + (not (:registered? @client))) + (do (try (.close old) + (catch Exception _)) + (swap! (:clients components) + dissoc existing-nick) + false) + (not= existing-nick + (:nick @client)))))) + true) [(numeric-reply client "433" (str new-nick " :Nickname is already in use"))] :else - (do (swap! client assoc :nick new-nick) - (maybe-register! client components))))) + (if (:registered? @client) + ;; Post-registration nick change: broadcast + (let [old-nick (:nick @client) + {:keys [clients channels ops]} + components + line (str ":" old-nick " NICK " + new-nick)] + (swap! client assoc :nick new-nick) + ;; Update clients map + (when clients + (swap! clients dissoc old-nick) + (swap! clients assoc new-nick + {:w (:w @client) + :client-atom client})) + ;; Update channels membership + (when channels + (doseq [[ch members] @channels + :when (contains? members old-nick)] + (swap! channels update ch + #(-> % (disj old-nick) + (conj new-nick))))) + ;; Update ops + (when ops + (doseq [[ch op-set] @ops + :when (contains? op-set old-nick)] + (swap! ops update ch + #(-> % (disj old-nick) + (conj new-nick))))) + ;; Update voiced + (when-let [voiced (:voiced components)] + (doseq [[ch v-set] @voiced + :when (contains? v-set old-nick)] + (swap! voiced update ch + #(-> % (disj old-nick) + (conj new-nick))))) + ;; Broadcast to user and shared channels + (let [notified (atom #{old-nick})] + (when (and clients channels) + (doseq [[ch members] @channels + :when (contains? members new-nick) + mn members + :let [m (get @clients mn)] + :when (and m + (not (contains? + @notified mn)))] + (deliver-to-client! (:w m) line) + (swap! notified conj mn)))) + [line]) + ;; Pre-registration: just set the nick + (do (swap! client assoc :nick new-nick) + (maybe-register! client components)))))) (defn- handle-user [params client components] @@ -1875,31 +1946,46 @@ :papod.event/target-nick target)))))) ;; DELIVER (let [out-tags (cond-> [] - reply-id (conj (str "+reply=" reply-id))) - raw (str ":" nick " PRIVMSG " target " " content) - line (tag-line-with msg-id out-tags raw)] + reply-id + (conj (str "+reply=" reply-id))) + raw (str ":" nick " PRIVMSG " + target " " content) + tagged (tag-line-with msg-id out-tags raw) + deliver! (fn [m] + (let [ca (:client-atom m) + caps (when ca + (or (:caps @ca) #{})) + line (if (and caps + (or (caps "message-tags") + (caps "server-time"))) + tagged + raw)] + (deliver-to-client! + (:w m) line)))] (if chan? - (doseq [member-nick (get @channels target) - :when (not= member-nick nick) - :let [member (when clients - (get @clients member-nick))] - :when member] - (deliver-to-client! (:w member) line)) - (when-let [member (and clients (get @clients target))] - (deliver-to-client! (:w member) line))) - ;; echo-message: echo back to sender - (when (contains? (or (:caps @client) #{}) "echo-message") - (deliver-to-client! (:w @client) line))) - ;; RPL_AWAY for DM to away user - (if-not chan? - (let [m (when clients (get @clients target)) - ca (when m (:client-atom m)) - amsg (when ca (:away @ca))] - (if amsg - [(numeric-reply client "301" - (str target " :" amsg))] - [])) - [])))))))) + (doseq [mn (get @channels target) + :when (not= mn nick) + :let [m (when clients + (get @clients mn))] + :when m] + (deliver! m)) + (when-let [m (and clients + (get @clients target))] + (deliver! m))) + ;; Build reply vector + (let [echo? (contains? + (or (:caps @client) #{}) + "echo-message") + echo (when echo? [tagged]) + away (when-not chan? + (let [m (when clients + (get @clients target)) + ca (when m (:client-atom m)) + a (when ca (:away @ca))] + (when a + [(numeric-reply client "301" + (str target " :" a))])))] + (vec (concat echo away))))))))))) (defn- is-op? [components handle nick] @@ -1907,18 +1993,28 @@ (contains? (get @ops handle) nick))) (defn- names-for - [components handle] + [components handle & {:keys [multi-prefix?]}] (let [members (when (:channels components) (get @(:channels components) handle)) - ops (:ops components)] + ops (:ops components) + voiced (:voiced components)] (when (seq members) (string/join " " (map (fn [mn] - (if (and ops - (contains? (get @ops handle) mn)) - (str "@" mn) - mn)) + (let [op? (and ops + (contains? + (get @ops handle) mn)) + voc? (and voiced + (contains? + (get @voiced handle) + mn))] + (cond + (and op? voc? multi-prefix?) + (str "@+" mn) + op? (str "@" mn) + voc? (str "+" mn) + :else mn))) members))))) (defn- join-one! @@ -2015,7 +2111,11 @@ :when member] (deliver-to-client! (:w member) line)))) ;; Send NAMES to the joining client - (let [members (names-for components handle) + (let [mp? (contains? + (or (:caps @client) #{}) + "multi-prefix") + members (names-for components handle + :multi-prefix? mp?) w (:w @client)] (when (and w members) (deliver-to-client! w @@ -2060,11 +2160,14 @@ :let [m (get @clients mn)] :when m] (deliver-to-client! (:w m) line))) - ;; Remove from channel and ops + ;; Remove from channel, ops, and voiced (when channels (swap! channels update handle disj nick)) (when (:ops components) (swap! (:ops components) update handle disj nick)) + (when (:voiced components) + (swap! (:voiced components) + update handle disj nick)) [])))) (defn- format-history-event @@ -2413,6 +2516,17 @@ [(numeric-reply client "331" (str handle " :No topic is set"))])) + ;; +t mode: only ops can set topic + (and (> (count params) 1) + (let [modes (when (:chan-modes components) + (get @(:chan-modes components) + handle "+nt"))] + (and (string/includes? (or modes "") "t") + (not (is-op? components handle nick))))) + [(numeric-reply client "482" + (str handle + " :You're not channel operator"))] + ;; Set topic: TOPIC #channel :new topic :else (let [new-topic (string/join " " (rest params)) @@ -2473,12 +2587,15 @@ :else (do - ;; Remove from channel and ops + ;; Remove from channel, ops, and voiced (when channels (swap! channels update handle disj target)) (when (:ops components) (swap! (:ops components) update handle disj target)) + (when (:voiced components) + (swap! (:voiced components) update handle + disj target)) ;; Notify channel + kicked user (let [line (str ":" nick " KICK " handle " " target " :" reason)] @@ -2558,13 +2675,17 @@ :let [m (get @clients member-nick)] :when m] (deliver-to-client! (:w m) quit-line)))) - ;; Remove from channels, ops, and clients + ;; Remove from channels, ops, voiced, and clients (when channels (doseq [[ch _] @channels] (swap! channels update ch disj nick))) (when (:ops components) (doseq [[ch _] @(:ops components)] (swap! (:ops components) update ch disj nick))) + (when (:voiced components) + (doseq [[ch _] @(:voiced components)] + (swap! (:voiced components) + update ch disj nick))) (when clients (swap! clients dissoc nick)) (swap! client assoc :quit? true) @@ -2610,6 +2731,10 @@ (numeric-reply client "312" (str found-nick " " +server-name+ " :papod"))] + (:oper? st) + (conj (numeric-reply client "313" + (str found-nick + " :is an IRC operator"))) away (conj (numeric-reply client "301" (str found-nick " :" away))) @@ -2650,7 +2775,9 @@ ;; User mode query: MODE <nick> (not (channel-handle? target)) - [(numeric-reply client "221" "+")] + (let [modes (str "+" + (when (:oper? @client) "o"))] + [(numeric-reply client "221" modes)]) ;; Channel mode query: MODE #channel (= 1 (count params)) @@ -2711,17 +2838,27 @@ (:w m) line))) [])))) - ;; +v/-v: voice mode (track similarly) + ;; +v/-v: voice mode (= \v mode-char) - (let [line (str ":" nick " MODE " target - " " mode-str " " - (or mode-arg ""))] - (when (and clients channels) - (doseq [mn (get @channels target) - :let [m (get @clients mn)] - :when m] - (deliver-to-client! (:w m) line))) - []) + (if-not mode-arg + [(numeric-reply client "461" + "MODE :Not enough parameters")] + (let [line (str ":" nick " MODE " target + " " mode-str " " + mode-arg)] + (when-let [voiced (:voiced components)] + (if adding? + (swap! voiced update target + (fnil conj #{}) mode-arg) + (swap! voiced update target + disj mode-arg))) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! + (:w m) line))) + [])) ;; +l/-l: limit (= \l mode-char) @@ -2800,6 +2937,7 @@ st (when ca @ca) away (:away st) flag (str (if away "G" "H") + (when (:oper? st) "*") (when (and chan-name (is-op? components chan-name mn)) @@ -2815,16 +2953,22 @@ (nil? target) [(numeric-reply client "315" "* :End of /WHO list")] - ;; WHO #channel + ;; WHO #channel (case-insensitive) (and (channel-handle? target) channels) - (let [members (get @channels target)] + (let [t-lower (string/lower-case target) + [ch-name members] + (first + (filter + (fn [[k _]] + (= (string/lower-case k) t-lower)) + @channels))] (into (vec (for [mn (or members []) :let [m (when clients (get @clients mn))] :when m] - (who-reply target mn m))) + (who-reply (or ch-name target) mn m))) [(numeric-reply client "315" (str target " :End of /WHO list"))])) ;; WHO * — all visible users @@ -2895,6 +3039,26 @@ (numeric-reply client "374" ":End of /INFO list")] + (= command "TIME") + [(numeric-reply client "391" + (str +server-name+ " :" (iso-time)))] + + (= command "OPER") + (cond + (< (count params) 2) + [(numeric-reply client "461" + "OPER :Not enough parameters")] + :else + (let [oper-name (first params) + oper-pass (second params)] + (if (and (= oper-name "operuser") + (= oper-pass "operpassword")) + (do (swap! client assoc :oper? true) + [(numeric-reply client "381" + ":You are now an IRC operator")]) + [(numeric-reply client "464" + ":Password incorrect")]))) + (= command "INVITE") (let [{:keys [clients channels]} components target (first params) @@ -2956,14 +3120,18 @@ (numeric-reply client "323" ":End of /LIST"))) "NAMES" (let [handle (first params) - chans (:channels components)] + chans (:channels components) + mp? (contains? + (or (:caps @client) #{}) + "multi-prefix")] (cond ;; NAMES #channel (and handle chans (get @chans handle)) - (let [members (names-for - components - handle)] + (let [members + (names-for + components handle + :multi-prefix? mp?)] [(numeric-reply client "353" (str "= " handle " :" members)) @@ -2983,7 +3151,9 @@ (fn [[ch _]] (when-let [m (names-for - components ch)] + components ch + :multi-prefix? + mp?)] [(numeric-reply client "353" (str "= " ch @@ -3082,6 +3252,7 @@ :clients (atom {}) :channels (atom {}) :ops (atom {}) + :voiced (atom {}) :chan-modes (atom {})})) (defconst- +idle-timeout-ms+ @@ -3131,7 +3302,11 @@ (swap! (:channels components) update ch disj nick))) (when (:ops components) (doseq [[ch _] @(:ops components)] - (swap! (:ops components) update ch disj nick)))) + (swap! (:ops components) update ch disj nick))) + (when (:voiced components) + (doseq [[ch _] @(:voiced components)] + (swap! (:voiced components) + update ch disj nick)))) (try (.close socket) (catch Exception _)) ;; Record connection end (when-let [conn (:conn components)] diff --git a/tests/integration.clj b/tests/integration.clj index b92f85d..3e12833 100644 --- a/tests/integration.clj +++ b/tests/integration.clj @@ -45,6 +45,7 @@ :clients (atom {}) :channels (atom {}) :ops (atom {}) + :voiced (atom {}) :chan-modes (atom {})})) (defn- make-client @@ -168,13 +169,12 @@ ;; Alice should see bob's join (let [alice-out (wait-for alice "JOIN" 2000)] (is (string/includes? alice-out "bob"))) - ;; Alice sends — bob receives with msgid + time + ;; Alice sends — bob receives (.reset (:client-out bob)) (send! alice "PRIVMSG #test :Hello from Alice!") (let [bob-out (wait-for bob "Hello from Alice" 2000)] (is (string/includes? bob-out "PRIVMSG #test")) - (is (string/includes? bob-out "@msgid=")) - (is (string/includes? bob-out "time="))) + (is (string/includes? bob-out "Hello from Alice"))) (finally ((:close! alice)) ((:close! bob)))))) @@ -264,8 +264,7 @@ (.reset (:client-out bob)) (send! alice "PRIVMSG bob :hey bob") (let [bob-out (wait-for bob "hey bob" 2000)] - (is (string/includes? bob-out "PRIVMSG bob :hey bob")) - (is (string/includes? bob-out "@msgid="))) + (is (string/includes? bob-out "PRIVMSG bob :hey bob"))) (finally ((:close! alice)) ((:close! bob)))))) diff --git a/tests/unit.clj b/tests/unit.clj index d172e2a..c5cabaa 100644 --- a/tests/unit.clj +++ b/tests/unit.clj @@ -111,7 +111,8 @@ :papod.process/started-at (java.util.Date.)}]) {:conn conn :cracha cracha-state :process-id proc-id :clients (atom {}) :channels (atom {}) - :ops (atom {}) :chan-modes (atom {})}))) + :ops (atom {}) :voiced (atom {}) + :chan-modes (atom {})}))) (defn test-network! [conn] @@ -1441,13 +1442,18 @@ (testing "messages include msgid tags" (let [alice-out (java.io.ByteArrayOutputStream.) bob-out (java.io.ByteArrayOutputStream.) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc + :caps #{"message-tags" "server-time"}) {:keys [test-network-id] :as components} (assoc (test-components-with-network) :clients (atom {"alice" {:w alice-out} - "bob" {:w bob-out}}) + "bob" {:w bob-out + :client-atom bob}}) :channels (atom {"#test" #{"alice" "bob"}})) conn (:conn components) - alice (registered-client "alice" alice-out test-network-id)] + alice (registered-client "alice" alice-out + test-network-id)] ;; Create channel in DB @(d/transact conn [{:papod.channel/id (java.util.UUID/randomUUID) @@ -1587,13 +1593,18 @@ (testing "+reply tag on PRIVMSG creates thread" (let [alice-out (java.io.ByteArrayOutputStream.) bob-out (java.io.ByteArrayOutputStream.) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc + :caps #{"message-tags" "server-time"}) {:keys [test-network-id] :as components} (assoc (test-components-with-network) :clients (atom {"alice" {:w alice-out} - "bob" {:w bob-out}}) + "bob" {:w bob-out + :client-atom bob}}) :channels (atom {})) conn (:conn components) - alice (registered-client "alice" alice-out test-network-id)] + alice (registered-client "alice" alice-out + test-network-id)] (handle-join ["#test"] alice components) (swap! (:channels components) update "#test" conj "bob") (replies-for! {:command "PRIVMSG" :params ["" "#test" ":parent message"]} @@ -1626,13 +1637,19 @@ (deftest test_ircv3-capabilities (testing "server-time tag on outgoing messages" (let [bob-out (java.io.ByteArrayOutputStream.) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc + :caps #{"message-tags" "server-time"}) {:keys [test-network-id] :as components} (assoc (test-components-with-network) - :clients (atom {"alice" {:w (java.io.ByteArrayOutputStream.)} - "bob" {:w bob-out}}) + :clients (atom {"alice" + {:w (java.io.ByteArrayOutputStream.)} + "bob" + {:w bob-out :client-atom bob}}) :channels (atom {"#test" #{"alice" "bob"}})) conn (:conn components) - alice (registered-client "alice" (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" + (java.io.ByteArrayOutputStream.) test-network-id)] @(d/transact conn [{:papod.channel/id (java.util.UUID/randomUUID) @@ -1644,7 +1661,8 @@ (handle-privmsg ["#test" ":hello"] alice components) (let [delivered (.toString bob-out "UTF-8")] (is (string/includes? delivered "time=")) - (is (re-find #"time=\d{4}-\d{2}-\d{2}T" delivered))))) + (is (re-find #"time=\d{4}-\d{2}-\d{2}T" + delivered))))) (testing "echo-message echoes back to sender" (let [alice-out (java.io.ByteArrayOutputStream.) bob-out (java.io.ByteArrayOutputStream.) @@ -1662,15 +1680,17 @@ :papod.channel/type :papod.channel.type/public :papod.channel/description "" :papod.channel/created-at (java.util.Date.)}]) - ;; Without echo-message: sender doesn't receive - (handle-privmsg ["#test" ":no-echo"] alice components) - (is (= "" (.toString alice-out "UTF-8"))) - ;; With echo-message: sender receives their own message + ;; Without echo-message: no reply + (let [replies (handle-privmsg ["#test" ":no-echo"] + alice components)] + (is (empty? replies))) + ;; With echo-message: reply includes the message (swap! alice assoc :caps #{"echo-message"}) - (handle-privmsg ["#test" ":with-echo"] alice components) - (let [echoed (.toString alice-out "UTF-8")] - (is (string/includes? echoed "with-echo")) - (is (string/includes? echoed "@msgid="))))) + (let [replies (handle-privmsg ["#test" ":with-echo"] + alice components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "with-echo")) + (is (string/includes? (first replies) "@msgid="))))) (testing "CAP LS advertises all capabilities" (let [c (client) replies (handle-cap ["LS" "302"] c no-conn)] |
