summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-23 10:03:07 -0300
committerEuAndreh <eu@euandre.org>2026-04-23 10:03:07 -0300
commitd879cbae489b4695c581c16d5659cadd100bd3c1 (patch)
tree1aeb7ea85206d8346ccd366eb0bf22ac6a1d92e4
parentm (diff)
downloadpapod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.gz
papod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.xz
m
-rw-r--r--src/papod.clj539
-rwxr-xr-xtests/acceptance.sh59
-rw-r--r--tests/integration.clj302
-rw-r--r--tests/unit.clj348
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*]