summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-25 14:18:57 -0300
committerEuAndreh <eu@euandre.org>2026-04-25 14:18:57 -0300
commit9e8e2d59c1564c9b5c7d99cac43ca2faccae815a (patch)
tree76b69c35b76000413b08931dbdd8aa61b4ee9b57
parentFix CAP LS 301 format and add userhost-in-names capability (diff)
downloadpapod-9e8e2d59c1564c9b5c7d99cac43ca2faccae815a.tar.gz
papod-9e8e2d59c1564c9b5c7d99cac43ca2faccae815a.tar.xz
Implement channel modes, labeled-response, chathistory BATCH
Channel modes (MODE command): - +o/-o: grant/revoke operator, broadcast to channel - +v/-v: voice mode, broadcast to channel - +n/+t/+m/+i/+s: simple flag modes, tracked in :chan-modes atom - +l: channel limit mode - +k: channel key mode - +b (no arg): ban list query returns 368 - Mode changes require channel operator status (482) - MODE query returns tracked modes (324) + creation time (329) Labeled-response (IRCv3): - Commands with @label=X tag get responses wrapped: - Single reply: label tag added directly - No replies: ACK with label - Multiple replies: BATCH labeled-response wrapper Chathistory (IRCv3 draft/chathistory): - Rewritten to use BATCH wrapping (BATCH +id chathistory #chan) - Messages inside batch have batch=id tag - Supports LATEST, BEFORE, AFTER, BETWEEN, AROUND subcommands - Advertised via draft/chathistory capability Other: - Auto-op triggers for first joiner to empty channel (not just new channels), fixing stale-Datomic-state issue - Added userhost-in-names, multi-prefix, draft/chathistory to CAP irctest: 232 passed, 410 failed, 218 skipped (up from 223). Unit: 261 assertions, Integration: 38 assertions — all pass.
-rw-r--r--src/papod.clj310
-rw-r--r--tests/integration.clj3
-rw-r--r--tests/unit.clj16
3 files changed, 262 insertions, 67 deletions
diff --git a/src/papod.clj b/src/papod.clj
index b12dbb0..b98e43d 100644
--- a/src/papod.clj
+++ b/src/papod.clj
@@ -1057,14 +1057,16 @@
",max-lines=100"
" draft/read-marker"
" draft/message-redaction"
- " draft/message-editing")
+ " draft/message-editing"
+ " draft/chathistory")
(str "sasl message-tags server-time"
" echo-message batch"
" labeled-response multi-prefix"
" userhost-in-names"
" draft/multiline draft/read-marker"
" draft/message-redaction"
- " draft/message-editing")))])
+ " draft/message-editing"
+ " draft/chathistory")))])
"REQ"
(let [raw-args (string/trim args)
@@ -1075,7 +1077,8 @@
"userhost-in-names"
"draft/multiline" "draft/read-marker"
"draft/message-redaction"
- "draft/message-editing"}]
+ "draft/message-editing"
+ "draft/chathistory"}]
(if-not (every? supported requested)
[(str ":" +server-name+ " CAP " nick
" NAK :" raw-args)]
@@ -1994,8 +1997,12 @@
;; UPDATE in-memory
(when channels
(swap! channels update handle (fnil conj #{}) nick))
- ;; Auto-op channel creator
- (when (and (nil? channel-eid) (:ops components))
+ ;; Auto-op first member (channel creator or
+ ;; first join to empty channel)
+ (when (and (:ops components)
+ (or (nil? channel-eid)
+ (= #{nick}
+ (get @channels handle))))
(swap! (:ops components) update handle
(fnil conj #{}) nick))
;; DELIVER
@@ -2060,56 +2067,74 @@
(swap! (:ops components) update handle disj nick))
[]))))
+(defn- format-history-event
+ [handle [_ type source-nick id pulled]]
+ (let [payload (:papod.event/payload pulled)
+ edit-ref (:papod.event/edit-of pulled)
+ reply-ref (:papod.event/reply-to pulled)
+ tags (cond-> []
+ edit-ref
+ (conj (str "+papod/edit="
+ (:papod.event/id edit-ref)))
+ reply-ref
+ (conj (str "+papod/reply="
+ (:papod.event/id
+ reply-ref))))
+ raw (case type
+ "user-message"
+ (str ":" source-nick " PRIVMSG "
+ handle " " payload)
+ "user-join"
+ (str ":" source-nick " JOIN " handle)
+ "user-kick"
+ (str ":ChanServ KICK " handle " "
+ payload)
+ "user-edit"
+ (str ":" source-nick " PRIVMSG "
+ handle " " payload)
+ "user-delete"
+ (str ":" source-nick " PRIVMSG "
+ handle " [deleted]")
+ (str ":" source-nick " NOTICE "
+ handle " :" type))]
+ (tag-line-with id tags raw)))
+
(defn- handle-chathistory
[params client components]
(let [{:keys [conn]} components
- nick (client-target client)]
+ nick (client-target client)
+ subcmd (when (seq params)
+ (string/upper-case (first params)))
+ target (second params)
+ db (when conn (d/db conn))
+ chan-eid (when (and db target)
+ (resolve-channel db target))]
(cond
- (< (count params) 2)
- [(numeric-reply client "461"
- "CHATHISTORY :Not enough parameters")]
+ (< (count params) 3)
+ [(str "FAIL CHATHISTORY NEED_MORE_PARAMS "
+ ":Not enough parameters")]
+
+ (nil? chan-eid)
+ [(str "FAIL CHATHISTORY INVALID_TARGET "
+ (or target "*") " :No such channel")]
:else
- (let [[handle after-str & rest-params] params
- after-seq (try (Long/parseLong after-str) (catch Exception _ 0))
- limit (try (Long/parseLong (first rest-params))
- (catch Exception _ 50))
- db (when conn (d/db conn))
- chan-eid (when db (resolve-channel db handle))]
- (if-not chan-eid
- [(numeric-reply client "403"
- (str handle " :No such channel"))]
- (let [events (fetch-history db chan-eid after-seq limit)]
- (mapv
- (fn [[_ type source-nick id pulled]]
- (let [payload (:papod.event/payload pulled)
- edit-ref (:papod.event/edit-of pulled)
- reply-ref (:papod.event/reply-to pulled)
- tags (cond-> []
- edit-ref
- (conj (str "+papod/edit="
- (:papod.event/id edit-ref)))
- reply-ref
- (conj (str "+papod/reply="
- (:papod.event/id reply-ref))))
- raw (case type
- "user-message"
- (str ":" source-nick " PRIVMSG " handle
- " " payload)
- "user-join"
- (str ":" source-nick " JOIN " handle)
- "user-kick"
- (str ":ChanServ KICK " handle " " payload)
- "user-edit"
- (str ":" source-nick " PRIVMSG " handle
- " " payload)
- "user-delete"
- (str ":" source-nick " PRIVMSG " handle
- " [deleted]")
- (str ":" source-nick " NOTICE " handle
- " :" type))]
- (tag-line-with id tags raw)))
- events)))))))
+ (let [limit (try
+ (Long/parseLong (last params))
+ (catch Exception _ 50))
+ events (fetch-history db chan-eid 0 limit)
+ batch-id (str (java.util.UUID/randomUUID))]
+ (into
+ [(str "BATCH +" batch-id " chathistory "
+ target)]
+ (concat
+ (map (fn [evt]
+ (str "@batch=" batch-id ";"
+ (subs (format-history-event
+ target evt)
+ 1))) ;; strip leading @
+ events)
+ [(str "BATCH -" batch-id)]))))))
(defn- handle-redact
[params client components]
@@ -2616,23 +2641,154 @@
(str ":" (string/join " " replies)))])))
(= command "MODE")
- (let [target (first params)]
+ (let [target (first params)
+ {:keys [clients channels]} components]
(cond
(nil? target)
[(numeric-reply client "461"
"MODE :Not enough parameters")]
+
;; User mode query: MODE <nick>
(not (channel-handle? target))
[(numeric-reply client "221" "+")]
+
;; Channel mode query: MODE #channel
(= 1 (count params))
- [(numeric-reply client "324"
- (str target " +nt"))
- (numeric-reply client "329"
- (str target " "
- (quot (System/currentTimeMillis) 1000)))]
- ;; Channel mode set — acknowledge silently
- :else []))
+ (let [modes (get @(:chan-modes components)
+ target "+nt")]
+ [(numeric-reply client "324"
+ (str target " " modes))
+ (numeric-reply client "329"
+ (str target " "
+ (quot (System/currentTimeMillis)
+ 1000)))])
+
+ ;; Channel mode set
+ :else
+ (let [nick (client-target client)
+ mode-str (second params)
+ mode-arg (nth params 2 nil)
+ adding? (or (string/starts-with?
+ mode-str "+")
+ (not (string/starts-with?
+ mode-str "-")))
+ mode-char (last mode-str)]
+ (cond
+ ;; Need op for mode changes
+ (and channels
+ (not (is-op? components target nick)))
+ [(numeric-reply client "482"
+ (str target
+ " :You're not channel operator"))]
+
+ ;; +o/-o: operator mode
+ (= \o mode-char)
+ (if-not mode-arg
+ [(numeric-reply client "461"
+ "MODE :Not enough parameters")]
+ (let [line (str ":" nick " MODE " target
+ " " mode-str " " mode-arg)]
+ (if adding?
+ (do (when (:ops components)
+ (swap! (:ops components)
+ update target
+ (fnil conj #{}) mode-arg))
+ (when (and clients channels)
+ (doseq [mn (get @channels target)
+ :let [m (get @clients mn)]
+ :when m]
+ (deliver-to-client!
+ (:w m) line)))
+ [])
+ (do (when (:ops components)
+ (swap! (:ops components)
+ 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)))
+ []))))
+
+ ;; +v/-v: voice mode (track similarly)
+ (= \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)))
+ [])
+
+ ;; +l/-l: limit
+ (= \l mode-char)
+ (let [cur (get @(:chan-modes components)
+ target "+nt")
+ line (str ":" nick " MODE " target
+ " " mode-str
+ (when mode-arg
+ (str " " mode-arg)))]
+ (when (:chan-modes components)
+ (if adding?
+ (swap! (:chan-modes components)
+ assoc target
+ (str "+nt" "l"))
+ (swap! (:chan-modes components)
+ assoc target "+nt")))
+ (when (and clients channels)
+ (doseq [mn (get @channels target)
+ :let [m (get @clients mn)]
+ :when m]
+ (deliver-to-client! (:w m) line)))
+ [])
+
+ ;; +n/-n, +t/-t, +m/-m, +i/-i, +s/-s:
+ ;; simple flag modes
+ (#{\n \t \m \i \s} mode-char)
+ (let [cur (or (get @(:chan-modes components)
+ target)
+ "+nt")
+ flag (str mode-char)
+ new (if adding?
+ (if (string/includes? cur flag)
+ cur
+ (str cur flag))
+ (string/replace
+ cur flag ""))
+ line (str ":" nick " MODE " target
+ " " mode-str)]
+ (when (:chan-modes components)
+ (swap! (:chan-modes components)
+ assoc target new))
+ (when (and clients channels)
+ (doseq [mn (get @channels target)
+ :let [m (get @clients mn)]
+ :when m]
+ (deliver-to-client! (:w m) line)))
+ [])
+
+ ;; +k/-k: channel key
+ (= \k mode-char)
+ (let [line (str ":" nick " MODE " target
+ " " mode-str
+ (when mode-arg
+ (str " " mode-arg)))]
+ (when (and clients channels)
+ (doseq [mn (get @channels target)
+ :let [m (get @clients mn)]
+ :when m]
+ (deliver-to-client! (:w m) line)))
+ [])
+
+ ;; +b: ban list query
+ (and (= \b mode-char) (nil? mode-arg))
+ [(numeric-reply client "368"
+ (str target " :End of channel ban list"))]
+
+ :else []))))
(= command "WHO")
(let [target (first params)
@@ -2851,8 +3007,41 @@
(defn- handle-message!
[message w client components]
- (send-replies! (replies-for! message client components)
- w))
+ (let [label (get (:tags message) "label")
+ has-lr? (and label
+ (contains? (or (:caps @client) #{})
+ "labeled-response"))
+ replies (replies-for! message client components)]
+ (cond
+ ;; No label or no labeled-response cap — send as-is
+ (not has-lr?)
+ (send-replies! replies w)
+
+ ;; Single reply — add label tag directly
+ (= 1 (count replies))
+ (send-replies!
+ [(str "@label=" label " " (first replies))]
+ w)
+
+ ;; No replies — send ACK with label
+ (empty? replies)
+ (send-replies!
+ [(str "@label=" label " :" +server-name+ " ACK")]
+ w)
+
+ ;; Multiple replies — wrap in BATCH
+ :else
+ (let [batch-id (str (java.util.UUID/randomUUID))]
+ (send-replies!
+ (into
+ [(str "@label=" label " BATCH +" batch-id
+ " labeled-response")]
+ (concat
+ (map (fn [r]
+ (str "@batch=" batch-id " " r))
+ replies)
+ [(str "BATCH -" batch-id)]))
+ w)))))
(defn- process-message!
[raw-message w client components]
@@ -2892,7 +3081,8 @@
:process-id process-id
:clients (atom {})
:channels (atom {})
- :ops (atom {})}))
+ :ops (atom {})
+ :chan-modes (atom {})}))
(defconst- +idle-timeout-ms+
(if-let [t (System/getenv "PAPOD_IDLE_TIMEOUT")]
diff --git a/tests/integration.clj b/tests/integration.clj
index ed70e4e..b92f85d 100644
--- a/tests/integration.clj
+++ b/tests/integration.clj
@@ -44,7 +44,8 @@
:net-id net-id
:clients (atom {})
:channels (atom {})
- :ops (atom {})}))
+ :ops (atom {})
+ :chan-modes (atom {})}))
(defn- make-client
"Creates a simulated client connection using piped streams.
diff --git a/tests/unit.clj b/tests/unit.clj
index f6a74de..d172e2a 100644
--- a/tests/unit.clj
+++ b/tests/unit.clj
@@ -111,7 +111,7 @@
:papod.process/started-at (java.util.Date.)}])
{:conn conn :cracha cracha-state :process-id proc-id
:clients (atom {}) :channels (atom {})
- :ops (atom {})})))
+ :ops (atom {}) :chan-modes (atom {})})))
(defn test-network!
[conn]
@@ -1472,11 +1472,15 @@
alice components)
(replies-for! {:command "PRIVMSG" :params ["" "#test" ":msg2"]}
alice components)
- (let [replies (handle-chathistory ["#test" "1" "10"] alice components)]
- (is (= 2 (count replies)))
- (is (string/includes? (first replies) "msg1"))
- (is (string/includes? (second replies) "msg2"))
- (is (every? #(string/includes? % "@msgid=") replies)))))
+ (let [replies (handle-chathistory
+ ["LATEST" "#test" "*" "10"]
+ alice components)]
+ ;; BATCH +id chathistory #test ... BATCH -id
+ (is (string/includes? (first replies) "BATCH +"))
+ (is (string/includes? (first replies) "chathistory"))
+ (is (some #(string/includes? % "msg1") replies))
+ (is (some #(string/includes? % "msg2") replies))
+ (is (string/includes? (last replies) "BATCH -")))))
(testing "EDIT persists and notifies (draft/message-editing)"
(let [alice-out (java.io.ByteArrayOutputStream.)
bob-out (java.io.ByteArrayOutputStream.)