Files
passepartout/org/gateway-messaging.org
Amr Gharbeia da160b71e3
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 2s
passepartout: v0.5.0 File Reorganization
Extract non-core fragments using self-repair criterion:
- core-context -> symbolic-awareness (224 lines, fboundp guards in think())
- heartbeat generation -> symbolic-events (renamed events-start-heartbeat)

Rename 23 files for clarity and new naming scheme:
- 6 core: core-package, core-transport, core-pipeline,
          core-perceive, core-reason, core-act
- 13 system: symbolic-*, neuro-*, embedding-*, channel-shell
- 4 gateway: channel-cli, channel-tui-*, channel-tui-state

Utility relocations:
- markdown-strip -> programming-markdown
- plist-keywords-normalize -> programming-lisp
- cognitive-tool-prompt -> programming-tools
- VAULT-MEMORY -> security-vault
- Merge *backend-registry* into *probabilistic-backends*

Split gateway-messaging into channel-telegram/channel-signal/
channel-discord/channel-slack (4 independent skills)

Delete dead system-model.lisp (16-line wrapper)

Document self-repair criterion in DESIGN_DECISIONS

Version bump: 0.4.3 -> 0.5.0
2026-05-07 18:20:48 -04:00

12 KiB

SKILL: Gateway Messaging (org-skill-gateway-messaging.org)

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

(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)")

Registry initialization

(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)))))

Gateway management (link/unlink)

(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))

Polling thread management

(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))))

Listing

(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 "~%"))

Boot

(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)))))

Registration and boot

(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

Test Suite

(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))))))