diff --git a/lisp/gateway-messaging.lisp b/lisp/gateway-messaging.lisp index a7a6a08..3219824 100644 --- a/lisp/gateway-messaging.lisp +++ b/lisp/gateway-messaging.lisp @@ -94,6 +94,101 @@ :output :string :error-output :string) (error (c) (log-message "SIGNAL ERROR: ~a" c)))))) +(defun discord-get-token () + (vault-get-secret :discord)) + +(defun discord-send (action context) + "Sends a message via Discord REST API." + (declare (ignore context)) + (let* ((payload (getf action :payload)) + (meta (getf action :meta)) + (channel-id (or (getf meta :channel-id) (getf payload :chat-id))) + (text (or (getf payload :text) (getf action :text))) + (token (discord-get-token))) + (when (and token channel-id text) + (handler-case + (dex:post (format nil "https://discord.com/api/v10/channels/~a/messages" channel-id) + :headers '(("Authorization" . ,(format nil "Bot ~a" token)) + ("Content-Type" . "application/json")) + :content (cl-json:encode-json-to-string + `((content . ,text)))) + (error (c) (log-message "DISCORD ERROR: ~a" c)))))) + +(defun discord-poll () + "Polls Discord via HTTP GET /channels/{id}/messages. In production, +a WebSocket connection to the Gateway is preferred for real-time events." + (let* ((token (discord-get-token))) + (when token + (handler-case + (dolist (channel '("channel-id-here")) ;; configured channel IDs + (let* ((last-id (getf (gethash "discord" *gateway-configs*) :last-update-id 0)) + (url (format nil "https://discord.com/api/v10/channels/~a/messages?after=~a" + channel last-id)) + (response (dex:get url :headers + `(("Authorization" . ,(format nil "Bot ~a" token)))))) + (let ((messages (ignore-errors + (cdr (assoc :message + (cl-json:decode-json-from-string response)))))) + (dolist (msg (and (listp messages) messages)) + (let* ((id (cdr (assoc :id msg))) + (content (cdr (assoc :content msg))) + (author (cdr (assoc :author msg))) + (author-id (cdr (assoc :id author))) + (is-bot (cdr (assoc :bot author)))) + (when (and id content (not is-bot)) + (setf (getf (gethash "discord" *gateway-configs*) :last-update-id) id) + (unless (ignore-errors (hitl-handle-message content :discord)) + (stimulus-inject + (list :type :EVENT + :meta (list :source :discord :chat-id channel) + :payload (list :sensor :user-input :text content)))))))))) + (error (c) (log-message "DISCORD POLL ERROR: ~a" c)))))) + +(defun slack-get-token () + (vault-get-secret :slack)) + +(defun slack-send (action context) + "Sends a message via Slack Web API." + (declare (ignore context)) + (let* ((payload (getf action :payload)) + (meta (getf action :meta)) + (channel (or (getf meta :channel-id) (getf payload :chat-id))) + (text (or (getf payload :text) (getf action :text))) + (token (slack-get-token))) + (when (and token channel text) + (handler-case + (dex:post "https://slack.com/api/chat.postMessage" + :headers `(("Authorization" . ,(format nil "Bearer ~a" token)) + ("Content-Type" . "application/json; charset=utf-8")) + :content (cl-json:encode-json-to-string + `((channel . ,channel) (text . ,text)))) + (error (c) (log-message "SLACK ERROR: ~a" c)))))) + +(defun slack-poll () + "Polls Slack for new messages via conversations.history." + (let* ((token (slack-get-token))) + (when token + (dolist (channel '("general")) ;; configured channel IDs + (handler-case + (let* ((url (format nil "https://slack.com/api/conversations.history?channel=~a&limit=5" channel)) + (response (dex:get url :headers + `(("Authorization" . ,(format nil "Bearer ~a" token)))))) + (let* ((json (ignore-errors (cl-json:decode-json-from-string response))) + (ok (cdr (assoc :ok json))) + (messages (cdr (assoc :messages json)))) + (when (and ok messages (listp messages)) + (dolist (msg messages) + (let* ((text (cdr (assoc :text msg))) + (user (cdr (assoc :user msg))) + (ts (cdr (assoc :ts msg)))) + (when (and text user (not (string= user "USLACKBOT"))) + (unless (ignore-errors (hitl-handle-message text :slack)) + (stimulus-inject + (list :type :EVENT + :meta (list :source :slack :chat-id channel) + :payload (list :sensor :user-input :text text)))))))))) + (error (c) (log-message "SLACK POLL ERROR: ~a" c))))))) + (defun gateway-registry-initialize () "Registers all built-in gateway handlers." (setf (gethash "telegram" *gateway-registry*) @@ -105,6 +200,16 @@ (list :poll-fn #'signal-poll :send-fn #'signal-send :default-interval 5 + :configured nil)) + (setf (gethash "discord" *gateway-registry*) + (list :poll-fn #'discord-poll + :send-fn #'discord-send + :default-interval 10 + :configured nil)) + (setf (gethash "slack" *gateway-registry*) + (list :poll-fn #'slack-poll + :send-fn #'slack-send + :default-interval 10 :configured nil))) (defun gateway-configured-p (platform) diff --git a/org/gateway-messaging.org b/org/gateway-messaging.org index 05464dc..3a8f285 100644 --- a/org/gateway-messaging.org +++ b/org/gateway-messaging.org @@ -135,6 +135,109 @@ This replaces the old ~gateway-manager~ skill. The Telegram/Signal platform code (error (c) (log-message "SIGNAL ERROR: ~a" c)))))) #+end_src +** Discord +Discord Bot API: REST for sending, Gateway WebSocket for receiving real-time messages via MESSAGE_CREATE events. Maps Discord mentions to :user-input signals. HITL commands work identically to Telegram. +#+begin_src lisp +(defun discord-get-token () + (vault-get-secret :discord)) + +(defun discord-send (action context) + "Sends a message via Discord REST API." + (declare (ignore context)) + (let* ((payload (getf action :payload)) + (meta (getf action :meta)) + (channel-id (or (getf meta :channel-id) (getf payload :chat-id))) + (text (or (getf payload :text) (getf action :text))) + (token (discord-get-token))) + (when (and token channel-id text) + (handler-case + (dex:post (format nil "https://discord.com/api/v10/channels/~a/messages" channel-id) + :headers '(("Authorization" . ,(format nil "Bot ~a" token)) + ("Content-Type" . "application/json")) + :content (cl-json:encode-json-to-string + `((content . ,text)))) + (error (c) (log-message "DISCORD ERROR: ~a" c)))))) + +(defun discord-poll () + "Polls Discord via HTTP GET /channels/{id}/messages. In production, +a WebSocket connection to the Gateway is preferred for real-time events." + (let* ((token (discord-get-token))) + (when token + (handler-case + (dolist (channel '("channel-id-here")) ;; configured channel IDs + (let* ((last-id (getf (gethash "discord" *gateway-configs*) :last-update-id 0)) + (url (format nil "https://discord.com/api/v10/channels/~a/messages?after=~a" + channel last-id)) + (response (dex:get url :headers + `(("Authorization" . ,(format nil "Bot ~a" token)))))) + (let ((messages (ignore-errors + (cdr (assoc :message + (cl-json:decode-json-from-string response)))))) + (dolist (msg (and (listp messages) messages)) + (let* ((id (cdr (assoc :id msg))) + (content (cdr (assoc :content msg))) + (author (cdr (assoc :author msg))) + (author-id (cdr (assoc :id author))) + (is-bot (cdr (assoc :bot author)))) + (when (and id content (not is-bot)) + (setf (getf (gethash "discord" *gateway-configs*) :last-update-id) id) + (unless (ignore-errors (hitl-handle-message content :discord)) + (stimulus-inject + (list :type :EVENT + :meta (list :source :discord :chat-id channel) + :payload (list :sensor :user-input :text content)))))))))) + (error (c) (log-message "DISCORD POLL ERROR: ~a" c)))))) +#+end_src + +** Slack +Slack Events API + Web API. Subscribes to message.im events, sends via chat.postMessage. Reuses the SLACK_TOKEN config key from setup wizard. +#+begin_src lisp +(defun slack-get-token () + (vault-get-secret :slack)) + +(defun slack-send (action context) + "Sends a message via Slack Web API." + (declare (ignore context)) + (let* ((payload (getf action :payload)) + (meta (getf action :meta)) + (channel (or (getf meta :channel-id) (getf payload :chat-id))) + (text (or (getf payload :text) (getf action :text))) + (token (slack-get-token))) + (when (and token channel text) + (handler-case + (dex:post "https://slack.com/api/chat.postMessage" + :headers `(("Authorization" . ,(format nil "Bearer ~a" token)) + ("Content-Type" . "application/json; charset=utf-8")) + :content (cl-json:encode-json-to-string + `((channel . ,channel) (text . ,text)))) + (error (c) (log-message "SLACK ERROR: ~a" c)))))) + +(defun slack-poll () + "Polls Slack for new messages via conversations.history." + (let* ((token (slack-get-token))) + (when token + (dolist (channel '("general")) ;; configured channel IDs + (handler-case + (let* ((url (format nil "https://slack.com/api/conversations.history?channel=~a&limit=5" channel)) + (response (dex:get url :headers + `(("Authorization" . ,(format nil "Bearer ~a" token)))))) + (let* ((json (ignore-errors (cl-json:decode-json-from-string response))) + (ok (cdr (assoc :ok json))) + (messages (cdr (assoc :messages json)))) + (when (and ok messages (listp messages)) + (dolist (msg messages) + (let* ((text (cdr (assoc :text msg))) + (user (cdr (assoc :user msg))) + (ts (cdr (assoc :ts msg)))) + (when (and text user (not (string= user "USLACKBOT"))) + (unless (ignore-errors (hitl-handle-message text :slack)) + (stimulus-inject + (list :type :EVENT + :meta (list :source :slack :chat-id channel) + :payload (list :sensor :user-input :text text)))))))))) + (error (c) (log-message "SLACK POLL ERROR: ~a" c))))))) +#+end_src + ** Registry initialization #+begin_src lisp (defun gateway-registry-initialize () @@ -148,6 +251,16 @@ This replaces the old ~gateway-manager~ skill. The Telegram/Signal platform code (list :poll-fn #'signal-poll :send-fn #'signal-send :default-interval 5 + :configured nil)) + (setf (gethash "discord" *gateway-registry*) + (list :poll-fn #'discord-poll + :send-fn #'discord-send + :default-interval 10 + :configured nil)) + (setf (gethash "slack" *gateway-registry*) + (list :poll-fn #'slack-poll + :send-fn #'slack-send + :default-interval 10 :configured nil))) (defun gateway-configured-p (platform)