openclaw-elys 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import { elysPlugin } from "./src/channel.js";
2
+ export { elysPlugin } from "./src/channel.js";
3
+ export { monitorElysProvider } from "./src/monitor.js";
4
+ export { registerDevice } from "./src/register.js";
5
+ export { ElysDeviceMQTTClient } from "./src/mqtt-client.js";
6
+ export { loadCredentials, saveCredentials, deleteCredentials, loadGatewayUrl, } from "./src/config.js";
7
+ export type { DeviceCredentials, CommandMessage, AckMessage, ResultMessage, ElysConfig, } from "./src/types.js";
8
+ declare const plugin: {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ configSchema: {
13
+ type: "object";
14
+ properties: {};
15
+ };
16
+ register(api: {
17
+ runtime: unknown;
18
+ registerChannel: (reg: {
19
+ plugin: typeof elysPlugin;
20
+ }) => void;
21
+ }): void;
22
+ };
23
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import { elysPlugin } from "./src/channel.js";
2
+ export { elysPlugin } from "./src/channel.js";
3
+ export { monitorElysProvider } from "./src/monitor.js";
4
+ export { registerDevice } from "./src/register.js";
5
+ export { ElysDeviceMQTTClient } from "./src/mqtt-client.js";
6
+ export { loadCredentials, saveCredentials, deleteCredentials, loadGatewayUrl, } from "./src/config.js";
7
+ const plugin = {
8
+ id: "openclaw-elys",
9
+ name: "Elys",
10
+ description: "Elys App channel plugin — connects to Elys App via MQTT gateway",
11
+ configSchema: { type: "object", properties: {} },
12
+ register(api) {
13
+ api.registerChannel({ plugin: elysPlugin });
14
+ },
15
+ };
16
+ export default plugin;
@@ -0,0 +1,89 @@
1
+ import type { ResolvedElysAccount } from "./types.js";
2
+ export declare const elysPlugin: {
3
+ id: string;
4
+ meta: {
5
+ id: string;
6
+ label: string;
7
+ selectionLabel: string;
8
+ docsPath: string;
9
+ docsLabel: string;
10
+ blurb: string;
11
+ order: number;
12
+ };
13
+ capabilities: {
14
+ chatTypes: "direct"[];
15
+ polls: boolean;
16
+ threads: boolean;
17
+ media: boolean;
18
+ reactions: boolean;
19
+ edit: boolean;
20
+ reply: boolean;
21
+ };
22
+ reload: {
23
+ configPrefixes: string[];
24
+ };
25
+ configSchema: {
26
+ schema: {
27
+ type: "object";
28
+ additionalProperties: boolean;
29
+ properties: {
30
+ enabled: {
31
+ type: "boolean";
32
+ };
33
+ gatewayUrl: {
34
+ type: "string";
35
+ };
36
+ registerToken: {
37
+ type: "string";
38
+ };
39
+ };
40
+ };
41
+ };
42
+ config: {
43
+ listAccountIds: () => string[];
44
+ resolveAccount: (cfg: Record<string, unknown>, accountId?: string | null) => {
45
+ accountId: string;
46
+ enabled: boolean;
47
+ configured: boolean;
48
+ gatewayUrl: string;
49
+ credentials: import("./types.js").DeviceCredentials | null;
50
+ };
51
+ isConfigured: (account: ResolvedElysAccount) => boolean;
52
+ isEnabled: (account: ResolvedElysAccount) => boolean;
53
+ describeAccount: (account: ResolvedElysAccount) => {
54
+ accountId: string;
55
+ enabled: boolean;
56
+ configured: boolean;
57
+ gatewayUrl: string;
58
+ };
59
+ };
60
+ outbound: {
61
+ deliveryMode: "direct";
62
+ textChunkLimit: number;
63
+ sendText: (ctx: {
64
+ cfg: Record<string, unknown>;
65
+ to: string;
66
+ text: string;
67
+ accountId?: string | null;
68
+ }) => Promise<{
69
+ messageId: string;
70
+ channel: string;
71
+ }>;
72
+ };
73
+ gateway: {
74
+ startAccount: (ctx: {
75
+ cfg: Record<string, unknown>;
76
+ accountId: string;
77
+ account: ResolvedElysAccount;
78
+ runtime: Record<string, unknown>;
79
+ abortSignal: AbortSignal;
80
+ log?: {
81
+ info: (...args: unknown[]) => void;
82
+ warn?: (...args: unknown[]) => void;
83
+ error?: (...args: unknown[]) => void;
84
+ };
85
+ setStatus: (status: Record<string, unknown>) => void;
86
+ channelRuntime?: Record<string, unknown>;
87
+ }) => Promise<void>;
88
+ };
89
+ };
@@ -0,0 +1,63 @@
1
+ import { resolveElysAccount } from "./config.js";
2
+ import { elysOutbound } from "./outbound.js";
3
+ export const elysPlugin = {
4
+ id: "elys",
5
+ meta: {
6
+ id: "elys",
7
+ label: "Elys",
8
+ selectionLabel: "Elys App",
9
+ docsPath: "/channels/elys",
10
+ docsLabel: "elys",
11
+ blurb: "Elys App integration via MQTT gateway.",
12
+ order: 80,
13
+ },
14
+ capabilities: {
15
+ chatTypes: ["direct"],
16
+ polls: false,
17
+ threads: false,
18
+ media: false,
19
+ reactions: false,
20
+ edit: false,
21
+ reply: true,
22
+ },
23
+ reload: { configPrefixes: ["channels.elys"] },
24
+ configSchema: {
25
+ schema: {
26
+ type: "object",
27
+ additionalProperties: false,
28
+ properties: {
29
+ enabled: { type: "boolean" },
30
+ gatewayUrl: { type: "string" },
31
+ registerToken: { type: "string" },
32
+ },
33
+ },
34
+ },
35
+ config: {
36
+ listAccountIds: () => ["default"],
37
+ resolveAccount: (cfg, accountId) => resolveElysAccount(cfg, accountId),
38
+ isConfigured: (account) => account.configured,
39
+ isEnabled: (account) => account.enabled,
40
+ describeAccount: (account) => ({
41
+ accountId: account.accountId,
42
+ enabled: account.enabled,
43
+ configured: account.configured,
44
+ gatewayUrl: account.gatewayUrl,
45
+ }),
46
+ },
47
+ outbound: elysOutbound,
48
+ gateway: {
49
+ startAccount: async (ctx) => {
50
+ const { monitorElysProvider } = await import("./monitor.js");
51
+ const account = resolveElysAccount(ctx.cfg, ctx.accountId);
52
+ ctx.setStatus({ accountId: ctx.accountId });
53
+ ctx.log?.info(`starting elys[${ctx.accountId}] (gateway: ${account.gatewayUrl})`);
54
+ return monitorElysProvider({
55
+ config: ctx.cfg,
56
+ runtime: ctx.runtime,
57
+ abortSignal: ctx.abortSignal,
58
+ accountId: ctx.accountId,
59
+ channelRuntime: ctx.channelRuntime,
60
+ });
61
+ },
62
+ },
63
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { registerDevice } from "./register.js";
3
+ import { loadCredentials, loadGatewayUrl, deleteCredentials, } from "./config.js";
4
+ const args = process.argv.slice(2);
5
+ const command = args[0];
6
+ if (command === "setup") {
7
+ const gatewayUrl = args[1];
8
+ const registerToken = args[2];
9
+ if (!gatewayUrl || !registerToken) {
10
+ console.error("Usage: openclaw-elys setup <gateway_url> <register_token>");
11
+ console.error("Example: openclaw-elys setup https://your-gateway.com reg_abc123");
12
+ process.exit(1);
13
+ }
14
+ console.log(`Registering device with gateway: ${gatewayUrl}`);
15
+ registerDevice(gatewayUrl, registerToken)
16
+ .then((creds) => {
17
+ console.log("Device registered successfully!");
18
+ console.log(` Device ID: ${creds.deviceId}`);
19
+ console.log(` MQTT Broker: ${creds.mqttBroker}`);
20
+ console.log(` Credentials saved to ~/.elys/config.json`);
21
+ })
22
+ .catch((err) => {
23
+ console.error("Registration failed:", err.message);
24
+ process.exit(1);
25
+ });
26
+ }
27
+ else if (command === "uninstall") {
28
+ const creds = loadCredentials();
29
+ const gatewayUrl = loadGatewayUrl();
30
+ if (!creds) {
31
+ console.log("No credentials found. Nothing to clean up.");
32
+ deleteCredentials();
33
+ process.exit(0);
34
+ }
35
+ if (!gatewayUrl) {
36
+ console.log("No gateway URL found. Cleaning up local credentials only.");
37
+ deleteCredentials();
38
+ console.log("Local credentials removed.");
39
+ process.exit(0);
40
+ }
41
+ console.log(`Revoking device ${creds.deviceId} from gateway...`);
42
+ fetch(`${gatewayUrl}/api/v1/device/revoke`, {
43
+ method: "DELETE",
44
+ headers: { Authorization: `Bearer ${creds.deviceToken}` },
45
+ })
46
+ .then(async (resp) => {
47
+ if (resp.ok) {
48
+ console.log("Device revoked successfully on gateway.");
49
+ }
50
+ else {
51
+ const text = await resp.text();
52
+ console.warn(`Gateway revoke returned ${resp.status}: ${text}`);
53
+ console.warn("Proceeding with local cleanup anyway.");
54
+ }
55
+ })
56
+ .catch((err) => {
57
+ console.warn(`Could not reach gateway: ${err.message}`);
58
+ console.warn("Proceeding with local cleanup anyway.");
59
+ })
60
+ .finally(() => {
61
+ deleteCredentials();
62
+ console.log("Local credentials removed.");
63
+ });
64
+ }
65
+ else if (command === "status") {
66
+ const creds = loadCredentials();
67
+ if (creds) {
68
+ console.log("Elys device credentials:");
69
+ console.log(` Device ID: ${creds.deviceId}`);
70
+ console.log(` MQTT Broker: ${creds.mqttBroker}`);
71
+ console.log(` Config file: ~/.elys/config.json`);
72
+ }
73
+ else {
74
+ console.log("No credentials found. Run: openclaw-elys setup <gateway_url> <register_token>");
75
+ }
76
+ }
77
+ else {
78
+ console.log("openclaw-elys - Elys OpenClaw channel plugin CLI");
79
+ console.log("");
80
+ console.log("Commands:");
81
+ console.log(" setup <gateway_url> <register_token> Register device with gateway");
82
+ console.log(" uninstall Revoke device and clean up");
83
+ console.log(" status Show current device credentials");
84
+ }
@@ -0,0 +1,13 @@
1
+ import type { DeviceCredentials, ElysConfig } from "./types.js";
2
+ export declare function loadCredentials(): DeviceCredentials | null;
3
+ export declare function saveCredentials(creds: DeviceCredentials, gatewayUrl?: string): void;
4
+ export declare function deleteCredentials(): void;
5
+ export declare function loadGatewayUrl(): string | null;
6
+ export declare function resolveElysConfig(cfg: Record<string, unknown>): ElysConfig;
7
+ export declare function resolveElysAccount(cfg: Record<string, unknown>, _accountId?: string | null): {
8
+ accountId: string;
9
+ enabled: boolean;
10
+ configured: boolean;
11
+ gatewayUrl: string;
12
+ credentials: DeviceCredentials | null;
13
+ };
@@ -0,0 +1,74 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ const CONFIG_DIR = path.join(os.homedir(), ".elys");
5
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
6
+ export function loadCredentials() {
7
+ try {
8
+ if (!fs.existsSync(CONFIG_FILE))
9
+ return null;
10
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
11
+ const data = JSON.parse(raw);
12
+ if (data.device_id && data.device_token && data.mqtt_broker) {
13
+ return {
14
+ deviceId: data.device_id,
15
+ deviceToken: data.device_token,
16
+ mqttBroker: data.mqtt_broker,
17
+ };
18
+ }
19
+ return null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ export function saveCredentials(creds, gatewayUrl) {
26
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
27
+ const data = {
28
+ device_id: creds.deviceId,
29
+ device_token: creds.deviceToken,
30
+ mqtt_broker: creds.mqttBroker,
31
+ };
32
+ if (gatewayUrl) {
33
+ data.gateway_url = gatewayUrl;
34
+ }
35
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
36
+ }
37
+ export function deleteCredentials() {
38
+ try {
39
+ if (fs.existsSync(CONFIG_FILE))
40
+ fs.unlinkSync(CONFIG_FILE);
41
+ if (fs.existsSync(CONFIG_DIR))
42
+ fs.rmdirSync(CONFIG_DIR);
43
+ }
44
+ catch {
45
+ // ignore
46
+ }
47
+ }
48
+ export function loadGatewayUrl() {
49
+ try {
50
+ if (!fs.existsSync(CONFIG_FILE))
51
+ return null;
52
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
53
+ const data = JSON.parse(raw);
54
+ return data.gateway_url || null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function resolveElysConfig(cfg) {
61
+ const elysCfg = cfg?.channels?.elys ?? {};
62
+ return elysCfg;
63
+ }
64
+ export function resolveElysAccount(cfg, _accountId) {
65
+ const elysCfg = resolveElysConfig(cfg);
66
+ const credentials = loadCredentials();
67
+ return {
68
+ accountId: "default",
69
+ enabled: elysCfg.enabled !== false,
70
+ configured: !!credentials && !!elysCfg.gatewayUrl,
71
+ gatewayUrl: elysCfg.gatewayUrl ?? "http://localhost:8080",
72
+ credentials,
73
+ };
74
+ }
@@ -0,0 +1,43 @@
1
+ export interface MonitorElysOpts {
2
+ config: Record<string, unknown>;
3
+ runtime?: Record<string, unknown>;
4
+ abortSignal?: AbortSignal;
5
+ accountId?: string;
6
+ channelRuntime?: ChannelRuntimeReply;
7
+ }
8
+ /** Subset of OpenClaw's channelRuntime.reply API that we use */
9
+ type ReplyPayload = {
10
+ text?: string;
11
+ mediaUrl?: string;
12
+ audioAsVoice?: boolean;
13
+ [key: string]: unknown;
14
+ };
15
+ type ChannelRuntimeReply = {
16
+ reply?: {
17
+ dispatchReplyWithBufferedBlockDispatcher?: (params: {
18
+ ctx: Record<string, unknown>;
19
+ cfg: Record<string, unknown>;
20
+ dispatcherOptions: {
21
+ deliver: (payload: ReplyPayload) => Promise<void>;
22
+ deliverBlock?: (payload: ReplyPayload) => Promise<void>;
23
+ deliverToolResult?: (payload: ReplyPayload) => Promise<void>;
24
+ };
25
+ replyOptions?: Record<string, unknown>;
26
+ }) => Promise<{
27
+ queuedFinal: boolean;
28
+ counts: Record<string, number>;
29
+ }>;
30
+ finalizeInboundContext?: (ctx: Record<string, unknown>) => Record<string, unknown>;
31
+ };
32
+ routing?: {
33
+ resolveAgentRoute?: (params: Record<string, unknown>) => Record<string, unknown>;
34
+ };
35
+ };
36
+ /**
37
+ * The main monitor loop for the Elys channel.
38
+ * Ensures device is registered, then connects to MQTT and dispatches
39
+ * incoming commands to the OpenClaw agent runtime using the standard
40
+ * channelRuntime dispatch API (supports streaming).
41
+ */
42
+ export declare function monitorElysProvider(opts: MonitorElysOpts): Promise<void>;
43
+ export {};
@@ -0,0 +1,131 @@
1
+ import { loadCredentials } from "./config.js";
2
+ import { registerDevice } from "./register.js";
3
+ import { ElysDeviceMQTTClient } from "./mqtt-client.js";
4
+ /**
5
+ * The main monitor loop for the Elys channel.
6
+ * Ensures device is registered, then connects to MQTT and dispatches
7
+ * incoming commands to the OpenClaw agent runtime using the standard
8
+ * channelRuntime dispatch API (supports streaming).
9
+ */
10
+ export async function monitorElysProvider(opts) {
11
+ const log = opts.runtime?.log ?? console.log;
12
+ const elysCfg = opts.config?.channels
13
+ ?.elys;
14
+ const gatewayUrl = elysCfg?.gatewayUrl ?? "http://localhost:8080";
15
+ // 1. Load or register credentials
16
+ let credentials = loadCredentials();
17
+ if (!credentials) {
18
+ const registerToken = elysCfg?.registerToken;
19
+ if (!registerToken) {
20
+ throw new Error("Elys plugin: no credentials found and no registerToken configured. " +
21
+ "Set channels.elys.registerToken in your config or run: npx openclaw-elys setup <gateway_url> <register_token>");
22
+ }
23
+ log("[elys] no credentials found, registering device...");
24
+ credentials = await registerDevice(gatewayUrl, registerToken);
25
+ log(`[elys] registered as device ${credentials.deviceId}`);
26
+ }
27
+ // 2. Connect MQTT
28
+ const mqttClient = new ElysDeviceMQTTClient(credentials, log);
29
+ // 3. Set up command handler
30
+ const channelRuntime = opts.channelRuntime;
31
+ const dispatchReply = channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
32
+ const finalizeCtx = channelRuntime?.reply?.finalizeInboundContext;
33
+ const commandHandler = async (cmd) => {
34
+ log(`[elys] executing command: ${cmd.command}`, cmd.args);
35
+ // If we have the full OpenClaw channelRuntime, use the standard dispatch path
36
+ if (dispatchReply && finalizeCtx) {
37
+ try {
38
+ let seq = 0;
39
+ let fullText = "";
40
+ // Build inbound context following OpenClaw protocol
41
+ const inboundCtx = finalizeCtx({
42
+ Body: formatCommandAsText(cmd),
43
+ BodyForAgent: formatCommandAsText(cmd),
44
+ RawBody: formatCommandAsText(cmd),
45
+ From: credentials.deviceId,
46
+ To: credentials.deviceId,
47
+ Surface: "elys",
48
+ Provider: "elys",
49
+ ChatType: "direct",
50
+ MessageSid: cmd.id,
51
+ AccountId: "default",
52
+ SessionKey: `elys:${credentials.deviceId}`,
53
+ CommandAuthorized: true,
54
+ });
55
+ await dispatchReply({
56
+ ctx: inboundCtx,
57
+ cfg: opts.config,
58
+ dispatcherOptions: {
59
+ // Block (streaming) delivery — send each chunk via MQTT
60
+ deliverBlock: async (payload) => {
61
+ if (payload.text) {
62
+ fullText += payload.text;
63
+ seq++;
64
+ mqttClient.publishStreamChunk(cmd.id, payload.text, seq, false);
65
+ log(`[elys] stream chunk #${seq}: ${payload.text.slice(0, 80)}...`);
66
+ }
67
+ },
68
+ // Final delivery
69
+ deliver: async (payload) => {
70
+ if (payload.text) {
71
+ fullText += payload.text;
72
+ }
73
+ // Send final stream marker
74
+ seq++;
75
+ mqttClient.publishStreamChunk(cmd.id, payload.text ?? "", seq, true);
76
+ log(`[elys] final reply delivered`);
77
+ },
78
+ // Tool result delivery (optional)
79
+ deliverToolResult: async (payload) => {
80
+ if (payload.text) {
81
+ seq++;
82
+ mqttClient.publishStreamChunk(cmd.id, payload.text, seq, false);
83
+ }
84
+ },
85
+ },
86
+ });
87
+ return {
88
+ id: cmd.id,
89
+ type: "result",
90
+ timestamp: Math.floor(Date.now() / 1000),
91
+ status: "success",
92
+ result: { text: fullText || "done" },
93
+ };
94
+ }
95
+ catch (err) {
96
+ return {
97
+ id: cmd.id,
98
+ type: "result",
99
+ timestamp: Math.floor(Date.now() / 1000),
100
+ status: "error",
101
+ error: err instanceof Error ? err.message : String(err),
102
+ };
103
+ }
104
+ }
105
+ // Fallback: echo the command back (no channelRuntime available)
106
+ return {
107
+ id: cmd.id,
108
+ type: "result",
109
+ timestamp: Math.floor(Date.now() / 1000),
110
+ status: "success",
111
+ result: { text: `command received: ${cmd.command}` },
112
+ };
113
+ };
114
+ mqttClient.setCommandHandler(commandHandler);
115
+ await mqttClient.connect(opts.abortSignal);
116
+ // 4. Keep alive until abort
117
+ if (opts.abortSignal) {
118
+ await new Promise((resolve) => {
119
+ opts.abortSignal.addEventListener("abort", () => resolve());
120
+ });
121
+ }
122
+ }
123
+ function formatCommandAsText(cmd) {
124
+ const parts = [cmd.command];
125
+ if (cmd.args) {
126
+ for (const [k, v] of Object.entries(cmd.args)) {
127
+ parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
128
+ }
129
+ }
130
+ return parts.join(" ");
131
+ }
@@ -0,0 +1,24 @@
1
+ import type { DeviceCredentials, CommandMessage, ResultMessage } from "./types.js";
2
+ export type CommandHandler = (cmd: CommandMessage) => Promise<ResultMessage>;
3
+ /**
4
+ * Manages the MQTT connection to EMQX for a single device.
5
+ * Subscribes to elys/down/{device_id}, publishes to elys/up/{device_id}.
6
+ */
7
+ export declare class ElysDeviceMQTTClient {
8
+ private client;
9
+ private credentials;
10
+ private commandHandler;
11
+ private log;
12
+ private consecutiveFailures;
13
+ private static readonly MAX_FAILURES_BEFORE_REVOKE_WARNING;
14
+ constructor(credentials: DeviceCredentials, log?: (...args: unknown[]) => void);
15
+ setCommandHandler(handler: CommandHandler): void;
16
+ connect(abortSignal?: AbortSignal): Promise<void>;
17
+ disconnect(): void;
18
+ /** Send a stream chunk (for streaming AI responses) */
19
+ publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean): void;
20
+ private handleMessage;
21
+ private publishAck;
22
+ private publishResult;
23
+ private publish;
24
+ }
@@ -0,0 +1,164 @@
1
+ import mqtt from "mqtt";
2
+ /**
3
+ * Manages the MQTT connection to EMQX for a single device.
4
+ * Subscribes to elys/down/{device_id}, publishes to elys/up/{device_id}.
5
+ */
6
+ export class ElysDeviceMQTTClient {
7
+ client = null;
8
+ credentials;
9
+ commandHandler = null;
10
+ log;
11
+ consecutiveFailures = 0;
12
+ static MAX_FAILURES_BEFORE_REVOKE_WARNING = 3;
13
+ constructor(credentials, log) {
14
+ this.credentials = credentials;
15
+ this.log = log ?? console.log;
16
+ }
17
+ setCommandHandler(handler) {
18
+ this.commandHandler = handler;
19
+ }
20
+ async connect(abortSignal) {
21
+ const { deviceId, deviceToken, mqttBroker } = this.credentials;
22
+ this.log(`[elys] connecting to MQTT broker: ${mqttBroker}`);
23
+ this.client = mqtt.connect(mqttBroker, {
24
+ clientId: deviceId,
25
+ username: deviceToken,
26
+ password: deviceToken,
27
+ reconnectPeriod: 5000,
28
+ keepalive: 30,
29
+ rejectUnauthorized: true, // verify TLS cert
30
+ });
31
+ this.client.on("connect", () => {
32
+ this.log(`[elys] MQTT connected as ${deviceId}`);
33
+ this.consecutiveFailures = 0;
34
+ const downTopic = `elys/down/${deviceId}`;
35
+ this.client.subscribe(downTopic, { qos: 1 }, (err) => {
36
+ if (err) {
37
+ this.log(`[elys] failed to subscribe to ${downTopic}:`, err);
38
+ }
39
+ else {
40
+ this.log(`[elys] subscribed to ${downTopic}`);
41
+ }
42
+ });
43
+ });
44
+ this.client.on("message", (_topic, payload) => {
45
+ this.handleMessage(payload).catch((err) => {
46
+ this.log("[elys] error handling message:", err);
47
+ });
48
+ });
49
+ this.client.on("reconnect", () => {
50
+ this.consecutiveFailures++;
51
+ if (this.consecutiveFailures >= ElysDeviceMQTTClient.MAX_FAILURES_BEFORE_REVOKE_WARNING) {
52
+ this.log(`[elys] WARNING: MQTT reconnect failed ${this.consecutiveFailures} times. ` +
53
+ `Your device credentials may have been revoked. ` +
54
+ `Please re-register: npx openclaw-elys setup <gateway_url> <register_token>`);
55
+ }
56
+ else {
57
+ this.log("[elys] MQTT reconnecting...");
58
+ }
59
+ });
60
+ this.client.on("error", (err) => {
61
+ // MQTT auth failures indicate revoked credentials
62
+ if (err.message.includes("Not authorized") || err.message.includes("Connection refused")) {
63
+ this.log(`[elys] ERROR: MQTT authentication failed — device credentials may have been revoked. ` +
64
+ `Please re-register: npx openclaw-elys setup <gateway_url> <register_token>`);
65
+ }
66
+ else {
67
+ this.log("[elys] MQTT error:", err.message);
68
+ }
69
+ });
70
+ this.client.on("close", () => {
71
+ this.log("[elys] MQTT connection closed");
72
+ });
73
+ // Handle abort signal for graceful shutdown
74
+ if (abortSignal) {
75
+ abortSignal.addEventListener("abort", () => {
76
+ this.disconnect();
77
+ });
78
+ }
79
+ // Wait for initial connection
80
+ return new Promise((resolve, reject) => {
81
+ const onConnect = () => {
82
+ cleanup();
83
+ resolve();
84
+ };
85
+ const onError = (err) => {
86
+ cleanup();
87
+ reject(err);
88
+ };
89
+ const cleanup = () => {
90
+ this.client?.removeListener("connect", onConnect);
91
+ this.client?.removeListener("error", onError);
92
+ };
93
+ this.client.once("connect", onConnect);
94
+ this.client.once("error", onError);
95
+ });
96
+ }
97
+ disconnect() {
98
+ if (this.client) {
99
+ this.client.end(true);
100
+ this.client = null;
101
+ this.log("[elys] MQTT disconnected");
102
+ }
103
+ }
104
+ /** Send a stream chunk (for streaming AI responses) */
105
+ publishStreamChunk(commandId, chunk, seq, done) {
106
+ const msg = {
107
+ id: commandId,
108
+ type: "stream",
109
+ timestamp: Math.floor(Date.now() / 1000),
110
+ chunk,
111
+ seq,
112
+ done,
113
+ };
114
+ this.publish(msg);
115
+ }
116
+ async handleMessage(payload) {
117
+ const raw = JSON.parse(payload.toString());
118
+ if (raw.type !== "command") {
119
+ this.log(`[elys] ignoring non-command message type: ${raw.type}`);
120
+ return;
121
+ }
122
+ this.log(`[elys] received command: ${raw.command} (id: ${raw.id})`);
123
+ // Send ACK immediately
124
+ this.publishAck(raw.id);
125
+ // Execute command
126
+ if (this.commandHandler) {
127
+ try {
128
+ const result = await this.commandHandler(raw);
129
+ this.publishResult(result);
130
+ }
131
+ catch (err) {
132
+ const errMsg = err instanceof Error ? err.message : String(err);
133
+ this.publishResult({
134
+ id: raw.id,
135
+ type: "result",
136
+ timestamp: Date.now() / 1000,
137
+ status: "error",
138
+ error: errMsg,
139
+ });
140
+ }
141
+ }
142
+ }
143
+ publishAck(commandId) {
144
+ const msg = {
145
+ id: commandId,
146
+ type: "ack",
147
+ timestamp: Math.floor(Date.now() / 1000),
148
+ };
149
+ this.publish(msg);
150
+ }
151
+ publishResult(msg) {
152
+ this.publish(msg);
153
+ }
154
+ publish(msg) {
155
+ if (!this.client)
156
+ return;
157
+ const topic = `elys/up/${this.credentials.deviceId}`;
158
+ this.client.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
159
+ if (err) {
160
+ this.log(`[elys] failed to publish to ${topic}:`, err);
161
+ }
162
+ });
163
+ }
164
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Send a text result back to the gateway via MQTT upstream.
3
+ * This is used by the outbound adapter when OpenClaw agent generates a response.
4
+ *
5
+ * In this channel the outbound is handled primarily through the MQTT result messages
6
+ * inside the monitor's command handler. This outbound adapter is the HTTP fallback
7
+ * for cases where we need to push a proactive message.
8
+ */
9
+ export declare function sendTextToGateway(params: {
10
+ gatewayUrl: string;
11
+ text: string;
12
+ deviceId?: string;
13
+ }): Promise<{
14
+ messageId: string;
15
+ }>;
16
+ export declare const elysOutbound: {
17
+ deliveryMode: "direct";
18
+ textChunkLimit: number;
19
+ sendText: (ctx: {
20
+ cfg: Record<string, unknown>;
21
+ to: string;
22
+ text: string;
23
+ accountId?: string | null;
24
+ }) => Promise<{
25
+ messageId: string;
26
+ channel: string;
27
+ }>;
28
+ };
@@ -0,0 +1,44 @@
1
+ import { loadCredentials } from "./config.js";
2
+ /**
3
+ * Send a text result back to the gateway via MQTT upstream.
4
+ * This is used by the outbound adapter when OpenClaw agent generates a response.
5
+ *
6
+ * In this channel the outbound is handled primarily through the MQTT result messages
7
+ * inside the monitor's command handler. This outbound adapter is the HTTP fallback
8
+ * for cases where we need to push a proactive message.
9
+ */
10
+ export async function sendTextToGateway(params) {
11
+ const credentials = loadCredentials();
12
+ if (!credentials) {
13
+ throw new Error("Elys plugin: device not registered");
14
+ }
15
+ const deviceId = params.deviceId ?? credentials.deviceId;
16
+ const resp = await fetch(`${params.gatewayUrl}/api/v1/message/send`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({
20
+ device_id: deviceId,
21
+ id: `msg_${Date.now()}`,
22
+ command: "elys.reply",
23
+ args: { text: params.text },
24
+ }),
25
+ });
26
+ if (!resp.ok) {
27
+ throw new Error(`Failed to send message: ${resp.status}`);
28
+ }
29
+ return { messageId: `msg_${Date.now()}` };
30
+ }
31
+ export const elysOutbound = {
32
+ deliveryMode: "direct",
33
+ textChunkLimit: 4000,
34
+ sendText: async (ctx) => {
35
+ const elysCfg = ctx.cfg?.channels?.elys;
36
+ const gatewayUrl = elysCfg?.gatewayUrl ?? "http://localhost:8080";
37
+ const result = await sendTextToGateway({
38
+ gatewayUrl,
39
+ text: ctx.text,
40
+ deviceId: ctx.to,
41
+ });
42
+ return { channel: "elys", ...result };
43
+ },
44
+ };
@@ -0,0 +1,6 @@
1
+ import type { DeviceCredentials } from "./types.js";
2
+ /**
3
+ * Register this device with the Elys gateway.
4
+ * Saves credentials to ~/.elys/config.json on success.
5
+ */
6
+ export declare function registerDevice(gatewayUrl: string, registerToken: string): Promise<DeviceCredentials>;
@@ -0,0 +1,33 @@
1
+ import os from "os";
2
+ import { saveCredentials } from "./config.js";
3
+ /**
4
+ * Register this device with the Elys gateway.
5
+ * Saves credentials to ~/.elys/config.json on success.
6
+ */
7
+ export async function registerDevice(gatewayUrl, registerToken) {
8
+ const body = {
9
+ register_token: registerToken,
10
+ hostname: os.hostname(),
11
+ os: process.platform,
12
+ os_version: os.release(),
13
+ arch: os.arch(),
14
+ client_version: "1.0.0",
15
+ };
16
+ const resp = await fetch(`${gatewayUrl}/api/v1/device/register`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(body),
20
+ });
21
+ if (!resp.ok) {
22
+ const text = await resp.text();
23
+ throw new Error(`Device registration failed (${resp.status}): ${text}`);
24
+ }
25
+ const data = (await resp.json());
26
+ const credentials = {
27
+ deviceId: data.device_id,
28
+ deviceToken: data.device_token,
29
+ mqttBroker: data.mqtt_broker,
30
+ };
31
+ saveCredentials(credentials, gatewayUrl);
32
+ return credentials;
33
+ }
@@ -0,0 +1,42 @@
1
+ export interface ElysConfig {
2
+ enabled?: boolean;
3
+ gatewayUrl?: string;
4
+ registerToken?: string;
5
+ }
6
+ export interface DeviceCredentials {
7
+ deviceId: string;
8
+ deviceToken: string;
9
+ mqttBroker: string;
10
+ }
11
+ export interface ResolvedElysAccount {
12
+ accountId: string;
13
+ enabled: boolean;
14
+ configured: boolean;
15
+ gatewayUrl: string;
16
+ credentials: DeviceCredentials | null;
17
+ }
18
+ export interface MQTTBaseMessage {
19
+ id: string;
20
+ type: "command" | "ack" | "result" | "stream";
21
+ timestamp: number;
22
+ }
23
+ export interface CommandMessage extends MQTTBaseMessage {
24
+ type: "command";
25
+ command: string;
26
+ args?: Record<string, unknown>;
27
+ }
28
+ export interface AckMessage extends MQTTBaseMessage {
29
+ type: "ack";
30
+ }
31
+ export interface StreamMessage extends MQTTBaseMessage {
32
+ type: "stream";
33
+ chunk: string;
34
+ done: boolean;
35
+ seq: number;
36
+ }
37
+ export interface ResultMessage extends MQTTBaseMessage {
38
+ type: "result";
39
+ status: "success" | "error";
40
+ result?: Record<string, unknown>;
41
+ error?: string;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "openclaw-elys",
3
+ "channels": ["elys"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "gatewayUrl": {
9
+ "type": "string"
10
+ },
11
+ "registerToken": {
12
+ "type": "string"
13
+ }
14
+ }
15
+ },
16
+ "uiHints": {
17
+ "gatewayUrl": {
18
+ "label": "Gateway URL",
19
+ "placeholder": "https://your-gateway.com"
20
+ },
21
+ "registerToken": {
22
+ "label": "Register Token",
23
+ "placeholder": "reg_xxx (one-time, get from Elys App)",
24
+ "sensitive": true
25
+ }
26
+ }
27
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "openclaw-elys",
3
+ "version": "1.1.0",
4
+ "description": "OpenClaw Elys channel plugin — connects to Elys App",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "openclaw-elys": "dist/src/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "openclaw.plugin.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "typecheck": "tsc --noEmit",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "mqtt": "^5.10.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^25.3.5",
25
+ "typescript": "^5.9.3"
26
+ },
27
+ "openclaw": {
28
+ "extensions": [
29
+ "./dist/index.js"
30
+ ],
31
+ "channel": {
32
+ "id": "elys",
33
+ "label": "Elys",
34
+ "selectionLabel": "Elys App",
35
+ "docsPath": "/channels/elys",
36
+ "docsLabel": "elys",
37
+ "blurb": "Elys App integration",
38
+ "order": 80
39
+ },
40
+ "install": {
41
+ "npmSpec": "openclaw-elys",
42
+ "localPath": "plugin",
43
+ "defaultChoice": "npm"
44
+ }
45
+ }
46
+ }