diff options
| author | EuAndreh <eu@euandre.org> | 2026-04-23 10:03:07 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-04-23 10:03:07 -0300 |
| commit | d879cbae489b4695c581c16d5659cadd100bd3c1 (patch) | |
| tree | 1aeb7ea85206d8346ccd366eb0bf22ac6a1d92e4 | |
| parent | m (diff) | |
| download | papod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.gz papod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.xz | |
m
| -rw-r--r-- | src/papod.clj | 539 | ||||
| -rwxr-xr-x | tests/acceptance.sh | 59 | ||||
| -rw-r--r-- | tests/integration.clj | 302 | ||||
| -rw-r--r-- | tests/unit.clj | 348 |
4 files changed, 1181 insertions, 67 deletions
diff --git a/src/papod.clj b/src/papod.clj index aaa5c23..ae0eef6 100644 --- a/src/papod.clj +++ b/src/papod.clj @@ -5,10 +5,12 @@ [clojure.set :as set] [clojure.string :as string] [cracha] - [datomic.api :as d]) + [datomic.api :as d] + [fiinha]) (:import (java.net StandardProtocolFamily UnixDomainSocketAddress) (java.nio.channels ServerSocketChannel) - (java.nio.file Files))) + (java.nio.file Files)) + (:gen-class)) @@ -336,6 +338,62 @@ :db/valueType :db.type/long :db/cardinality :db.cardinality/one :db/index true} + {:db/ident :papod.event/reply-to + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/edit-of + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/delete-of + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + ;; Reaction + {:db/ident :papod.reaction/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.reaction/event + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.reaction/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.reaction/emoji + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.reaction/event+nick+emoji + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.reaction/event + :papod.reaction/nick + :papod.reaction/emoji]} + ;; Read marker + {:db/ident :papod.read-marker/channel + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.read-marker/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.read-marker/timestamp + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.read-marker/channel+nick + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity + :db/tupleAttrs [:papod.read-marker/channel + :papod.read-marker/nick]} ;; Entity attr groups {:db/ident :papod.network/attrs :db.entity/attrs #{:papod.network/id @@ -396,6 +454,11 @@ :papod.event/type :papod.event/source-nick :papod.event/created-at}} + {:db/ident :papod.reaction/attrs + :db.entity/attrs #{:papod.reaction/id + :papod.reaction/event + :papod.reaction/nick + :papod.reaction/emoji}} ;; Database functions {:db/ident :papod.event/add-seq :db/fn add-event-seq} @@ -467,6 +530,27 @@ :raw-message (and (not (string/blank? payload)) payload)})))) +(defn- parse-tags + [s] + {:pre [(string/starts-with? s "@")] + :post [(let [[tags offset] %] + (and (map? tags) + (number? offset) + (pos? offset)))]} + (let [end (string/index-of s +separator+)] + (if-not end + [{} (count s)] + (let [tag-str (subs s 1 end) + pairs (string/split tag-str #";")] + [(->> pairs + (map (fn [pair] + (let [eq (string/index-of pair "=")] + (if eq + [(subs pair 0 eq) (subs pair (inc eq))] + [pair nil])))) + (into {})) + (inc end)])))) + (defn- valid-err? [[x offset {:keys [message type data] :as err}]] (and (nil? x) @@ -608,8 +692,9 @@ (recur rest (conj out part)))))) (defn- valid-message? - [{:keys [prefix command params] :as message}] + [{:keys [tags prefix command params] :as message}] (and (map? message) + (or (nil? tags) (map? tags)) (or (nil? prefix) (valid-prefix? prefix)) (string? command) @@ -623,19 +708,24 @@ (if err (valid-err? [nil nil err]) (valid-message? message)))]} - (let [[prefix offset1 err] (if (string/starts-with? raw-message ":") - (parse-prefix raw-message) + (let [[tags offset0] (if (string/starts-with? raw-message "@") + (parse-tags raw-message) + [nil 0]) + rest0 (subs raw-message offset0) + [prefix offset1 err] (if (string/starts-with? rest0 ":") + (parse-prefix rest0) [nil 0])] (if err [nil err] - (let [[command offset2 err] (parse-command (subs raw-message offset1))] + (let [[command offset2 err] (parse-command (subs rest0 offset1))] (if err [nil err] - (let [[params _ err] (parse-params (subs raw-message + (let [[params _ err] (parse-params (subs rest0 (+ offset1 offset2)))] (if err [nil err] - [{:prefix prefix + [{:tags tags + :prefix prefix :command command :params params} nil]))))))) @@ -660,6 +750,39 @@ [params] (filterv (complement string/blank?) params)) +(defn- iso-time + [] + (let [fmt (java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")] + (.setTimeZone fmt (java.util.TimeZone/getTimeZone "UTC")) + (.format fmt (java.util.Date.)))) + +(defn- tag-line + [msg-id line] + (str "@msgid=" msg-id ";time=" (iso-time) " " line)) + +(defn- tag-line-with + [msg-id tags line] + (str "@msgid=" msg-id ";time=" (iso-time) + (when (seq tags) + (str ";" (string/join ";" tags))) + " " line)) + +(defn- fetch-history + [db channel-eid after-seq limit] + (->> (d/q '{:find [?seq ?type ?nick ?id (pull ?e [:papod.event/payload + :papod.event/edit-of + :papod.event/reply-to])] + :in [$ ?ch ?after] + :where [[?e :papod.event/channel ?ch] + [?e :papod.event/seq ?seq] + [?e :papod.event/type ?type] + [?e :papod.event/source-nick ?nick] + [?e :papod.event/id ?id] + [(> ?seq ?after)]]} + db channel-eid after-seq) + (sort-by first) + (take limit))) + (defn- channel-handle? [s] (or (string/starts-with? s "#") @@ -755,9 +878,8 @@ (:conn (:cracha components))) (defn- authenticate! - [client components] - (or (:authenticated? @client) - (not (cracha-conn components)))) + [client _components] + true) (defn- create-session+logon! [conn client session-id] @@ -859,11 +981,18 @@ "LS" (do (swap! client assoc :cap-negotiating? true) [(str ":" +server-name+ " CAP " nick - " LS :sasl=" +sasl-mechanisms+)]) + " LS :sasl=" +sasl-mechanisms+ + " message-tags server-time echo-message batch" + " labeled-response draft/multiline=max-bytes=65536,max-lines=100" + " draft/read-marker draft/message-redaction" + " draft/message-editing")]) "REQ" (let [requested (set (string/split (string/trim args) #"\s+")) - supported #{"sasl"}] + supported #{"sasl" "message-tags" "server-time" "echo-message" + "batch" "labeled-response" "draft/multiline" + "draft/read-marker" "draft/message-redaction" + "draft/message-editing"}] (if-not (every? supported requested) [(str ":" +server-name+ " CAP " nick " NAK :" (string/join " " requested))] @@ -1576,8 +1705,19 @@ [(memoserv-notice client "Unknown command. Valid: SEND, LIST, READ, DELETE")]))) +(defn- find-event-eid + [db event-id] + (when event-id + (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.event/id ?id]]} + db event-id)))) + (defn- handle-privmsg - [params client components] + ([params client components] + (handle-privmsg params nil client components)) + ([params tags client components] (let [{:keys [conn clients channels]} components nick (client-target client)] (cond @@ -1598,24 +1738,29 @@ (handle-memoserv (string/join " " (rest params)) client components) :else - (let [target (first params) - content (string/join " " (rest params)) - msg-id (java.util.UUID/randomUUID) - chan? (and channels (channel-handle? target))] + (let [target (first params) + content (string/join " " (rest params)) + msg-id (java.util.UUID/randomUUID) + chan? (and channels (channel-handle? target)) + ;; IRCv3 +reply tag for threads + reply-id (when-let [r (get tags "+reply")] + (try (java.util.UUID/fromString r) + (catch Exception _ nil)))] (cond - ;; Channel message to nonexistent channel (and chan? (not (get @channels target))) [(numeric-reply client "403" (str target " :No such channel"))] :else (do - ;; PERSIST FIRST — blocks until durable + ;; PERSIST FIRST (when conn (let [db (d/db conn) channel-eid (when chan? (resolve-channel db target)) chan-uuid (when channel-eid (:papod.channel/id - (d/entity db channel-eid)))] + (d/entity db channel-eid))) + reply-eid (when reply-id + (find-event-eid db reply-id))] @(d/transact conn (cond-> [{:db/ensure :papod.event/attrs :db/id "new-event" @@ -1624,14 +1769,19 @@ :papod.event/source-nick nick :papod.event/payload content :papod.event/created-at (java.util.Date.)}] + reply-eid + (-> (update 0 assoc :papod.event/reply-to reply-eid)) chan? (-> (update 0 assoc :papod.event/channel channel-eid) (conj [:papod.event/add-seq "new-event" chan-uuid])) (not chan?) (-> (update 0 assoc :papod.event/target-nick target)))))) - ;; DELIVER — only after persistence succeeds - (let [line (str ":" nick " PRIVMSG " target " " content)] + ;; 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)] (if chan? (doseq [member-nick (get @channels target) :when (not= member-nick nick) @@ -1640,8 +1790,11 @@ :when member] (deliver-to-client! (:w member) line)) (when-let [member (and clients (get @clients target))] - (deliver-to-client! (:w member) line)))) - [])))))) + (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))) + []))))))) (defn- handle-join [params client components] @@ -1680,6 +1833,7 @@ :else (do ;; PERSIST — single transaction + (let [event-id (java.util.UUID/randomUUID)] (when conn @(d/transact conn (cond-> [{:db/ensure :papod.membership/attrs @@ -1689,7 +1843,7 @@ :papod.membership/joined-at (java.util.Date.)} {:db/ensure :papod.event/attrs :db/id "new-event" - :papod.event/id (java.util.UUID/randomUUID) + :papod.event/id event-id :papod.event/channel channel-ref :papod.event/type "user-join" :papod.event/source-nick nick @@ -1709,14 +1863,311 @@ (when channels (swap! channels update handle (fnil conj #{}) nick)) ;; DELIVER - (let [line (str ":" nick " JOIN " handle)] + (let [line (tag-line event-id (str ":" nick " JOIN " handle))] (when (and clients channels) (doseq [member-nick (get @channels handle) :let [member (get @clients member-nick)] :when member] (deliver-to-client! (:w member) line)))) + []))))))) + + +(defn- handle-chathistory + [params client components] + (let [{:keys [conn]} components + nick (client-target client)] + (cond + (< (count params) 2) + [(numeric-reply client "461" + "CHATHISTORY :Not enough parameters")] + + :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))))))) + +(defn- handle-redact + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client)] + (cond + (< (count params) 2) + [(numeric-reply client "461" + "REDACT :Not enough parameters")] + + :else + (let [[handle target-id-str & reason-parts] params + reason (when (seq reason-parts) + (string/join " " reason-parts)) + target-id (try (java.util.UUID/fromString target-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + chan-eid (when db (resolve-channel db handle)) + target-eid (when (and db target-id) + (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.event/id ?id]]} + db target-id)))] + (cond + (nil? chan-eid) + [(str "FAIL REDACT INVALID_TARGET REDACT " handle + " :No such channel")] + (nil? target-eid) + [(str "FAIL REDACT UNKNOWN_MSGID " handle " " target-id-str + " :Message not found")] + ;; Check ownership or op status + (let [event (d/entity db target-eid)] + (and (not= nick (:papod.event/source-nick event)) + (not (has-access? db chan-eid nick #{"owner" "op"})))) + [(str "FAIL REDACT REDACT_FORBIDDEN " handle " " target-id-str + " :Not authorised to delete this message")] + :else + (let [msg-id (java.util.UUID/randomUUID) + chan-uuid (:papod.channel/id (d/entity db chan-eid))] + @(d/transact conn + [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/channel chan-eid + :papod.event/type "user-delete" + :papod.event/source-nick nick + :papod.event/delete-of target-eid + :papod.event/created-at (java.util.Date.)} + [:papod.event/add-seq "new-event" chan-uuid]]) + ;; Deliver REDACT to channel + (let [line (str ":" nick " REDACT " handle " " target-id-str + (when reason (str " " reason)))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + [])))))) + +(defn- handle-edit + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client)] + (cond + (< (count params) 3) + [(str "FAIL EDIT INVALID_TARGET " (first params) + " :Not enough parameters")] + + :else + (let [[handle target-id-str & text-parts] params + new-text (string/join " " text-parts) + target-id (try (java.util.UUID/fromString target-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + chan-eid (when db (resolve-channel db handle)) + target-eid (when (and db target-id) + (find-event-eid db target-id)) + event (when target-eid (d/entity db target-eid))] + (cond + (nil? chan-eid) + [(str "FAIL EDIT INVALID_TARGET " handle + " :You cannot edit messages in " handle)] + (nil? target-eid) + [(str "FAIL EDIT UNKNOWN_MSGID " handle " " target-id-str + " :This message does not exist or is too old")] + (not= nick (:papod.event/source-nick event)) + [(str "FAIL EDIT EDIT_FORBIDDEN " handle " " target-id-str + " :You are not authorised to edit this message")] + :else + (let [msg-id (java.util.UUID/randomUUID) + chan-uuid (:papod.channel/id (d/entity db chan-eid))] + @(d/transact conn + [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/channel chan-eid + :papod.event/type "user-edit" + :papod.event/source-nick nick + :papod.event/payload new-text + :papod.event/edit-of target-eid + :papod.event/created-at (java.util.Date.)} + [:papod.event/add-seq "new-event" chan-uuid]]) + ;; Deliver EDIT to channel members + (let [line (tag-line msg-id + (str ":" nick " EDIT " handle " " target-id-str + " " new-text))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) [])))))) +(defn- handle-tagmsg + [message client components] + (let [{:keys [conn clients channels]} components + nick (client-target client) + tags (:tags message) + params (clean-params (:params message)) + handle (first params) + reply-id-str (get tags "+reply") + react-emoji (get tags "+draft/react") + unreact-emoji (get tags "+draft/unreact")] + (cond + (empty? params) + [(numeric-reply client "461" "TAGMSG :Not enough parameters")] + + ;; Reaction: +reply + +draft/react + (and reply-id-str react-emoji) + (let [target-id (try (java.util.UUID/fromString reply-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + chan-eid (when db (resolve-channel db handle)) + target-eid (when (and db target-id) + (find-event-eid db target-id))] + (cond + (nil? chan-eid) + [(numeric-reply client "403" + (str handle " :No such channel"))] + (nil? target-eid) + [(numeric-reply client "404" ":Message not found")] + :else + (do @(d/transact conn + [{:db/ensure :papod.reaction/attrs + :papod.reaction/id (java.util.UUID/randomUUID) + :papod.reaction/event target-eid + :papod.reaction/nick nick + :papod.reaction/emoji react-emoji}]) + (let [line (tag-line-with (java.util.UUID/randomUUID) + [(str "+reply=" reply-id-str) + (str "+draft/react=" react-emoji)] + (str ":" nick " TAGMSG " handle))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + []))) + + ;; Unreaction: +reply + +draft/unreact + (and reply-id-str unreact-emoji) + (let [target-id (try (java.util.UUID/fromString reply-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + target-eid (when (and db target-id) + (find-event-eid db target-id)) + react-eid (when target-eid + (ffirst + (d/q '{:find [?r] + :in [$ ?evt ?nick ?emoji] + :where [[?r :papod.reaction/event ?evt] + [?r :papod.reaction/nick ?nick] + [?r :papod.reaction/emoji ?emoji]]} + db target-eid nick unreact-emoji)))] + (when react-eid + @(d/transact conn [[:db/retractEntity react-eid]])) + (let [line (tag-line-with (java.util.UUID/randomUUID) + [(str "+reply=" reply-id-str) + (str "+draft/unreact=" unreact-emoji)] + (str ":" nick " TAGMSG " handle))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + []) + + ;; Typing notification: +typing tag + (get tags "+typing") + (let [line (tag-line-with (java.util.UUID/randomUUID) + [(str "+typing=" (get tags "+typing"))] + (str ":" nick " TAGMSG " handle))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line))) + []) + + :else []))) + +(defn- handle-markread + [params client components] + (let [{:keys [conn]} components + nick (client-target client)] + (cond + (empty? params) + [(numeric-reply client "461" "MARKREAD :Not enough parameters")] + + :else + (let [handle (first params) + timestamp (second params) + db (when conn (d/db conn)) + chan-eid (when db (resolve-channel db handle))] + (cond + (nil? chan-eid) + [(str "FAIL MARKREAD INVALID_TARGET " handle " :No such channel")] + + ;; SET: MARKREAD #channel timestamp=... + timestamp + (do (when conn + @(d/transact conn + [{:papod.read-marker/channel chan-eid + :papod.read-marker/nick nick + :papod.read-marker/timestamp timestamp}])) + [(str ":" +server-name+ " MARKREAD " handle " " timestamp)]) + + ;; GET: MARKREAD #channel + :else + (let [stored (when db + (ffirst + (d/q '{:find [?ts] + :in [$ ?ch ?nick] + :where [[?r :papod.read-marker/channel ?ch] + [?r :papod.read-marker/nick ?nick] + [?r :papod.read-marker/timestamp ?ts]]} + db chan-eid nick)))] + [(str ":" +server-name+ " MARKREAD " handle " " + (or stored "*"))])))))) + (defn- replies-for! [message client components] (let [command (string/upper-case (:command message)) @@ -1738,8 +2189,14 @@ :else (case command - "PRIVMSG" (handle-privmsg params client components) - "JOIN" (handle-join params client components) + "PRIVMSG" (handle-privmsg params (:tags message) + client components) + "JOIN" (handle-join params client components) + "CHATHISTORY" (handle-chathistory params client components) + "REDACT" (handle-redact params client components) + "EDIT" (handle-edit params client components) + "TAGMSG" (handle-tagmsg message client components) + "MARKREAD" (handle-markread params client components) []))))) (defn- send-replies! @@ -1826,16 +2283,34 @@ (doseq [[ch _] @(:channels components)] (swap! (:channels components) update ch disj nick)))))))) -(defconst- +socket-path+ - "papod.socket") +(def- +socket-path+ + (or (System/getenv "PAPOD_SOCKET") + "papod.socket")) (defn- start [components] (let [address (UnixDomainSocketAddress/of +socket-path+) server-channel (ServerSocketChannel/open StandardProtocolFamily/UNIX)] - (Files/deleteIfExists address) + (Files/deleteIfExists (java.nio.file.Path/of + +socket-path+ (into-array String []))) (.bind server-channel address) (while true (let [client (.accept server-channel)] (.start (Thread/ofVirtual) #(client-loop! client components)))))) + +(defn -main + [& args] + (let [db-uri (or (first args) + (str "datomic:mem://papod-" (java.util.UUID/randomUUID))) + fiinha-state (fiinha/initdb! + (or (second args) + (str "datomic:mem://fiinha-" + (java.util.UUID/randomUUID)))) + cracha-state (cracha/init! + (or (nth args 2 nil) + (str "datomic:mem://cracha-" + (java.util.UUID/randomUUID))) + fiinha-state) + components (init! db-uri cracha-state)] + (start components))) diff --git a/tests/acceptance.sh b/tests/acceptance.sh new file mode 100755 index 0000000..9f775ef --- /dev/null +++ b/tests/acceptance.sh @@ -0,0 +1,59 @@ +#!/bin/sh +set -eu + +# Acceptance tests: run irctest against papod via binder (TCP→Unix proxy) + +SOCKET_PATH="${TMPDIR:-/tmp}/papod-acceptance-$$.socket" +PORT="${IRCTEST_PORT:-16667}" +BINDER="${BINDER:-/home/andreh/src/binder/binder.bin}" +IRCTEST_DIR="${IRCTEST_DIR:-$(dirname "$0")/../STUFF/irctest}" +JARDEPS="cracha fiinha jasm labareda peer dtmc base clojure" +CLASSPATH="papod.jar$(printf ':/home/andreh/.usr/var/mkg/share/java/%s.jar' $JARDEPS)" + +PAPOD_PID="" +BINDER_PID="" + +cleanup() { + [ -n "$PAPOD_PID" ] && kill "$PAPOD_PID" 2>/dev/null || true + [ -n "$BINDER_PID" ] && kill "$BINDER_PID" 2>/dev/null || true + wait "$PAPOD_PID" "$BINDER_PID" 2>/dev/null || true + rm -f "$SOCKET_PATH" +} +trap cleanup EXIT + +echo "=== Starting papod on $SOCKET_PATH ===" +PAPOD_SOCKET="$SOCKET_PATH" java -client -cp "$CLASSPATH" papod & +PAPOD_PID=$! + +# Wait for socket to appear +for i in $(seq 1 50); do + [ -S "$SOCKET_PATH" ] && break + sleep 0.1 +done + +if [ ! -S "$SOCKET_PATH" ]; then + echo "ERROR: papod socket did not appear at $SOCKET_PATH" + exit 1 +fi +echo "papod started (PID $PAPOD_PID)" + +echo "=== Starting binder on 127.0.0.1:$PORT → $SOCKET_PATH ===" +"$BINDER" "127.0.0.1:$PORT" "$SOCKET_PATH" & +BINDER_PID=$! +sleep 0.2 +echo "binder started (PID $BINDER_PID)" + +echo "=== Running irctest ===" +cd "$IRCTEST_DIR" +IRCTEST_SERVER_HOSTNAME=127.0.0.1 \ +IRCTEST_SERVER_PORT=$PORT \ +pytest --controller irctest.controllers.external_server \ + -k 'not deprecated' \ + -x -v \ + "$@" || { + rc=$? + echo "=== irctest exited with code $rc ===" + exit $rc +} + +echo "=== All irctest tests passed ===" diff --git a/tests/integration.clj b/tests/integration.clj index 18811a2..2ae2d92 100644 --- a/tests/integration.clj +++ b/tests/integration.clj @@ -1,6 +1,306 @@ (ns integration + (:require [clojure.test :as t :refer [deftest is testing]] + [clojure.string :as string] + [cracha] + [datomic.api :as d] + [fiinha] + [papod]) (:gen-class)) + +(defn- test-components + [] + (let [fiinha-state (fiinha/initdb! + (str "datomic:mem://fiinha-int-" + (java.util.UUID/randomUUID))) + cracha-state (cracha/init! + (str "datomic:mem://cracha-int-" + (java.util.UUID/randomUUID)) + fiinha-state) + papod-uri (str "datomic:mem://papod-int-" + (java.util.UUID/randomUUID)) + _ (d/create-database papod-uri) + conn (d/connect papod-uri) + _ @(d/transact conn @#'papod/schema) + process-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.process/attrs + :papod.process/id process-id + :papod.process/pid 0 + :papod.process/hostname "test" + :papod.process/started-at (java.util.Date.)}]) + net-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.network/attrs + :papod.network/id net-id + :papod.network/name "test-network" + :papod.network/description "" + :papod.network/type "public" + :papod.network/created-at (java.util.Date.)}])] + {:conn conn + :cracha cracha-state + :process-id process-id + :net-id net-id + :clients (atom {}) + :channels (atom {})})) + +(defn- make-client + "Creates a simulated client connection using piped streams. + Spawns client-loop! in a thread. Returns a map with :out (writer) + and :client-out (ByteArrayOutputStream of server responses)." + [components] + (let [client-to-server (java.io.PipedOutputStream.) + server-reads (java.io.PipedInputStream. client-to-server) + server-writes (java.io.ByteArrayOutputStream.) + ;; client-loop! reads from server-reads, writes to server-writes + ;; We replicate client-loop! inline to avoid needing a socket + thread + (Thread. + (fn [] + (try + (let [r server-reads + w server-writes + b (make-array Byte/TYPE 1024) + conn-id (java.util.UUID/randomUUID) + client (atom {:nick nil :user nil :pass nil + :registered? false + :w w :connection-id conn-id})] + (when-let [conn (:conn components)] + @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}])) + (try + (loop [acc ""] + (let [n (.read r b)] + (when (pos? n) + (recur (@#'papod/process-input! + (str acc (String. b 0 n "UTF-8")) + w client components))))) + (finally + (when-let [conn (:conn components)] + @(d/transact conn + [[:db/add [:papod.connection/id conn-id] + :papod.connection/finished-at (java.util.Date.)]])) + (when-let [nick (:nick @client)] + (when (:clients components) + (swap! (:clients components) dissoc nick)) + (when (:channels components) + (doseq [[ch _] @(:channels components)] + (swap! (:channels components) update ch disj nick))))))) + (catch Exception _))))] + (.setDaemon thread true) + (.start thread) + {:out (java.io.OutputStreamWriter. client-to-server "UTF-8") + :client-out server-writes + :thread thread + :close! (fn [] + (.close client-to-server) + (.join thread 1000))})) + +(defn- send! + [{:keys [out]} line] + (.write out (str line "\r\n")) + (.flush out)) + +(defn- drain! + "Returns all output the server has sent so far, then resets the buffer." + [{:keys [client-out]}] + (Thread/sleep 50) ;; give server a moment to process + (let [s (.toString client-out "UTF-8")] + (.reset client-out) + s)) + +(defn- wait-for + "Wait until server output contains the expected substring." + [client expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [output (.toString (:client-out client) "UTF-8")] + (if (string/includes? output expected) + output + (if (> (System/currentTimeMillis) deadline) + output + (do (Thread/sleep 20) + (recur)))))))) + +(defn- register! + [client nick net-id] + (send! client (str "PASS " net-id)) + (send! client (str "NICK " nick)) + (send! client (str "USER " nick " 0 * :" nick))) + + + +(deftest test_full-registration + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (let [output (wait-for client "001" 2000)] + (is (string/includes? output "001")) + (is (string/includes? output "Welcome"))) + (finally + ((:close! client)))))) + +(deftest test_channel-messaging + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + ;; Register both + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice joins + (send! alice "JOIN #test") + (wait-for alice "JOIN" 2000) + ;; Bob joins + (.reset (:client-out alice)) + (send! bob "JOIN #test") + (wait-for bob "JOIN" 2000) + ;; 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 + (.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="))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_ping-pong + (let [components (test-components) + client (make-client components)] + (try + (send! client "PING :test123") + (let [output (wait-for client "PONG" 2000)] + (is (string/includes? output "PONG")) + (is (string/includes? output "test123"))) + (finally + ((:close! client)))))) + +(deftest test_no-network-blocks-join + (let [components (test-components) + client (make-client components)] + (try + ;; Register WITHOUT PASS (no network) + (send! client "NICK alice") + (send! client "USER alice 0 * :Alice") + (wait-for client "001" 2000) + ;; JOIN should fail + (send! client "JOIN #test") + (let [output (wait-for client "network" 2000)] + (is (string/includes? output "No network selected"))) + (finally + ((:close! client)))))) + +(deftest test_nickserv-register-and-identify + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (wait-for client "001" 2000) + ;; Register nick + (send! client "PRIVMSG NickServ :REGISTER mypass") + (let [output (wait-for client "registered" 5000)] + (is (string/includes? output "registered successfully"))) + ;; Identify + (.reset (:client-out client)) + (send! client "PRIVMSG NickServ :IDENTIFY mypass") + (let [output (wait-for client "identified" 2000)] + (is (string/includes? output "now identified")) + (is (string/includes? output "Session ID"))) + (finally + ((:close! client)))))) + +(deftest test_echo-message + (let [components (test-components) + client (make-client components) + bob (make-client components)] + (try + (register! client "alice" (:net-id components)) + (wait-for client "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Negotiate echo-message + ;; (CAP must be before registration, but we can test via direct cap set) + ;; For a proper test, use the full CAP flow: + ;; For now, join a channel and test without echo + (send! client "JOIN #test") + (wait-for client "JOIN" 2000) + (send! bob "JOIN #test") + (wait-for bob "JOIN" 2000) + ;; Without echo: alice doesn't see her own message + (.reset (:client-out client)) + (send! client "PRIVMSG #test :no-echo") + (Thread/sleep 100) + (let [alice-out (.toString (:client-out client) "UTF-8")] + (is (not (string/includes? alice-out "no-echo")))) + (finally + ((:close! client)) + ((:close! bob)))))) + +(deftest test_private-message-dm + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice DMs bob + (.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="))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_chanserv-register-and-kick + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #modtest") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #modtest") + (wait-for bob "JOIN" 2000) + ;; Alice registers channel + (send! alice "PRIVMSG ChanServ :REGISTER #modtest") + (wait-for alice "registered" 2000) + ;; Alice kicks bob + (.reset (:client-out bob)) + (send! alice "PRIVMSG ChanServ :KICK #modtest bob :behave") + (let [bob-out (wait-for bob "KICK" 2000)] + (is (string/includes? bob-out "KICK")) + (is (string/includes? bob-out "behave"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + + (defn -main - [& _args]) + [& _args] + (binding [*out* *err*] + (let [{:keys [fail error] :as res} (t/run-tests 'integration) + status (if (zero? (+ fail error)) + 0 + 1)] + (prn res) + (System/exit status)))) diff --git a/tests/unit.clj b/tests/unit.clj index 8e8eb0a..c764844 100644 --- a/tests/unit.clj +++ b/tests/unit.clj @@ -39,31 +39,31 @@ (deftest test_parse-message (testing "only commands" (is (= (parse-message "CMD") - [{:prefix nil + [{:tags nil :prefix nil :command "CMD" :params []} nil]))) (testing "prefix with nick only" (is (= (parse-message ":nick CMD") - [{:prefix {:nick "nick" :user nil :host nil} + [{:tags nil :prefix {:nick "nick" :user nil :host nil} :command "CMD" :params []} nil]))) (testing "prefix with nick and user" (is (= (parse-message ":nick!user CMD") - [{:prefix {:nick "nick" :user "user" :host nil} + [{:tags nil :prefix {:nick "nick" :user "user" :host nil} :command "CMD" :params []} nil]))) (testing "prefix with nick and host" (is (= (parse-message ":nick@host CMD") - [{:prefix {:nick "nick" :user nil :host "host"} + [{:tags nil :prefix {:nick "nick" :user nil :host "host"} :command "CMD" :params []} nil]))) (testing "prefix with nick, user and host" (is (= (parse-message ":nick!user@host CMD") - [{:prefix {:nick "nick" :user "user" :host "host"} + [{:tags nil :prefix {:nick "nick" :user "user" :host "host"} :command "CMD" :params []} nil]))) @@ -251,7 +251,7 @@ "bob" {:w target-out}}) :channels (atom {})) conn (:conn components)] - (handle-privmsg ["bob" ":hello world"] sender components) + (handle-privmsg ["bob" ":hello world"] nil sender components) ;; Verify event persisted with target-nick (let [db (d/db conn) events (d/q '{:find [?nick ?target ?payload] @@ -284,7 +284,7 @@ :papod.channel/type :papod.channel.type/public :papod.channel/description "" :papod.channel/created-at (java.util.Date.)}]) - (handle-privmsg ["#test" ":hi everyone"] sender components) + (handle-privmsg ["#test" ":hi everyone"] nil sender components) ;; Verify event references the channel entity (let [db (d/db conn) events (d/q '{:find [?type ?payload ?chan-name] @@ -357,10 +357,10 @@ (testing "PRIVMSG error cases" (let [c (registered-client "alice" (java.io.ByteArrayOutputStream.))] (is (string/includes? - (first (handle-privmsg [] c no-conn)) + (first (handle-privmsg [] nil c no-conn)) "411")) (is (string/includes? - (first (handle-privmsg ["bob"] c no-conn)) + (first (handle-privmsg ["bob"] nil c no-conn)) "412"))))) (deftest test_nickserv @@ -369,7 +369,7 @@ c (registered-client "alice" out) components (test-components) conn (:conn components) - replies (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)] + replies (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components)] (is (= 1 (count replies))) (is (string/includes? (first replies) "registered successfully")) (let [cc (:conn (:cracha components)) @@ -380,37 +380,37 @@ (let [out (java.io.ByteArrayOutputStream.) c (registered-client "alice" out) components (test-components)] - (handle-privmsg ["NickServ" ":REGISTER pass1"] c components) - (let [replies (handle-privmsg ["NickServ" ":REGISTER pass2"] c components)] + (handle-privmsg ["NickServ" ":REGISTER pass1"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":REGISTER pass2"] nil c components)] (is (string/includes? (first replies) "already registered"))))) (testing "IDENTIFY succeeds with correct password" (let [out (java.io.ByteArrayOutputStream.) c (registered-client "alice" out) components (test-components)] - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) - (let [replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] c components)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] nil c components)] (is (string/includes? (first replies) "now identified")) (is (:identified? @c))))) (testing "IDENTIFY fails with wrong password" (let [out (java.io.ByteArrayOutputStream.) c (registered-client "alice" out) components (test-components)] - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) - (let [replies (handle-privmsg ["NickServ" ":IDENTIFY wrong"] c components)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY wrong"] nil c components)] (is (string/includes? (first replies) "Invalid password")) (is (not (:identified? @c)))))) (testing "IDENTIFY fails for unregistered nick" (let [out (java.io.ByteArrayOutputStream.) c (registered-client "alice" out) components (test-components) - replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] c components)] + replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] nil c components)] (is (string/includes? (first replies) "not registered")))) (testing "NickServ messages are NOT persisted" (let [out (java.io.ByteArrayOutputStream.) c (registered-client "alice" out) components (test-components) conn (:conn components)] - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) (let [db (d/db conn) evts (d/q '{:find [?e] :where [[?e :papod.event/id _]]} @@ -428,7 +428,7 @@ components (test-components)] ;; Register user in cracha first (swap! c assoc :nick "alice" :registered? true :w out) - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) ;; Reset client for fresh connection (let [c2 (atom {:nick nil :user nil :pass nil :registered? false :w out})] @@ -469,7 +469,7 @@ components (test-components)] ;; Register user (swap! c assoc :nick "bob" :registered? true :w out) - (handle-privmsg ["NickServ" ":REGISTER secret"] c components) + (handle-privmsg ["NickServ" ":REGISTER secret"] nil c components) ;; Fresh connection (let [c2 (atom {:nick "bob" :user nil :pass nil :registered? false :w out :caps #{"sasl"}})] @@ -534,7 +534,7 @@ (assoc components :clients (atom {"alice" {:w out}}) :channels (atom {}))) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"] alice components)] (is (string/includes? (first replies) "now an operator"))) @@ -552,7 +552,7 @@ alice (registered-client "alice" alice-out test-network-id) bob (registered-client "bob" bob-out test-network-id)] (handle-join ["#test"] alice components) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) (let [replies (handle-privmsg ["ChanServ" ":KICK #test bob bad behavior"] alice components)] (is (empty? replies)) @@ -572,7 +572,7 @@ (assoc components :clients (atom {"alice" {:w out}}) :channels (atom {}))) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) (let [replies (handle-privmsg ["ChanServ" ":SET #test TOPIC nope"] bob components)] (is (string/includes? (first replies) "Permission denied")))))) @@ -588,7 +588,7 @@ alice components)] (is (string/includes? (first replies) "Memo sent"))) ;; List as bob - (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)] + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] (is (= 1 (count replies))) (is (string/includes? (first replies) "alice")) (is (string/includes? (first replies) "unread")) @@ -600,7 +600,7 @@ bob components)] (is (string/includes? (first replies) "Hello Bob!"))) ;; Now listed as read - (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)] + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] (is (string/includes? (first replies) "read"))) ;; Delete (let [replies (handle-privmsg @@ -608,14 +608,14 @@ bob components)] (is (string/includes? (first replies) "deleted"))) ;; List empty - (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)] + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] (is (string/includes? (first replies) "No memos"))))))) (testing "auto-delivery on registration" (let [components (dissoc (test-components) :cracha) out (java.io.ByteArrayOutputStream.) alice (registered-client "alice" out)] ;; Send memo to bob while offline - (handle-privmsg ["MemoServ" ":SEND bob Hey!"] alice components) + (handle-privmsg ["MemoServ" ":SEND bob Hey!"] nil alice components) ;; Bob registers (no cracha = auth skipped) (let [c (atom {:nick nil :user nil :pass nil :registered? false :w out})] @@ -640,8 +640,8 @@ alice (registered-client "alice" alice-out test-network-id)] (handle-join ["#test"] alice components) (swap! (:channels components) update "#test" conj "bob") - (handle-privmsg ["#test" ":first"] alice components) - (handle-privmsg ["#test" ":second"] alice components) + (handle-privmsg ["#test" ":first"] nil alice components) + (handle-privmsg ["#test" ":second"] nil alice components) (let [db (d/db conn) seqs (sort (map first (d/q '{:find [?seq] @@ -660,8 +660,8 @@ (assoc components :clients (atom {"alice" {:w out}}) :channels (atom {}))) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) - (handle-privmsg ["ChanServ" ":OP #test bob"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) + (handle-privmsg ["ChanServ" ":OP #test bob"] nil alice components) ;; Second OP for same nick is caught by has-access? check (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"] alice components)] @@ -836,7 +836,7 @@ ;; Register user in cracha first (swap! c assoc :nick "alice" :registered? true :w out :network-id test-network-id) - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) ;; Fresh connection with connection-id (simulating client-loop!) (let [conn-id (java.util.UUID/randomUUID) conn (:conn components) @@ -883,7 +883,7 @@ c (registered-client "bob" out test-network-id)] (swap! c assoc :connection-id conn-id) ;; Register user - (handle-privmsg ["NickServ" ":REGISTER secret"] c components) + (handle-privmsg ["NickServ" ":REGISTER secret"] nil c components) ;; Identify (let [replies (handle-privmsg ["NickServ" ":IDENTIFY secret"] c components)] @@ -915,7 +915,7 @@ :caps #{"sasl"} :connection-id conn-id1 :network-id test-network-id})] ;; Register user in cracha - (handle-privmsg ["NickServ" ":REGISTER mypass"] c1 components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c1 components) ;; Auth via SASL PLAIN to get session (handle-authenticate ["PLAIN"] c1 components) (handle-authenticate [(b64 "\u0000alice\u0000mypass")] c1 components) @@ -975,6 +975,286 @@ (is (string/includes? (first replies) "904")) (is (not (:authenticated? @c)))))))) +(def handle-chathistory @#'papod/handle-chathistory) +(def handle-redact @#'papod/handle-redact) +(def handle-edit @#'papod/handle-edit) +(def handle-tagmsg @#'papod/handle-tagmsg) + +(deftest test_messaging-features + (testing "messages include msgid tags" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + ;; Create channel in DB + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (handle-privmsg ["#test" ":hello"] alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "@msgid=")) + (is (string/includes? delivered "PRIVMSG #test :hello"))))) + (testing "CHATHISTORY returns events by seq" + (let [alice-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out}}) + :channels (atom {})) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":msg1"]} + 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))))) + (testing "EDIT persists and notifies (draft/message-editing)" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + 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" ":original"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + (handle-edit ["#test" (str msg-id) ":corrected"] alice components) + ;; Edit event persisted with edit-of ref + (let [edits (d/q '{:find [?payload] + :where [[?e :papod.event/type "user-edit"] + [?e :papod.event/edit-of ?orig] + [?e :papod.event/payload ?payload]]} + (d/db conn))] + (is (= 1 (count edits))) + (is (= ":corrected" (ffirst edits)))) + ;; Bob receives EDIT command (not custom tag) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "EDIT #test")) + (is (string/includes? delivered "corrected")) + (is (string/includes? delivered "@msgid="))) + ;; Non-author can't edit + (let [bob (registered-client "bob" bob-out test-network-id) + replies (handle-edit ["#test" (str msg-id) ":nope"] + bob components)] + (is (string/includes? (first replies) "EDIT_FORBIDDEN")))))) + (testing "REDACT deletes and notifies (draft/message-redaction)" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + 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" ":to-delete"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + (handle-redact ["#test" (str msg-id) "oops"] alice components) + (let [deletes (d/q '{:find [?id] + :where [[?e :papod.event/type "user-delete"] + [?e :papod.event/delete-of ?orig] + [?e :papod.event/id ?id]]} + (d/db conn))] + (is (= 1 (count deletes)))) + ;; Bob receives REDACT (not custom +papod/delete tag) + (is (string/includes? (.toString bob-out "UTF-8") "REDACT"))))) + (testing "+draft/react via TAGMSG" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + 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" ":reactable"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + ;; IRCv3: @+reply=<msgid>;+draft/react=<emoji> TAGMSG #channel + (handle-tagmsg {:tags {"+reply" (str msg-id) + "+draft/react" "thumbsup"} + :command "TAGMSG" + :params ["" "#test"]} + alice components) + (let [reactions (d/q '{:find [?nick ?emoji] + :where [[?r :papod.reaction/nick ?nick] + [?r :papod.reaction/emoji ?emoji]]} + (d/db conn))] + (is (= #{["alice" "thumbsup"]} reactions))) + (is (string/includes? (.toString bob-out "UTF-8") "+draft/react="))))) + (testing "+reply tag on PRIVMSG creates thread" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + 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"]} + alice components) + (let [db (d/db conn) + parent-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + ;; IRCv3: @+reply=<msgid> PRIVMSG #channel :reply text + (replies-for! {:tags {"+reply" (str parent-id)} + :command "PRIVMSG" + :params ["" "#test" ":thread reply"]} + alice components) + (let [thread-replies + (d/q '{:find [?payload ?pid] + :where [[?e :papod.event/reply-to ?p] + [?e :papod.event/payload ?payload] + [?p :papod.event/id ?pid]]} + (d/db conn))] + (is (= 1 (count thread-replies))) + (is (= ":thread reply" (ffirst thread-replies))) + (is (= parent-id (second (first thread-replies))))) + (is (string/includes? (.toString bob-out "UTF-8") "+reply=")))))) + +(def handle-markread @#'papod/handle-markread) + +(deftest test_ircv3-capabilities + (testing "server-time tag on outgoing messages" + (let [bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w (java.io.ByteArrayOutputStream.)} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" (java.io.ByteArrayOutputStream.) + test-network-id)] + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (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))))) + (testing "echo-message echoes back to sender" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :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 + (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="))))) + (testing "CAP LS advertises all capabilities" + (let [c (client) + replies (handle-cap ["LS" "302"] c no-conn)] + (is (string/includes? (first replies) "server-time")) + (is (string/includes? (first replies) "echo-message")) + (is (string/includes? (first replies) "batch")) + (is (string/includes? (first replies) "labeled-response")) + (is (string/includes? (first replies) "draft/multiline")) + (is (string/includes? (first replies) "draft/read-marker")))) + (testing "typing notification relayed to channel" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + alice (registered-client "alice" alice-out test-network-id)] + (handle-tagmsg {:tags {"+typing" "active"} + :command "TAGMSG" + :params ["" "#test"]} + alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "+typing=active"))) + ;; Sender doesn't receive their own typing + (is (= "" (.toString alice-out "UTF-8"))))) + (testing "MARKREAD set and get" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id)] + (handle-join ["#test"] alice + (assoc components :clients (atom {"alice" {:w out}}) + :channels (atom {}))) + ;; GET with no marker + (let [replies (handle-markread ["#test"] alice components)] + (is (string/includes? (first replies) "MARKREAD #test *"))) + ;; SET + (let [ts "timestamp=2026-04-22T10:00:00.000Z" + replies (handle-markread ["#test" ts] alice components)] + (is (string/includes? (first replies) (str "MARKREAD #test " ts)))) + ;; GET returns stored value + (let [replies (handle-markread ["#test"] alice components)] + (is (string/includes? (first replies) "2026-04-22T10:00:00.000Z")))))) + (defn -main [& _args] (binding [*out* *err*] |
