FEAT: Implement Telegram Gateway and Channel-Aware Chat
This commit is contained in:
43
docs/rca/rca-gateway-telegram.org
Normal file
43
docs/rca/rca-gateway-telegram.org
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))))
|
||||
|
||||
138
skills/org-skill-gateway-telegram.org
Normal file
138
skills/org-skill-gateway-telegram.org
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
81
src/gateway-telegram.lisp
Normal 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))
|
||||
@@ -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."
|
||||
|
||||
59
tests/gateway-telegram-tests.lisp
Normal file
59
tests/gateway-telegram-tests.lisp
Normal 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))))
|
||||
Reference in New Issue
Block a user