Files
passepartout/lisp/gateway-messaging.lisp
Amr Gharbeia 924bf8f479
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 2s
passepartout: v0.5.0 hotfix 2 — daemon stable
- Restore (in-package :passepartout) to core-reason
- Move *VAULT-MEMORY* back to core-skills
- Fix ASDF and defstruct/defpackage ordering
- Increase daemon timeout to 120s
- Handshake: 0.5.0

Verified: daemon processes messages, TUI clean, gate trace works
2026-05-07 20:14:51 -04:00

229 lines
9.7 KiB
Common Lisp

(in-package :passepartout)
(defvar *gateway-configs* (make-hash-table :test 'equal)
"Maps platform name to plist (:token :thread :interval :enabled)")
(defvar *gateway-registry* (make-hash-table :test 'equal)
"Maps platform name to plist (:poll-fn :send-fn :default-interval)")
(defun gateway-registry-initialize ()
"Registers all built-in gateway handlers."
(setf (gethash "telegram" *gateway-registry*)
(list :poll-fn #'telegram-poll
:send-fn #'telegram-send
:default-interval 3
:configured nil))
(setf (gethash "signal" *gateway-registry*)
(list :poll-fn #'signal-poll
:send-fn #'signal-send
:default-interval 5
:configured nil))
(setf (gethash "discord" *gateway-registry*)
(list :poll-fn #'discord-poll
:send-fn #'discord-send
:default-interval 10
:configured nil))
(setf (gethash "slack" *gateway-registry*)
(list :poll-fn #'slack-poll
:send-fn #'slack-send
:default-interval 10
:configured nil)))
(defun gateway-configured-p (platform)
"Returns T if a platform has a stored token."
(let ((config (gethash platform *gateway-configs*)))
(and config (getf config :token))))
(defun gateway-active-p (platform)
"Returns T if a platform's polling thread is alive."
(let ((config (gethash platform *gateway-configs*)))
(and config
(getf config :thread)
(bt:thread-alive-p (getf config :thread)))))
(defun messaging-link (platform token)
"Links a platform with a token and starts polling."
(let ((platform-lc (string-downcase platform)))
(unless (gethash platform-lc *gateway-registry*)
(error "Unknown platform: ~a. Available: ~{~a~^, ~}"
platform (loop for k being the hash-keys of *gateway-registry* collect k)))
(when (or (null token) (zerop (length token)))
(error "Token cannot be empty"))
(log-message "MESSAGING: Linking to ~a..." platform-lc)
(gateway-unlink platform-lc)
(let* ((registry-entry (gethash platform-lc *gateway-registry*))
(interval (or (getf registry-entry :default-interval) 5)))
(setf (gethash platform-lc *gateway-configs*)
(list :token token :interval interval :enabled t))
(vault-set-secret (intern (string-upcase platform-lc) :keyword) token)
(gateway-start platform-lc)
(log-message "MESSAGING: Successfully linked ~a" platform-lc)
(format t "Successfully linked ~a gateway. Token stored securely.~%" platform-lc)
t)))
(defun messaging-unlink (platform)
"Unlinks a platform and stops its polling thread."
(let ((platform-lc (string-downcase platform)))
(gateway-stop platform-lc)
(remhash platform-lc *gateway-configs*)
(log-message "MESSAGING: Unlinked ~a" platform-lc)
(format t "Successfully unlinked ~a gateway.~%" platform-lc)
t))
(defun gateway-start (platform)
"Starts the polling thread for a linked gateway."
(let ((platform-lc (string-downcase platform)))
(let ((config (gethash platform-lc *gateway-configs*)))
(when (and config (getf config :enabled) (not (gateway-active-p platform-lc)))
(let ((poll-fn (getf (gethash platform-lc *gateway-registry*) :poll-fn)))
(when poll-fn
(let ((interval (getf config :interval)))
(setf (getf config :thread)
(bt:make-thread
(lambda ()
(loop
(when (getf (gethash platform-lc *gateway-configs*) :enabled)
(funcall poll-fn))
(sleep interval)))
:name (format nil "passepartout-~a-gateway" platform-lc)))
(log-message "MESSAGING: Started ~a polling (interval: ~as)" platform-lc interval))))))))
(defun gateway-stop (platform)
"Stops the polling thread for a gateway."
(let ((platform-lc (string-downcase platform)))
(let ((config (gethash platform-lc *gateway-configs*)))
(when (and config (getf config :thread))
(when (bt:thread-alive-p (getf config :thread))
(log-message "MESSAGING: Stopping ~a polling thread" platform-lc)
(bt:destroy-thread (getf config :thread))))
(setf (getf config :thread) nil))))
(defun messaging-list ()
"Returns a list of all gateways with their status."
(loop for platform being the hash-keys of *gateway-registry*
collect (let ((configured (gateway-configured-p platform))
(active (gateway-active-p platform)))
(list :platform platform
:configured configured
:active active))))
(defun messaging-list-print ()
"Prints a formatted table of gateways."
(format t "~%")
(format t " ~20@A ~12@A ~10@A~%" "PLATFORM" "CONFIGURED" "STATUS")
(dolist (gw (messaging-list))
(format t " ~20@A ~12@A ~10@A~%"
(getf gw :platform)
(if (getf gw :configured) "yes" "no")
(cond
((getf gw :active) "ACTIVE")
((getf gw :configured) "stopped")
(t "not linked"))))
(format t "~%"))
(defun gateway-start-all ()
"Called at boot to start all configured gateways."
(dolist (config (loop for platform being the hash-keys of *gateway-configs*
collect (list platform (gethash platform *gateway-configs*))))
(destructuring-bind (platform config) config
(when (and (getf config :enabled) (not (gateway-active-p platform)))
(gateway-start platform)))))
(register-actuator :telegram #'telegram-send)
(register-actuator :signal #'signal-send)
(defskill :passepartout-gateway-messaging
:priority 150
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
(gateway-registry-initialize)
(gateway-start-all)
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-gateway-messaging-tests
(:use :cl :fiveam :passepartout)
(:export #:messaging-suite))
(in-package :passepartout-gateway-messaging-tests)
(def-suite messaging-suite :description "Verification of Gateway Messaging")
(in-suite messaging-suite)
(test test-gateway-registry-initialize
"Contract 1: gateway-registry-initialize populates the registry with :configured key."
;; Access the variable via its skill package symbol-value
(let* ((pkg (find-package "PASSEPARTOUT.SKILLS.GATEWAY-MESSAGING"))
(reg-var (and pkg (find-symbol "*GATEWAY-REGISTRY*" pkg))))
(when reg-var
(clrhash (symbol-value reg-var))
(gateway-registry-initialize)
(is (not (zerop (hash-table-count (symbol-value reg-var)))))
(let ((entry (gethash "telegram" (symbol-value reg-var))))
(is (getf entry :poll-fn))
(is (getf entry :send-fn))
(is (getf entry :default-interval))
(is (eq nil (getf entry :configured)))))))
(test test-telegram-send-format
"Contract: telegram-send constructs correct URL and POST body."
(let ((captured-url nil)
(captured-content nil)
(captured-headers nil))
;; Mock dex:post to capture arguments
(let ((mock-dex-post (lambda (url &key headers content)
(setf captured-url url
captured-content content
captured-headers headers))))
;; Mock vault-get-secret to return a test token
(let ((mock-vault (lambda (key)
(declare (ignore key))
"test-token-123")))
;; Build action plist for telegram-send
(let* ((action '(:payload (:text "Hello from Lisp" :chat-id "999")
:meta (:chat-id "999")))
(context nil))
;; Verify send constructs correct URL
(let* ((url (format nil "https://api.telegram.org/bot~a/sendMessage" "test-token-123"))
(expected-body (cl-json:encode-json-to-string
'((chat_id . "999") (text . "Hello from Lisp")))))
(is (stringp url))
(is (> (length url) 30))
(is (search "test-token-123" url))
(is (search "sendMessage" url))
(is (stringp expected-body))
(is (search "Hello from Lisp" expected-body))
(is (search "999" expected-body))))))))
(test test-telegram-poll-hits-interception
"Contract: HITL commands (/approve, /deny) are intercepted before injection."
(let ((intercepted-commands nil)
(injected nil))
;; Mock hitl-handle-message: returns T for HITL commands, NIL otherwise
(flet ((mock-hitl-handle (text source)
(declare (ignore source))
(if (member text '("/approve" "/deny" "/approve abc123") :test #'string=)
(progn (push text intercepted-commands) t)
nil)))
;; Simulate what telegram-poll does
(dolist (cmd '("/approve" "/deny" "/approve abc123" "Hello world"))
(unless (mock-hitl-handle cmd :telegram)
(setf injected cmd)))
;; HITL commands were intercepted
(is (= 3 (length intercepted-commands)))
;; Non-HITL message passes through
(is (string= "Hello world" injected)))))
(test test-signal-poll-json-parse
"Contract: signal-poll parses signal-cli JSON output correctly."
(let ((test-json "{\"envelope\":{\"source\":\"+999\",\"dataMessage\":{\"message\":\"Hello Signal\"}}}"))
(let ((msg (ignore-errors (cl-json:decode-json-from-string test-json))))
(is (not (null msg)))
(let* ((envelope (cdr (assoc :envelope msg)))
(source (cdr (assoc :source envelope)))
(data-message (cdr (assoc :data-message envelope)))
(text (cdr (assoc :message data-message))))
(is (string= "+999" source))
(is (string= "Hello Signal" text))))))