diff options
| author | EuAndreh <eu@euandre.org> | 2026-04-25 14:18:57 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-04-25 14:18:57 -0300 |
| commit | 9e8e2d59c1564c9b5c7d99cac43ca2faccae815a (patch) | |
| tree | 76b69c35b76000413b08931dbdd8aa61b4ee9b57 | |
| parent | Fix CAP LS 301 format and add userhost-in-names capability (diff) | |
| download | papod-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.clj | 310 | ||||
| -rw-r--r-- | tests/integration.clj | 3 | ||||
| -rw-r--r-- | tests/unit.clj | 16 |
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.) |
