#+TITLE: SKILL: Gateway Messaging (org-skill-gateway-messaging.org) #+AUTHOR: Agent #+FILETAGS: :skill:gateway:messaging: #+PROPERTY: header-args:lisp :tangle ../lisp/gateway-messaging.lisp * Architectural Intent ~gateway-messaging~ bridges Passepartout to external messaging platforms — Telegram, Signal, and any future service that speaks HTTP or has a CLI. Each gateway follows the same pattern: 1. **Registration** — a poll function and a send function are registered in ~*gateway-registry*~ by name ("telegram", "signal") 2. **Linking** — the user provides a token (Telegram bot token) or account name (Signal CLI); it's stored in the vault and a polling thread starts 3. **Polling** — the background thread calls the poll function every N seconds; inbound messages are injected into the daemon as ~:EVENT~ signals via ~stimulus-inject~ 4. **Sending** — when ~telegram-send~ or ~signal-send~ is invoked as an actuator (registered via ~register-actuator~), it formats the message and pushes it through the platform's API The gateway management functions (~messaging-link~, ~messaging-unlink~, ~messaging-list~, ~messaging-list-print~) are what the CLI's =passepartout gateway= subcommand calls. The old ~gateway-manager~ skill had ~gateway-link~/~gateway-unlink~/~gateway-list~ printed with the same signatures; the rename to ~messaging-*~ aligns the public API with the skill name while keeping the internal engine functions (~gateway-start~, ~gateway-stop~) as-is since they're implementation details. This replaces the old ~gateway-manager~ skill. The Telegram/Signal platform code is unchanged; only the management entry points and the defskill name changed. ** Contract 1. (gateway-registry-initialize): populates ~*gateway-registry*~ with ~:configured~ key per platform (boolean, set when linked). 2. (messaging-link platform &key token): stores the token in the vault and starts the gateway's polling thread. 3. (messaging-unlink platform): removes the token and stops the thread. 4. (gateway-configured-p platform): returns T if platform is configured. 5. (gateway-start platform): starts the background poll thread for a named gateway platform. * Implementation ** Data #+begin_src 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)") #+end_src ** Registry initialization #+begin_src lisp (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))))) #+end_src ** Gateway management (link/unlink) #+begin_src lisp (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)) #+end_src ** Polling thread management #+begin_src lisp (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)))) #+end_src ** Listing #+begin_src lisp (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 "~%")) #+end_src ** Boot #+begin_src lisp (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))))) #+end_src ** Registration and boot #+begin_src lisp (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) #+end_src #+end_src * Test Suite #+begin_src lisp (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)))))) #+end_src