FEAT: Implement Telegram Gateway and Channel-Aware Chat

This commit is contained in:
2026-04-11 15:34:51 -04:00
parent b2acd9c702
commit 8ba3532067
13 changed files with 381 additions and 30 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -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 \"<Response>\")" chat-id))
(t "- To reply via Emacs: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* <Response>\")"))))
(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 \"* <Response>\")
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))")))
" reply-instruction "
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))"))))
#+end_src
* Registration

View File

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

View File

@@ -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

View File

@@ -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 \"<Response>\")" chat-id))
(t "- To reply via Emacs: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* <Response>\")"))))
(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 \"* <Response>\")
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))")))
" reply-instruction "
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))"))))
(defskill :skill-chat
:priority 100
:trigger #'trigger-skill-chat
:neuro #'neuro-skill-chat
:symbolic #'verify-skill-chat)

View File

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

View File

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

81
src/gateway-telegram.lisp Normal file
View File

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

View File

@@ -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."

View File

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