feat(channels): add emacs channel adapter

Native HTTP bridge on 127.0.0.1: POST /api/message fires onInbound,
GET /api/messages serves an outbound ring buffer. Single-user, single-chat
(platform_id = "default"); gated by EMACS_ENABLED. No threads, no cold DM.

Ships emacs/nanoclaw.el unchanged from v1 — the HTTP protocol is identical,
so the existing client works against the v2 adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-17 15:26:44 +03:00
parent fbd8af618d
commit 0d75ca26f4
4 changed files with 952 additions and 0 deletions
+504
View File
@@ -0,0 +1,504 @@
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
;; Author: NanoClaw
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: ai, assistant, chat
;;
;; Vanilla Emacs (init.el):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
;;
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
;;
;; Doom Emacs (config.el):
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
;; (map! :leader
;; :prefix ("N" . "NanoClaw")
;; :desc "Chat buffer" "c" #'nanoclaw-chat
;; :desc "Send org" "o" #'nanoclaw-org-send)
;; ;; Evil users: teach evil about the C-c C-c send binding
;; (after! evil
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
;;; Code:
(require 'cl-lib)
(require 'url)
(require 'json)
(require 'org)
;; ---------------------------------------------------------------------------
;; Customization
(defgroup nanoclaw nil
"NanoClaw AI assistant interface."
:group 'tools
:prefix "nanoclaw-")
(defcustom nanoclaw-host "localhost"
"Hostname where NanoClaw is running."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-port 8766
"Port for the NanoClaw Emacs channel HTTP server."
:type 'integer
:group 'nanoclaw)
(defcustom nanoclaw-auth-token nil
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
Leave nil if EMACS_AUTH_TOKEN is not set."
:type '(choice (const nil) string)
:group 'nanoclaw)
(defcustom nanoclaw-poll-interval 1.5
"Seconds between response polls when waiting for a reply."
:type 'number
:group 'nanoclaw)
(defcustom nanoclaw-agent-name "Andy"
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-convert-to-org t
"When non-nil, convert agent responses to org-mode format.
Uses pandoc when available; falls back to regex substitutions."
:type 'boolean
:group 'nanoclaw)
(defcustom nanoclaw-timestamp-format "%H:%M"
"Format string for timestamps shown next to agent replies in the chat buffer.
Passed to `format-time-string'. Set to nil to suppress timestamps."
:type '(choice (const nil) string)
:group 'nanoclaw)
;; ---------------------------------------------------------------------------
;; Formatting helpers
(defun nanoclaw--to-org (text)
"Convert TEXT (markdown or plain) to org-mode markup.
Tries pandoc -f gfm -t org when available; falls back to regex."
(if (not nanoclaw-convert-to-org)
text
(if (executable-find "pandoc")
(with-temp-buffer
(insert text)
(let* ((coding-system-for-read 'utf-8)
(coding-system-for-write 'utf-8)
(exit (call-process-region
(point-min) (point-max)
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
(if (zerop exit)
(string-trim (buffer-string))
text)))
(nanoclaw--md-to-org-regex text))))
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
;; Agents responding on this channel must output markdown, not org-mode syntax.
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
;; re-converted to /bold/ by the italic rule.
(defun nanoclaw--md-to-org-regex (text)
"Lightweight markdown → org conversion using regexp substitutions."
(let ((s text))
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
;; (must run before inline-code to avoid mangling backticks)
(setq s (replace-regexp-in-string
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
(lambda (m)
(let ((lang (match-string 1 m))
(body (match-string 2 m)))
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
"\n" body "#+end_src")))
s t))
;; Bold **text** → *text*, italic *text* → /text/
;; Two-pass to prevent the italic regex from re-matching the bold result:
;; 1. Mark bold spans with a placeholder (control char \x01)
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
;; 2. Convert remaining single-star spans to italic
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
;; 3. Resolve bold placeholders to org bold markers
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
;; Strikethrough ~~text~~ → +text+
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
;; Underline __text__ → _text_
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
;; Inline code `code` → ~code~
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
;; ATX headings ## … → ** …
(setq s (replace-regexp-in-string
"^\\(#+\\) "
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
s))
;; Links [text](url) → [[url][text]]
(setq s (replace-regexp-in-string
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
s))
(defun nanoclaw--format-timestamp ()
"Return a formatted timestamp string, or nil if disabled."
(when nanoclaw-timestamp-format
(format-time-string nanoclaw-timestamp-format)))
;; ---------------------------------------------------------------------------
;; Internal state
(defvar nanoclaw--poll-timer nil
"Timer used to poll for responses in the chat buffer.")
(defvar nanoclaw--last-timestamp 0
"Epoch ms of the most recently received message.")
(defvar nanoclaw--pending nil
"Non-nil while waiting for a response.")
(defvar-local nanoclaw--thinking-dot-count 0
"Dot cycle counter for the animated thinking indicator.")
(defvar-local nanoclaw--input-beg nil
"Marker for the start of the current user input area.")
;; ---------------------------------------------------------------------------
;; HTTP helpers
(defun nanoclaw--url (path)
"Return the full URL for PATH on the NanoClaw server."
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
(defun nanoclaw--headers ()
"Return alist of HTTP headers for NanoClaw requests."
(let ((hdrs '(("Content-Type" . "application/json"))))
(when nanoclaw-auth-token
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
hdrs))
(defun nanoclaw--post (text callback)
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
(let* ((url-request-method "POST")
(url-request-extra-headers (nanoclaw--headers))
(url-request-data (encode-coding-string
(json-encode `((text . ,text)))
'utf-8)))
(url-retrieve
(nanoclaw--url "/api/message")
(lambda (status)
(if (plist-get status :error)
(message "NanoClaw: POST error %s" (plist-get status :error))
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let ((data (ignore-errors (json-read))))
(funcall callback data))))
nil t t)))
(defun nanoclaw--poll (since callback)
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
(let* ((url-request-method "GET")
(url-request-extra-headers (nanoclaw--headers)))
(url-retrieve
(nanoclaw--url (format "/api/messages?since=%d" since))
(lambda (status)
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
(body (decode-coding-string raw 'utf-8))
(data (ignore-errors (json-read-from-string body)))
(msgs (cdr (assq 'messages data))))
(when msgs (funcall callback (append msgs nil))))))
nil t t)))
;; ---------------------------------------------------------------------------
;; Chat buffer
(defvar nanoclaw-chat-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'newline)
(define-key map (kbd "<return>") #'newline)
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
map)
"Keymap for `nanoclaw-chat-mode'.")
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
"Major mode for the NanoClaw chat buffer.
Derives from org-mode so that org markup (headings, bold, code blocks,
etc.) is fontified automatically. RET and <return> insert plain newlines
for multi-line input; send with C-c C-c."
(setq-local word-wrap t)
(visual-line-mode 1)
;; Disable org features that conflict with a linear chat buffer
(setq-local org-return-follows-link nil)
(setq-local org-cycle-emulate-tab nil)
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
(defun nanoclaw--advance-input-beg ()
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
(setq nanoclaw--input-beg (copy-marker (point-max)))))
(defun nanoclaw--chat-buffer ()
"Return the NanoClaw chat buffer, creating it if necessary."
(or (get-buffer "*NanoClaw*")
(with-current-buffer (get-buffer-create "*NanoClaw*")
(nanoclaw-chat-mode)
(set-buffer-file-coding-system 'utf-8)
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
(nanoclaw--insert-header)
(setq nanoclaw--input-beg (copy-marker (point-max)))
(current-buffer))))
(defun nanoclaw--insert-header ()
"Insert the welcome header into the chat buffer."
(let ((inhibit-read-only t))
(insert (propertize
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
nanoclaw-agent-name)
'face 'font-lock-comment-face))))
(defun nanoclaw--chat-insert (speaker text)
"Append SPEAKER: TEXT to the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let* ((inhibit-read-only t)
(is-agent (not (string= speaker "You")))
(display-text (if is-agent (nanoclaw--to-org text) text))
(ts (nanoclaw--format-timestamp))
(label (if ts (format "%s [%s]" speaker ts) speaker))
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
(goto-char (point-max))
(insert (propertize (concat label ": ") 'face face))
(insert display-text "\n\n")
(goto-char (point-max))
(when is-agent
(nanoclaw--advance-input-beg)))))
;;;###autoload
(defun nanoclaw-chat ()
"Open the NanoClaw chat buffer."
(interactive)
(pop-to-buffer (nanoclaw--chat-buffer))
(goto-char (point-max)))
(defun nanoclaw-chat-send ()
"Send the accumulated input area as a message to NanoClaw.
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
(interactive)
(when nanoclaw--pending
(message "NanoClaw: waiting for previous response...")
(cl-return-from nanoclaw-chat-send))
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
(marker-position nanoclaw--input-beg)
(line-beginning-position)))
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
(when (string-empty-p text)
(user-error "Nothing to send"))
(let ((inhibit-read-only t))
(delete-region beg (point-max)))
(nanoclaw--chat-insert "You" text)
(nanoclaw--advance-input-beg)
(setq nanoclaw--pending t)
(nanoclaw--post text
(lambda (data)
(when data
(setq nanoclaw--last-timestamp
(or (cdr (assq 'timestamp data))
nanoclaw--last-timestamp))
(nanoclaw--start-thinking)
(nanoclaw--start-poll))))))
(defun nanoclaw--start-poll ()
"Start polling for new messages."
(nanoclaw--stop-poll)
(setq nanoclaw--poll-timer
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
#'nanoclaw--poll-tick)))
(defun nanoclaw--stop-poll ()
"Stop the polling timer."
(when nanoclaw--poll-timer
(cancel-timer nanoclaw--poll-timer)
(setq nanoclaw--poll-timer nil)))
(defun nanoclaw--start-thinking ()
"Insert an animated thinking indicator at the end of the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let ((inhibit-read-only t))
(goto-char (point-max))
(setq nanoclaw--thinking-dot-count 1)
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
'nanoclaw-thinking t
'face 'font-lock-string-face)))))
(defun nanoclaw--tick-thinking ()
"Advance the dot animation in the thinking indicator."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(when nanoclaw--pending
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(let* ((end (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
(setq nanoclaw--thinking-dot-count n)
(delete-region pos end)
(save-excursion
(goto-char pos)
(insert (propertize
(format "%s: %s\n\n" nanoclaw-agent-name
(make-string n ?.))
'nanoclaw-thinking t
'face 'font-lock-string-face)))))))))))
(defun nanoclaw--clear-thinking ()
"Remove the thinking indicator from the chat buffer."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(delete-region pos (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))))))))
(defun nanoclaw--poll-tick ()
"Poll for new messages and insert them into the chat buffer."
(nanoclaw--tick-thinking)
(nanoclaw--poll
nanoclaw--last-timestamp
(lambda (msgs)
(dolist (msg msgs)
(let ((text (cdr (assq 'text msg)))
(ts (cdr (assq 'timestamp msg))))
(when (and text (> ts nanoclaw--last-timestamp))
(setq nanoclaw--last-timestamp ts)
(nanoclaw--clear-thinking)
(nanoclaw--chat-insert nanoclaw-agent-name text))))
(when msgs
(setq nanoclaw--pending nil)
(nanoclaw--stop-poll)))))
;; ---------------------------------------------------------------------------
;; Org integration
;;;###autoload
(defun nanoclaw-org-send ()
"Send the current org subtree to NanoClaw and insert the response as a child.
If a region is active, send the region text instead."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Not in an org-mode buffer"))
(let ((text (if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(nanoclaw--org-subtree-text))))
(when (string-empty-p (string-trim text))
(user-error "Nothing to send"))
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
(let ((marker (point-marker))
(buf (current-buffer)))
(nanoclaw--post
text
(lambda (data)
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
(level (with-current-buffer buf
(save-excursion (goto-char marker) (org-outline-level))))
(ph (with-current-buffer buf
(save-excursion
(goto-char marker)
(nanoclaw--org-insert-placeholder level)))))
(nanoclaw--poll-until-response
ts
(lambda (response)
(with-current-buffer buf
(save-excursion
(when (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil))
(goto-char marker)
(nanoclaw--org-insert-response response))))
(lambda ()
(message "NanoClaw: timed out waiting for response")
(when (marker-buffer ph)
(with-current-buffer (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil)))))))))))
(defun nanoclaw--org-insert-placeholder (level)
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
(org-back-to-heading t)
(org-end-of-subtree t t)
(let ((beg (point)))
(insert "\n" (make-string (1+ level) ?*) " "
nanoclaw-agent-name " [processing...]\n\n")
(copy-marker beg)))
(defun nanoclaw--org-subtree-text ()
"Return the text of the org subtree at point (heading + body)."
(org-with-wide-buffer
(org-back-to-heading t)
(let ((start (point))
(end (progn (org-end-of-subtree t t) (point))))
(buffer-substring-no-properties start end))))
(defun nanoclaw--org-insert-response (text)
"Insert TEXT as a child org heading under the current subtree."
(org-back-to-heading t)
(let* ((level (org-outline-level))
(child-stars (make-string (1+ level) ?*))
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
(body (nanoclaw--to-org text)))
(org-end-of-subtree t t)
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
body "\n")))
(defun nanoclaw--now-ms ()
"Return current time as milliseconds since epoch."
(let ((time (current-time)))
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
(/ (caddr time) 1000))))
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
"Poll until a message newer than SINCE arrives, then call CALLBACK.
Calls TIMEOUT-FN after 60 attempts (~90s)."
(let ((n (or attempts 0)))
(if (>= n 60)
(funcall timeout-fn)
(nanoclaw--poll
since
(lambda (msgs)
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
msgs)))
(if fresh
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
fresh "\n")))
(funcall callback text))
(run-with-timer nanoclaw-poll-interval nil
#'nanoclaw--poll-until-response
since callback timeout-fn (1+ n)))))))))
;; ---------------------------------------------------------------------------
(provide 'nanoclaw)
;;; nanoclaw.el ends here
+259
View File
@@ -0,0 +1,259 @@
/**
* Tests for the v2 emacs channel adapter.
*
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
*/
import http from 'http';
import type { AddressInfo } from 'net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createEmacsAdapter } from './emacs.js';
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
return {
conversations: [],
onInbound: vi.fn(),
onMetadata: vi.fn(),
onAction: vi.fn(),
...overrides,
};
}
/** Ask the OS for a free port, then immediately release it. Small race window
* before the adapter grabs it, but sufficient for local test use. */
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = http.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as AddressInfo).port;
srv.close(() => resolve(port));
});
});
}
async function req(
port: number,
method: string,
path: string,
body?: string,
extraHeaders: Record<string, string> = {},
): Promise<{ status: number; data: unknown }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
let raw = '';
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode!, data: raw });
}
});
});
request.on('error', reject);
if (body) request.write(body);
request.end();
});
}
describe('emacs adapter', () => {
let adapter: ChannelAdapter;
let port: number;
beforeEach(async () => {
port = await getFreePort();
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
});
afterEach(async () => {
if (adapter.isConnected()) await adapter.teardown();
});
describe('lifecycle', () => {
it('isConnected is false before setup', () => {
expect(adapter.isConnected()).toBe(false);
});
it('isConnected is true after setup', async () => {
await adapter.setup(makeSetup());
expect(adapter.isConnected()).toBe(true);
});
it('isConnected is false after teardown', async () => {
await adapter.setup(makeSetup());
await adapter.teardown();
expect(adapter.isConnected()).toBe(false);
});
it('teardown is a no-op before setup', async () => {
await expect(adapter.teardown()).resolves.not.toThrow();
});
it('calls onMetadata after setup with channel name', async () => {
const onMetadata = vi.fn();
await adapter.setup(makeSetup({ onMetadata }));
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
});
});
describe('POST /api/message', () => {
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
beforeEach(async () => {
onInbound = vi.fn() as unknown as typeof onInbound;
await adapter.setup(makeSetup({ onInbound }));
});
it('fires onInbound with chat kind and sender metadata', async () => {
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
expect(status).toBe(200);
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
expect(onInbound).toHaveBeenCalledOnce();
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
expect(platformId).toBe('default');
expect(threadId).toBeNull();
expect(msg).toMatchObject({
kind: 'chat',
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
});
});
it('returns 400 for empty text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
expect(status).toBe(400);
expect(onInbound).not.toHaveBeenCalled();
});
it('returns 400 for whitespace-only text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
expect(status).toBe(400);
});
it('returns 400 for invalid JSON', async () => {
const { status } = await req(port, 'POST', '/api/message', 'not-json');
expect(status).toBe(400);
});
it('returns 404 for unknown paths', async () => {
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
expect(status).toBe(404);
});
});
describe('GET /api/messages + deliver', () => {
beforeEach(async () => {
await adapter.setup(makeSetup());
});
it('returns empty buffer initially', async () => {
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
expect(status).toBe(200);
expect(data).toEqual({ messages: [] });
});
it('deliver pushes text for the poll endpoint to return', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.text).toBe('reply');
expect(typeof messages[0]?.timestamp).toBe('number');
});
it('deliver accepts plain-string content', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
});
it('deliver skips empty text silently', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('deliver rejects unknown platformId', async () => {
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
expect(result).toBeUndefined();
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('filters out messages at or before the since cutoff', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
const since = Date.now();
await new Promise((r) => setTimeout(r, 5));
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
expect(texts).not.toContain('old');
expect(texts).toContain('new');
});
it('caps buffer at 200 messages, evicting the oldest', async () => {
for (let i = 0; i < 205; i++) {
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
}
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string }[] }).messages;
expect(messages).toHaveLength(200);
expect(messages.map((m) => m.text)).not.toContain('m-0');
expect(messages.map((m) => m.text)).toContain('m-5');
expect(messages.map((m) => m.text)).toContain('m-204');
});
});
describe('auth', () => {
let authAdapter: ChannelAdapter;
let authPort: number;
beforeEach(async () => {
authPort = await getFreePort();
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
await authAdapter.setup(makeSetup());
});
afterEach(async () => {
if (authAdapter.isConnected()) await authAdapter.teardown();
});
it('rejects POST without Authorization header', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
expect(status).toBe(401);
});
it('rejects POST with wrong token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer wrong',
});
expect(status).toBe(401);
});
it('accepts POST with correct Bearer token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
it('rejects GET without Authorization header', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
expect(status).toBe(401);
});
it('accepts GET with correct Bearer token', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* Emacs channel adapter (v2) — native HTTP bridge.
*
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
* - POST /api/message — user typed a message in Emacs; fire onInbound
* - GET /api/messages?since=<ms> — Emacs polls for agent replies
*
* Single-user, single-chat: one adapter instance = one messaging group with
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
* no cold DM. Self-registers on import.
*/
import http from 'http';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
const OUTBOUND_BUFFER_MAX = 200;
interface BufferedMessage {
text: string;
timestamp: number;
}
interface EmacsAdapterOptions {
port: number;
authToken: string | null;
platformId: string;
}
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
let server: http.Server | null = null;
let setupConfig: ChannelSetup | null = null;
const outboundBuffer: BufferedMessage[] = [];
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
if (!opts.authToken) return true;
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
res
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Unauthorized' }));
return false;
}
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
let text: string;
try {
const parsed = JSON.parse(body) as { text?: string };
text = parsed.text ?? '';
} catch {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (!text.trim()) {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'text required' }));
return;
}
const timestamp = new Date().toISOString();
const id = `emacs-${Date.now()}`;
const inbound: InboundMessage = {
id,
kind: 'chat',
content: {
text,
sender: 'Emacs',
senderId: `emacs:${opts.platformId}`,
},
timestamp,
};
try {
setupConfig?.onInbound(opts.platformId, null, inbound);
} catch (err) {
log.error('Emacs onInbound failed', { err });
}
res
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
});
}
function handlePoll(url: URL, res: http.ServerResponse): void {
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
const messages = outboundBuffer.filter((m) => m.timestamp > since);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
}
return {
name: 'emacs',
channelType: 'emacs',
supportsThreads: false,
async setup(config: ChannelSetup): Promise<void> {
setupConfig = config;
server = http.createServer((req, res) => {
if (!checkAuth(req, res)) return;
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
if (req.method === 'POST' && url.pathname === '/api/message') {
handlePost(req, res);
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
handlePoll(url, res);
} else {
res
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Not found' }));
}
});
await new Promise<void>((resolve, reject) => {
server!.once('error', reject);
server!.listen(opts.port, '127.0.0.1', () => {
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
resolve();
});
});
// Stamp a human-readable name on the messaging_groups row on first boot.
config.onMetadata(opts.platformId, 'Emacs', false);
},
async teardown(): Promise<void> {
if (!server) return;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
log.info('Emacs channel stopped');
},
isConnected(): boolean {
return server?.listening ?? false;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
if (platformId !== opts.platformId) {
log.warn('Emacs deliver called with unknown platformId', { platformId });
return undefined;
}
const text = extractText(message.content);
if (!text) return undefined;
const id = `emacs-out-${Date.now()}`;
outboundBuffer.push({ text, timestamp: Date.now() });
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
return id;
},
};
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content;
if (content && typeof content === 'object') {
const c = content as { text?: unknown };
if (typeof c.text === 'string') return c.text;
}
return '';
}
registerChannelAdapter('emacs', {
factory: () => {
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
if (!enabled || enabled === 'false') return null;
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
const port = parseInt(portStr, 10);
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
return createEmacsAdapter({ port, authToken, platformId });
},
});
export { createEmacsAdapter };
+3
View File
@@ -41,3 +41,6 @@ import './imessage.js';
// whatsapp (native, no Chat SDK)
import './whatsapp.js';
// emacs (native HTTP bridge, no Chat SDK)
// import './emacs.js';