summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-25 16:05:59 -0300
committerEuAndreh <eu@euandre.org>2026-04-25 16:05:59 -0300
commit5f7d58c81eda3c8de4725a4a0561a10723786d7a (patch)
tree154ff4f0c59e96a728fa05ad9f4c2907d4ceb989
parentImplement channel modes, labeled-response, chathistory BATCH (diff)
downloadpapod-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.clj319
-rw-r--r--tests/integration.clj9
-rw-r--r--tests/unit.clj54
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)]