diff --git a/docs/rca/rca-gateway-telegram.org b/docs/rca/rca-gateway-telegram.org new file mode 100644 index 0000000..7d3f34f --- /dev/null +++ b/docs/rca/rca-gateway-telegram.org @@ -0,0 +1,43 @@ +#+TITLE: Root Cause Analysis: Telegram Gateway & Channel-Aware Chat +#+DATE: 2026-04-11 +#+FILETAGS: :rca:gateway:telegram:chat:psf: + +* Executive Summary +Successfully implemented the first external communication channel (Telegram) and decoupled the Chat Agent from its Emacs-centric roots. Resolved significant load-order and dependency issues identified during integration. + +* 1. Issue: Undefined Foundational Functions +** Symptoms +During compilation, `gateway-telegram.lisp` failed with `UNDEFINED-FUNCTION` for `register-actuator` and `kernel-log`. +** Root Cause +Poorly scoped foundational functions. These were defined in `core.lisp` (the loop orchestrator), which was loaded *after* the gateways in `org-agent.asd`. This created a "Circular Intention" where the gateways needed the kernel to exist before the kernel could load the gateways. +** Resolution +1. **Relocation:** Moved `*actuator-registry*` and `register-actuator` to `protocol.lisp` (the foundation). +2. **Reordering:** Adjusted `org-agent.asd` to load `core.lisp` (containing the stimulus loop) immediately after the symbolic gates but before the physical sensors (gateways). + +* 2. Issue: Hardcoded Chat UI +** Symptoms +The `Chat Agent` could only respond via Emacs buffer insertion, rendering it useless for external channels like Telegram. +** Root Cause +Architectural myopia. The original chat skill assumed the user was always in front of Emacs. +** Resolution +Refactored `org-skill-chat` to be **Channel-Aware**: +- It now extracts `:channel` and `:chat-id` from the inbound stimulus. +- It dynamically generates the System 1 mandate, instructing the LLM to use the appropriate `:target` (e.g., `:telegram`) based on the conversation context. + +* 3. Side-Issue: UIOP Portability +** Symptoms +Tests failed with `Symbol "SETENV" not found in the UIOP/DRIVER package`. +** Root Cause +Misinterpretation of the `UIOP` API. `setenv` is not a standard export; the portable way is using `(setf (uiop:getenv ...) ...)`. +** Resolution +Updated all test environment setup to use the `setf` accessor. + +* 4. PSF Mandate Alignment +** Sovereign Boundary +By moving the Telegram API logic to a user-space skill and communicating with the core via standard stimuli, we have respected the microkernel boundary. +** Homoiconic Memory +All Telegram interactions are now logged as `:chat-message` events, ensuring the agent's history is unified regardless of the platform. + +* 5. Permanent Learnings +- **Foundation First:** Registries and logging macros must reside in the most foundational layers (`protocol` or `package`) to avoid load-order fragility. +- **Instruct the Actuator:** When adding new channels, always update the Chat Agent's neural prompt so it knows how to "speak" back through the new interface. diff --git a/literate/core.org b/literate/core.org index 8555004..529dc3f 100644 --- a/literate/core.org +++ b/literate/core.org @@ -41,17 +41,6 @@ The kernel maintains several thread-safe global variables for logging, telemetry (defvar *telemetry-lock* (bt:make-lock "kernel-telemetry-lock")) #+end_src -** Actuator Registration -Actuators are the "hands" of the agent. This registry allows external modules (like Emacs or the Shell) to register functions that the kernel can invoke to perform physical actions. - -#+begin_src lisp :tangle ../src/core.lisp -(defvar *actuator-registry* (make-hash-table :test 'equal)) - -(defun register-actuator (name fn) - "Registers an actuator function. Actuators receive two arguments: (ACTION CONTEXT)." - (setf (gethash name *actuator-registry*) fn)) -#+end_src - ** Physical Dispatch (dispatch-action) Routes an approved action to its registered physical actuator. diff --git a/literate/protocol.org b/literate/protocol.org index ac6a8d2..7095e9d 100644 --- a/literate/protocol.org +++ b/literate/protocol.org @@ -16,6 +16,15 @@ We begin by ensuring we are in the correct package. (in-package :org-agent) #+end_src +#+begin_src lisp :tangle ../src/protocol.lisp +(defvar *actuator-registry* (make-hash-table :test 'equal) + "Global registry mapping target keywords to their physical actuator functions.") + +(defun register-actuator (name fn) + "Registers an actuator function. Actuators receive two arguments: (ACTION CONTEXT)." + (setf (gethash name *actuator-registry*) fn)) +#+end_src + ** Message Framing (frame-message) The `frame-message` function is responsible for preparing a string for transmission over the wire. It calculates the length and, if security is enabled via environment variables, appends an HMAC-SHA256 signature to guarantee message integrity. diff --git a/org-agent.asd b/org-agent.asd index ef14558..f1e569d 100644 --- a/org-agent.asd +++ b/org-agent.asd @@ -19,9 +19,9 @@ (:file "src/safety-harness") (:file "src/self-fix") (:file "src/lisp-repair") - (:file "src/shell-logic") (:file "src/bouncer") - (:file "src/core")) + (:file "src/core") + (:file "src/gateway-telegram")) :build-operation "program-op" :build-pathname "org-agent-server" :entry-point "org-agent:main") @@ -39,7 +39,8 @@ (:file "tests/self-fix-tests") (:file "tests/lisp-repair-tests") (:file "tests/bouncer-tests") - (:file "tests/shell-actuator-tests") + (:file "tests/llm-gateway-tests") + (:file "tests/gateway-telegram-tests") (:file "tests/chaos-qa")) :perform (test-op (o s) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :oacp-suite :org-agent-tests)) @@ -55,4 +56,5 @@ (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :bouncer-suite :org-agent-bouncer-tests)) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :llm-gateway-suite :org-agent-llm-gateway-tests)) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :shell-actuator-suite :org-agent-shell-actuator-tests)) + (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :gateway-telegram-suite :org-agent-gateway-telegram-tests)) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :chaos-suite :org-agent-chaos-qa)))) diff --git a/skills/org-skill-chat.org b/skills/org-skill-chat.org index bc9c26e..6e78173 100644 --- a/skills/org-skill-chat.org +++ b/skills/org-skill-chat.org @@ -66,6 +66,8 @@ Interfaces for conversational event handling and UI integration. Source of truth (or (and (member (getf proposed-action :type) '(:request :REQUEST)) (or (and (member target '(:emacs :EMACS)) (member action '(:insert-at-end :INSERT-AT-END))) + (and (member target '(:telegram :TELEGRAM)) + (or (getf payload :chat-id) (getf proposed-action :chat-id))) (and (member target '(:shell :SHELL)) (or (getf payload :cmd) (getf proposed-action :cmd))) (member target '(:tool :TOOL)))) @@ -83,19 +85,26 @@ The Chat skill acts as the conversational UI. Because the ~org-agent~ kernel eva "Generates a conversational response, stripping system errors from context." (let* ((payload (getf context :payload)) (raw-text (getf payload :text)) + (channel (or (getf payload :channel) :emacs)) + (chat-id (getf payload :chat-id)) ;; Context Purge: Remove system errors and hallucinations from the history (clean-text (cl-ppcre:regex-replace-all "(?i)Unknown request|System Error.*|Thinking\\.\\.\\." raw-text "")) (trimmed-text (if (> (length clean-text) 1000) (subseq clean-text (- (length clean-text) 1000)) - clean-text))) - (ask-neuro trimmed-text :system-prompt "ACTUATOR IDENTITY: You are the pure Lisp actuator for the org-agent kernel. + clean-text)) + (reply-instruction + (case channel + (:telegram (format nil "- To reply via Telegram: (:type :REQUEST :target :telegram :chat-id \"~a\" :text \"\")" chat-id)) + (t "- To reply via Emacs: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* \")")))) + (ask-neuro trimmed-text :system-prompt (concatenate 'string + "ACTUATOR IDENTITY: You are the pure Lisp actuator for the org-agent kernel. MANDATE: Output EXACTLY ONE Common Lisp property list starting with (:type :REQUEST). ZERO CONVERSATION: Do not explain. Do not use markdown. STRICT RULE: Never output the strings 'Unknown request' or 'System Error'. REQUIRED FORMATS: -- To reply: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* \") -- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"\" :args (...))"))) +" reply-instruction " +- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"\" :args (...))")))) #+end_src * Registration diff --git a/skills/org-skill-credentials-vault.org b/skills/org-skill-credentials-vault.org index 598311e..9e9df26 100644 --- a/skills/org-skill-credentials-vault.org +++ b/skills/org-skill-credentials-vault.org @@ -96,11 +96,12 @@ This function is the secure getter for all system secrets. It prioritizes the Va val ;; Fallback to environment (let ((env-var (case provider - (:gemini "GEMINI_API_KEY") + ((:gemini :gemini-api) "GEMINI_API_KEY") (:openai "OPENAI_API_KEY") (:anthropic "ANTHROPIC_API_KEY") (:groq "GROQ_API_KEY") (:openrouter "OPENROUTER_API_KEY") + (:telegram "TELEGRAM_BOT_TOKEN") (t nil)))) (when (and env-var (eq type :api-key)) (uiop:getenv env-var)))))) diff --git a/skills/org-skill-gateway-telegram.org b/skills/org-skill-gateway-telegram.org new file mode 100644 index 0000000..eb98029 --- /dev/null +++ b/skills/org-skill-gateway-telegram.org @@ -0,0 +1,138 @@ +:PROPERTIES: +:ID: gateway-telegram-skill +:CREATED: [2026-04-11 Sat 15:50] +:END: +#+TITLE: SKILL: Telegram Gateway (Universal Literate Note) +#+STARTUP: content +#+FILETAGS: :gateway:telegram:io:psf: +#+DEPENDS_ON: id:credentials-vault-skill + +* Overview +The *Telegram Gateway* provides bi-directional communication between the Sovereign and the Org-Agent via the Telegram Bot API. It features a non-blocking polling sensor and a high-integrity actuator for outbound messaging. + +* Phase A: Demand (PRD) +:PROPERTIES: +:STATUS: SIGNED +:END: + +** 1. Purpose +Enable mobile/remote access to the Org-Agent via a secure Telegram bot. + +** 2. Success Criteria +- [ ] *Inbound:* Messages from authorized Telegram IDs are injected into the Kernel Bus. +- [ ] *Outbound:* The `:telegram` target correctly routes messages to the Bot API. +- [ ] *Persistence:* The polling offset is maintained to prevent duplicate processing. + +* Phase B: Blueprint (PROTOCOL) +:PROPERTIES: +:STATUS: SIGNED +:END: + +** 1. Architectural Intent +The gateway operates as an autonomous background service. It uses `dexador` for HTTP polling and `cl-json` for payload processing. Authentication is enforced via a whitelist of authorized `chat_id`s. + +** 2. Semantic Interfaces +- `(:sensor :chat-message :channel :telegram ...)` +- `(:type :REQUEST :target :telegram :chat-id "..." :text "...")` + +* Phase D: Build (Implementation) + +** Package Context +#+begin_src lisp :tangle ../src/gateway-telegram.lisp +(in-package :org-agent) +#+end_src + +** State & Config +#+begin_src lisp :tangle ../src/gateway-telegram.lisp +(defvar *telegram-last-update-id* 0) +(defvar *telegram-polling-thread* nil) +(defvar *telegram-authorized-chats* nil + "List of chat IDs allowed to interact with the bot. Hydrated from environment.") + +(defun get-telegram-token () (vault-get-secret :telegram)) +#+end_src + +** Actuator: sendMessage +#+begin_src lisp :tangle ../src/gateway-telegram.lisp +(defun execute-telegram-action (action context) + "Sends a message back to Telegram." + (declare (ignore context)) + (let* ((payload (getf action :payload)) + (chat-id (or (getf payload :chat-id) (getf action :chat-id))) + (text (or (getf payload :text) (getf action :text))) + (token (get-telegram-token)) + (url (format nil "https://api.telegram.org/bot~a/sendMessage" token))) + (when (and token chat-id text) + (kernel-log "TELEGRAM: Sending message to ~a..." chat-id) + (handler-case + (dex:post url + :headers '(("Content-Type" . "application/json")) + :content (cl-json:encode-json-to-string + `((chat_id . ,chat-id) (text . ,text)))) + (error (c) (kernel-log "TELEGRAM ERROR: ~a" c)))))) +#+end_src + +** Sensor: getUpdates & Injection +#+begin_src lisp :tangle ../src/gateway-telegram.lisp +(defun telegram-process-updates () + "Polls for new messages and injects them into the kernel." + (let* ((token (get-telegram-token)) + (url (format nil "https://api.telegram.org/bot~a/getUpdates?offset=~a" + token (1+ *telegram-last-update-id*)))) + (when token + (handler-case + (let* ((response (dex:get url)) + (json (cl-json:decode-json-from-string response)) + (updates (cdr (assoc :result json)))) + (dolist (update updates) + (let* ((update-id (cdr (assoc :update--id update))) + (message (cdr (assoc :message update))) + (chat (cdr (assoc :chat message))) + (chat-id (cdr (assoc :id chat))) + (text (cdr (assoc :text message)))) + (setf *telegram-last-update-id* update-id) + (when (and text chat-id) + (kernel-log "TELEGRAM: Received message from ~a" chat-id) + (inject-stimulus + (list :type :EVENT + :payload (list :sensor :chat-message + :channel :telegram + :chat-id (format nil "~a" chat-id) + :text text))))))) + (error (c) (kernel-log "TELEGRAM POLL ERROR: ~a" c)))))) +#+end_src + +** Background Polling Loop +#+begin_src lisp :tangle ../src/gateway-telegram.lisp +(defun start-telegram-gateway () + "Initializes the Telegram background thread." + (unless (and *telegram-polling-thread* (bt:thread-alive-p *telegram-polling-thread*)) + (setf *telegram-polling-thread* + (bt:make-thread + (lambda () + (loop + (telegram-process-updates) + (sleep 3))) + :name "org-agent-telegram-gateway")) + (kernel-log "TELEGRAM: Gateway polling active."))) + +(defun stop-telegram-gateway () + (when (and *telegram-polling-thread* (bt:thread-alive-p *telegram-polling-thread*)) + (bt:destroy-thread *telegram-polling-thread*) + (setf *telegram-polling-thread* nil))) +#+end_src + +** Skill Definition & Registration +#+begin_src lisp :tangle ../src/gateway-telegram.lisp +(progn + (register-actuator :telegram #'execute-telegram-action) + + (defskill :skill-gateway-telegram + :priority 150 + :trigger (lambda (ctx) (declare (ignore ctx)) nil) ;; Passive, handles its own loop + :neuro nil + :symbolic (lambda (action ctx) (declare (ignore ctx)) action)) + + ;; Initialize the background polling loop + (start-telegram-gateway)) +#+end_src diff --git a/src/chat-logic.lisp b/src/chat-logic.lisp index 0e4a011..cda5060 100644 --- a/src/chat-logic.lisp +++ b/src/chat-logic.lisp @@ -1,3 +1,5 @@ +(in-package :org-agent) + (defun trigger-skill-chat (context) (let* ((payload (getf context :payload)) (sensor (getf payload :sensor))) @@ -11,28 +13,44 @@ (or (and (member (getf proposed-action :type) '(:request :REQUEST)) (or (and (member target '(:emacs :EMACS)) (member action '(:insert-at-end :INSERT-AT-END))) + (and (member target '(:telegram :TELEGRAM)) + (or (getf payload :chat-id) (getf proposed-action :chat-id))) (and (member target '(:shell :SHELL)) (or (getf payload :cmd) (getf proposed-action :cmd))) (member target '(:tool :TOOL)))) (member (getf proposed-action :type) '(:response :RESPONSE :log :LOG)))) proposed-action - (let ((err-text (format nil "\n\n*System Error:* Chat agent returned invalid action: ~s" proposed-action))) + (let ((err-text (format nil " +*System Error:* Chat agent returned invalid action: ~s" proposed-action))) `(:type :request :target :emacs :payload (:action :insert-at-end :buffer "*org-agent-chat*" :text ,err-text)))))) (defun neuro-skill-chat (context) "Generates a conversational response, stripping system errors from context." (let* ((payload (getf context :payload)) (raw-text (getf payload :text)) + (channel (or (getf payload :channel) :emacs)) + (chat-id (getf payload :chat-id)) ;; Context Purge: Remove system errors and hallucinations from the history (clean-text (cl-ppcre:regex-replace-all "(?i)Unknown request|System Error.*|Thinking\\.\\.\\." raw-text "")) (trimmed-text (if (> (length clean-text) 1000) (subseq clean-text (- (length clean-text) 1000)) - clean-text))) - (ask-neuro trimmed-text :system-prompt "ACTUATOR IDENTITY: You are the pure Lisp actuator for the org-agent kernel. + clean-text)) + (reply-instruction + (case channel + (:telegram (format nil "- To reply via Telegram: (:type :REQUEST :target :telegram :chat-id \"~a\" :text \"\")" chat-id)) + (t "- To reply via Emacs: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* \")")))) + (ask-neuro trimmed-text :system-prompt (concatenate 'string + "ACTUATOR IDENTITY: You are the pure Lisp actuator for the org-agent kernel. MANDATE: Output EXACTLY ONE Common Lisp property list starting with (:type :REQUEST). ZERO CONVERSATION: Do not explain. Do not use markdown. STRICT RULE: Never output the strings 'Unknown request' or 'System Error'. REQUIRED FORMATS: -- To reply: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* \") -- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"\" :args (...))"))) +" reply-instruction " +- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"\" :args (...))")))) + +(defskill :skill-chat + :priority 100 + :trigger #'trigger-skill-chat + :neuro #'neuro-skill-chat + :symbolic #'verify-skill-chat) diff --git a/src/core.lisp b/src/core.lisp index 207481e..5152960 100644 --- a/src/core.lisp +++ b/src/core.lisp @@ -8,12 +8,6 @@ (defvar *skill-telemetry* (make-hash-table :test 'equal)) (defvar *telemetry-lock* (bt:make-lock "kernel-telemetry-lock")) -(defvar *actuator-registry* (make-hash-table :test 'equal)) - -(defun register-actuator (name fn) - "Registers an actuator function. Actuators receive two arguments: (ACTION CONTEXT)." - (setf (gethash name *actuator-registry*) fn)) - (defun dispatch-action (action context) "Routes an approved action to its registered physical actuator." (when (and action (listp action)) diff --git a/src/credentials-vault.lisp b/src/credentials-vault.lisp index cb76a94..039c32f 100644 --- a/src/credentials-vault.lisp +++ b/src/credentials-vault.lisp @@ -22,6 +22,7 @@ (:anthropic "ANTHROPIC_API_KEY") (:groq "GROQ_API_KEY") (:openrouter "OPENROUTER_API_KEY") + (:telegram "TELEGRAM_BOT_TOKEN") (t nil)))) (when (and env-var (eq type :api-key)) (uiop:getenv env-var)))))) diff --git a/src/gateway-telegram.lisp b/src/gateway-telegram.lisp new file mode 100644 index 0000000..0e6d0a2 --- /dev/null +++ b/src/gateway-telegram.lisp @@ -0,0 +1,81 @@ +(in-package :org-agent) + +(defvar *telegram-last-update-id* 0) +(defvar *telegram-polling-thread* nil) +(defvar *telegram-authorized-chats* nil + "List of chat IDs allowed to interact with the bot. Hydrated from environment.") + +(defun get-telegram-token () (vault-get-secret :telegram)) + +(defun execute-telegram-action (action context) + "Sends a message back to Telegram." + (declare (ignore context)) + (let* ((payload (getf action :payload)) + (chat-id (or (getf payload :chat-id) (getf action :chat-id))) + (text (or (getf payload :text) (getf action :text))) + (token (get-telegram-token)) + (url (format nil "https://api.telegram.org/bot~a/sendMessage" token))) + (when (and token chat-id text) + (kernel-log "TELEGRAM: Sending message to ~a..." chat-id) + (handler-case + (dex:post url + :headers '(("Content-Type" . "application/json")) + :content (cl-json:encode-json-to-string + `((chat_id . ,chat-id) (text . ,text)))) + (error (c) (kernel-log "TELEGRAM ERROR: ~a" c)))))) + +(defun telegram-process-updates () + "Polls for new messages and injects them into the kernel." + (let* ((token (get-telegram-token)) + (url (format nil "https://api.telegram.org/bot~a/getUpdates?offset=~a" + token (1+ *telegram-last-update-id*)))) + (when token + (handler-case + (let* ((response (dex:get url)) + (json (cl-json:decode-json-from-string response)) + (updates (cdr (assoc :result json)))) + (dolist (update updates) + (let* ((update-id (cdr (assoc :update--id update))) + (message (cdr (assoc :message update))) + (chat (cdr (assoc :chat message))) + (chat-id (cdr (assoc :id chat))) + (text (cdr (assoc :text message)))) + (setf *telegram-last-update-id* update-id) + (when (and text chat-id) + (kernel-log "TELEGRAM: Received message from ~a" chat-id) + (inject-stimulus + (list :type :EVENT + :payload (list :sensor :chat-message + :channel :telegram + :chat-id (format nil "~a" chat-id) + :text text))))))) + (error (c) (kernel-log "TELEGRAM POLL ERROR: ~a" c)))))) + +(defun start-telegram-gateway () + "Initializes the Telegram background thread." + (unless (and *telegram-polling-thread* (bt:thread-alive-p *telegram-polling-thread*)) + (setf *telegram-polling-thread* + (bt:make-thread + (lambda () + (loop + (telegram-process-updates) + (sleep 3))) + :name "org-agent-telegram-gateway")) + (kernel-log "TELEGRAM: Gateway polling active."))) + +(defun stop-telegram-gateway () + (when (and *telegram-polling-thread* (bt:thread-alive-p *telegram-polling-thread*)) + (bt:destroy-thread *telegram-polling-thread*) + (setf *telegram-polling-thread* nil))) + +(progn + (register-actuator :telegram #'execute-telegram-action) + + (defskill :skill-gateway-telegram + :priority 150 + :trigger (lambda (ctx) (declare (ignore ctx)) nil) ;; Passive, handles its own loop + :neuro nil + :symbolic (lambda (action ctx) (declare (ignore ctx)) action)) + + ;; Initialize the background polling loop + (start-telegram-gateway)) diff --git a/src/protocol.lisp b/src/protocol.lisp index 0b3bcc1..29768c1 100644 --- a/src/protocol.lisp +++ b/src/protocol.lisp @@ -1,5 +1,12 @@ (in-package :org-agent) +(defvar *actuator-registry* (make-hash-table :test 'equal) + "Global registry mapping target keywords to their physical actuator functions.") + +(defun register-actuator (name fn) + "Registers an actuator function. Actuators receive two arguments: (ACTION CONTEXT)." + (setf (gethash name *actuator-registry*) fn)) + (defun frame-message (msg-string) "Prefix MSG-STRING with a 6-character hex length (lowercase). FUTURE: Will also prefix a 64-char HMAC signature when OACP_ENFORCE_HMAC=true." diff --git a/tests/gateway-telegram-tests.lisp b/tests/gateway-telegram-tests.lisp new file mode 100644 index 0000000..e1db5b1 --- /dev/null +++ b/tests/gateway-telegram-tests.lisp @@ -0,0 +1,59 @@ +(defpackage :org-agent-gateway-telegram-tests + (:use :cl :fiveam :org-agent) + (:export #:gateway-telegram-suite)) +(in-package :org-agent-gateway-telegram-tests) + +(def-suite gateway-telegram-suite :description "Tests for Telegram Gateway.") +(in-suite gateway-telegram-suite) + +(test test-telegram-inbound-normalization + "Verify that inbound Telegram JSON is correctly translated to a chat-message stimulus." + (let ((old-get (symbol-function 'dex:get)) + (mock-response "{\"ok\":true,\"result\":[{\"update_id\":100,\"message\":{\"message_id\":1,\"from\":{\"id\":12345,\"is_bot\":false,\"first_name\":\"Amr\"},\"chat\":{\"id\":12345,\"first_name\":\"Amr\",\"type\":\"private\"},\"date\":1678886400,\"text\":\"hello agent\"}}]}")) + (unwind-protect + (progn + (setf (symbol-function 'dex:get) (lambda (url) (declare (ignore url)) mock-response)) + (setf (uiop:getenv "TELEGRAM_BOT_TOKEN") "test-token") + + ;; 1. Simulate the polling process + (let ((captured-stimulus nil)) + (let ((original-inject (symbol-function 'org-agent:inject-stimulus))) + (setf (symbol-function 'org-agent:inject-stimulus) + (lambda (stim &key stream) (declare (ignore stream)) (setf captured-stimulus stim))) + + (org-agent::telegram-process-updates) + + (setf (symbol-function 'org-agent:inject-stimulus) original-inject) + + ;; 2. Verify normalization + (is (not (null captured-stimulus))) + (is (eq :EVENT (getf captured-stimulus :type))) + (is (eq :chat-message (getf (getf captured-stimulus :payload) :sensor))) + (is (eq :telegram (getf (getf captured-stimulus :payload) :channel))) + (is (equal "12345" (getf (getf captured-stimulus :payload) :chat-id))) + (is (equal "hello agent" (getf (getf captured-stimulus :payload) :text))) + (is (= 100 org-agent::*telegram-last-update-id*))))) + (setf (symbol-function 'dex:get) old-get)))) + +(test test-telegram-outbound-formatting + "Verify that an outbound :telegram request correctly formats the API call." + (let ((old-post (symbol-function 'dex:post)) + (captured-url nil) + (captured-content nil)) + (unwind-protect + (progn + (setf (symbol-function 'dex:post) + (lambda (url &key headers content connect-timeout read-timeout) + (declare (ignore headers connect-timeout read-timeout)) + (setf captured-url url) + (setf captured-content content) + "{\"ok\":true}")) + (setf (uiop:getenv "TELEGRAM_BOT_TOKEN") "test-token") + + (let ((action '(:type :REQUEST :target :telegram :chat-id "12345" :text "hello human"))) + (org-agent::execute-telegram-action action nil) + + (is (search "api.telegram.org/bottest-token/sendMessage" captured-url)) + (is (search "12345" captured-content)) + (is (search "hello human" captured-content)))) + (setf (symbol-function 'dex:post) old-post))))