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"))
|
(defvar *telemetry-lock* (bt:make-lock "kernel-telemetry-lock"))
|
||||||
#+end_src
|
#+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)
|
** Physical Dispatch (dispatch-action)
|
||||||
Routes an approved action to its registered physical actuator.
|
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)
|
(in-package :org-agent)
|
||||||
#+end_src
|
#+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)
|
** 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.
|
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/safety-harness")
|
||||||
(:file "src/self-fix")
|
(:file "src/self-fix")
|
||||||
(:file "src/lisp-repair")
|
(:file "src/lisp-repair")
|
||||||
(:file "src/shell-logic")
|
|
||||||
(:file "src/bouncer")
|
(:file "src/bouncer")
|
||||||
(:file "src/core"))
|
(:file "src/core")
|
||||||
|
(:file "src/gateway-telegram"))
|
||||||
:build-operation "program-op"
|
:build-operation "program-op"
|
||||||
:build-pathname "org-agent-server"
|
:build-pathname "org-agent-server"
|
||||||
:entry-point "org-agent:main")
|
:entry-point "org-agent:main")
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
(:file "tests/self-fix-tests")
|
(:file "tests/self-fix-tests")
|
||||||
(:file "tests/lisp-repair-tests")
|
(:file "tests/lisp-repair-tests")
|
||||||
(:file "tests/bouncer-tests")
|
(:file "tests/bouncer-tests")
|
||||||
(:file "tests/shell-actuator-tests")
|
(:file "tests/llm-gateway-tests")
|
||||||
|
(:file "tests/gateway-telegram-tests")
|
||||||
(:file "tests/chaos-qa"))
|
(:file "tests/chaos-qa"))
|
||||||
:perform (test-op (o s)
|
:perform (test-op (o s)
|
||||||
(uiop:symbol-call :fiveam :run! (uiop:find-symbol* :oacp-suite :org-agent-tests))
|
(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* :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* :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* :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))))
|
(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 (getf proposed-action :type) '(:request :REQUEST))
|
||||||
(or (and (member target '(:emacs :EMACS))
|
(or (and (member target '(:emacs :EMACS))
|
||||||
(member action '(:insert-at-end :INSERT-AT-END)))
|
(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))
|
(and (member target '(:shell :SHELL))
|
||||||
(or (getf payload :cmd) (getf proposed-action :cmd)))
|
(or (getf payload :cmd) (getf proposed-action :cmd)))
|
||||||
(member target '(:tool :TOOL))))
|
(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."
|
"Generates a conversational response, stripping system errors from context."
|
||||||
(let* ((payload (getf context :payload))
|
(let* ((payload (getf context :payload))
|
||||||
(raw-text (getf payload :text))
|
(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
|
;; 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 ""))
|
(clean-text (cl-ppcre:regex-replace-all "(?i)Unknown request|System Error.*|Thinking\\.\\.\\." raw-text ""))
|
||||||
(trimmed-text (if (> (length clean-text) 1000)
|
(trimmed-text (if (> (length clean-text) 1000)
|
||||||
(subseq clean-text (- (length clean-text) 1000))
|
(subseq clean-text (- (length clean-text) 1000))
|
||||||
clean-text)))
|
clean-text))
|
||||||
(ask-neuro trimmed-text :system-prompt "ACTUATOR IDENTITY: You are the pure Lisp actuator for the org-agent kernel.
|
(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).
|
MANDATE: Output EXACTLY ONE Common Lisp property list starting with (:type :REQUEST).
|
||||||
ZERO CONVERSATION: Do not explain. Do not use markdown.
|
ZERO CONVERSATION: Do not explain. Do not use markdown.
|
||||||
STRICT RULE: Never output the strings 'Unknown request' or 'System Error'.
|
STRICT RULE: Never output the strings 'Unknown request' or 'System Error'.
|
||||||
|
|
||||||
REQUIRED FORMATS:
|
REQUIRED FORMATS:
|
||||||
- To reply: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* <Response>\")
|
" reply-instruction "
|
||||||
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))")))
|
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))"))))
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
* Registration
|
* Registration
|
||||||
|
|||||||
@@ -96,11 +96,12 @@ This function is the secure getter for all system secrets. It prioritizes the Va
|
|||||||
val
|
val
|
||||||
;; Fallback to environment
|
;; Fallback to environment
|
||||||
(let ((env-var (case provider
|
(let ((env-var (case provider
|
||||||
(:gemini "GEMINI_API_KEY")
|
((:gemini :gemini-api) "GEMINI_API_KEY")
|
||||||
(:openai "OPENAI_API_KEY")
|
(:openai "OPENAI_API_KEY")
|
||||||
(:anthropic "ANTHROPIC_API_KEY")
|
(:anthropic "ANTHROPIC_API_KEY")
|
||||||
(:groq "GROQ_API_KEY")
|
(:groq "GROQ_API_KEY")
|
||||||
(:openrouter "OPENROUTER_API_KEY")
|
(:openrouter "OPENROUTER_API_KEY")
|
||||||
|
(:telegram "TELEGRAM_BOT_TOKEN")
|
||||||
(t nil))))
|
(t nil))))
|
||||||
(when (and env-var (eq type :api-key))
|
(when (and env-var (eq type :api-key))
|
||||||
(uiop:getenv env-var))))))
|
(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)
|
(defun trigger-skill-chat (context)
|
||||||
(let* ((payload (getf context :payload))
|
(let* ((payload (getf context :payload))
|
||||||
(sensor (getf payload :sensor)))
|
(sensor (getf payload :sensor)))
|
||||||
@@ -11,28 +13,44 @@
|
|||||||
(or (and (member (getf proposed-action :type) '(:request :REQUEST))
|
(or (and (member (getf proposed-action :type) '(:request :REQUEST))
|
||||||
(or (and (member target '(:emacs :EMACS))
|
(or (and (member target '(:emacs :EMACS))
|
||||||
(member action '(:insert-at-end :INSERT-AT-END)))
|
(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))
|
(and (member target '(:shell :SHELL))
|
||||||
(or (getf payload :cmd) (getf proposed-action :cmd)))
|
(or (getf payload :cmd) (getf proposed-action :cmd)))
|
||||||
(member target '(:tool :TOOL))))
|
(member target '(:tool :TOOL))))
|
||||||
(member (getf proposed-action :type) '(:response :RESPONSE :log :LOG))))
|
(member (getf proposed-action :type) '(:response :RESPONSE :log :LOG))))
|
||||||
proposed-action
|
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))))))
|
`(:type :request :target :emacs :payload (:action :insert-at-end :buffer "*org-agent-chat*" :text ,err-text))))))
|
||||||
|
|
||||||
(defun neuro-skill-chat (context)
|
(defun neuro-skill-chat (context)
|
||||||
"Generates a conversational response, stripping system errors from context."
|
"Generates a conversational response, stripping system errors from context."
|
||||||
(let* ((payload (getf context :payload))
|
(let* ((payload (getf context :payload))
|
||||||
(raw-text (getf payload :text))
|
(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
|
;; 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 ""))
|
(clean-text (cl-ppcre:regex-replace-all "(?i)Unknown request|System Error.*|Thinking\\.\\.\\." raw-text ""))
|
||||||
(trimmed-text (if (> (length clean-text) 1000)
|
(trimmed-text (if (> (length clean-text) 1000)
|
||||||
(subseq clean-text (- (length clean-text) 1000))
|
(subseq clean-text (- (length clean-text) 1000))
|
||||||
clean-text)))
|
clean-text))
|
||||||
(ask-neuro trimmed-text :system-prompt "ACTUATOR IDENTITY: You are the pure Lisp actuator for the org-agent kernel.
|
(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).
|
MANDATE: Output EXACTLY ONE Common Lisp property list starting with (:type :REQUEST).
|
||||||
ZERO CONVERSATION: Do not explain. Do not use markdown.
|
ZERO CONVERSATION: Do not explain. Do not use markdown.
|
||||||
STRICT RULE: Never output the strings 'Unknown request' or 'System Error'.
|
STRICT RULE: Never output the strings 'Unknown request' or 'System Error'.
|
||||||
|
|
||||||
REQUIRED FORMATS:
|
REQUIRED FORMATS:
|
||||||
- To reply: (:type :REQUEST :target :emacs :action :insert-at-end :buffer \"*org-agent-chat*\" :text \"* <Response>\")
|
" reply-instruction "
|
||||||
- To use a tool: (:type :REQUEST :target :tool :action :call :tool \"<name>\" :args (...))")))
|
- 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 *skill-telemetry* (make-hash-table :test 'equal))
|
||||||
(defvar *telemetry-lock* (bt:make-lock "kernel-telemetry-lock"))
|
(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)
|
(defun dispatch-action (action context)
|
||||||
"Routes an approved action to its registered physical actuator."
|
"Routes an approved action to its registered physical actuator."
|
||||||
(when (and action (listp action))
|
(when (and action (listp action))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
(:anthropic "ANTHROPIC_API_KEY")
|
(:anthropic "ANTHROPIC_API_KEY")
|
||||||
(:groq "GROQ_API_KEY")
|
(:groq "GROQ_API_KEY")
|
||||||
(:openrouter "OPENROUTER_API_KEY")
|
(:openrouter "OPENROUTER_API_KEY")
|
||||||
|
(:telegram "TELEGRAM_BOT_TOKEN")
|
||||||
(t nil))))
|
(t nil))))
|
||||||
(when (and env-var (eq type :api-key))
|
(when (and env-var (eq type :api-key))
|
||||||
(uiop:getenv env-var))))))
|
(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)
|
(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)
|
(defun frame-message (msg-string)
|
||||||
"Prefix MSG-STRING with a 6-character hex length (lowercase).
|
"Prefix MSG-STRING with a 6-character hex length (lowercase).
|
||||||
FUTURE: Will also prefix a 64-char HMAC signature when OACP_ENFORCE_HMAC=true."
|
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