@eclaw/openclaw-channel 1.0.18 → 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.
package/dist/gateway.js CHANGED
@@ -1,57 +1,51 @@
1
+ import { createServer } from 'node:http';
1
2
  import { randomBytes } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { homedir } from 'node:os';
5
3
  import { resolveAccount } from './config.js';
6
4
  import { EClawClient } from './client.js';
7
5
  import { setClient } from './outbound.js';
8
6
  import { createWebhookHandler } from './webhook-handler.js';
9
- import { registerWebhookToken, unregisterWebhookToken } from './webhook-registry.js';
10
- /**
11
- * Resolve account from ctx.
12
- *
13
- * OpenClaw may pass a pre-resolved account object in ctx.account,
14
- * or an empty config. Fall back to reading openclaw.json from disk.
15
- */
16
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
- function resolveAccountFromCtx(ctx) {
18
- // Preferred: OpenClaw passes the resolved account in ctx.account
19
- if (ctx.account?.apiKey) {
20
- return {
21
- enabled: ctx.account.enabled ?? true,
22
- apiKey: ctx.account.apiKey,
23
- apiSecret: ctx.account.apiSecret,
24
- apiBase: (ctx.account.apiBase ?? 'https://eclawbot.com').replace(/\/$/, ''),
25
- entityId: ctx.account.entityId, // undefined = auto-select
26
- botName: ctx.account.botName,
27
- webhookUrl: ctx.account.webhookUrl,
28
- };
29
- }
30
- // Fallback: read config from disk (OpenClaw passes empty config object)
31
- const configPath = process.env.OPENCLAW_CONFIG_PATH
32
- || join(homedir(), '.openclaw', 'openclaw.json');
33
- let fullConfig = {};
34
- try {
35
- fullConfig = JSON.parse(readFileSync(configPath, 'utf8'));
36
- }
37
- catch { /* ignore */ }
38
- return resolveAccount(fullConfig, ctx.accountId ?? ctx.account?.accountId);
7
+ // ── Reconnect / health-check constants ───────────────────────────────────────
8
+ const HEALTH_CHECK_INTERVAL_MS = 60_000; // re-register every 60 s to stay live
9
+ const BACKOFF_INITIAL_MS = 5_000; // first retry after 5 s
10
+ const BACKOFF_MAX_MS = 300_000; // cap at 5 min
11
+ const BACKOFF_MULTIPLIER = 2;
12
+ /** Build callbackUrl fresh from env every time never use a stale closure value. */
13
+ function buildCallbackUrl(actualPort) {
14
+ const publicUrl = process.env.ECLAW_WEBHOOK_URL?.replace(/\/$/, '');
15
+ const base = publicUrl || `http://localhost:${actualPort}`;
16
+ return `${base}/eclaw-webhook`;
17
+ }
18
+ /** Sleep ms, but resolve early if abortSignal fires. */
19
+ function sleep(ms, signal) {
20
+ return new Promise((resolve) => {
21
+ if (signal?.aborted) {
22
+ resolve();
23
+ return;
24
+ }
25
+ const timer = setTimeout(resolve, ms);
26
+ signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
27
+ });
28
+ }
29
+ /** Add ±20 % random jitter to reduce thundering-herd on reconnect. */
30
+ function jitter(ms) {
31
+ return Math.floor(ms * (0.8 + Math.random() * 0.4));
39
32
  }
40
33
  /**
41
34
  * Gateway lifecycle: start an E-Claw account.
42
35
  *
43
- * 1. Resolve credentials from ctx.account or disk
44
- * 2. Register a per-session handler in the webhook-registry (served by the
45
- * main OpenClaw gateway HTTP server at /eclaw-webhook no separate port)
46
- * 3. Register callback URL with E-Claw backend
36
+ * 1. Initialize HTTP client with channel API credentials
37
+ * 2. Start a local HTTP server to receive webhook callbacks
38
+ * 3. Register callback URL with E-Claw backend (with exponential-backoff retry)
47
39
  * 4. Auto-bind entity if not already bound
48
- * 5. Keep the promise alive until abort signal fires
40
+ * 5. Periodically re-register to keep callback URL live (health check)
41
+ * 6. On health-check failure, reconnect with exponential backoff
42
+ * 7. Keep the promise alive until abort signal fires
49
43
  */
50
44
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
45
  export async function startAccount(ctx) {
52
- const accountId = ctx.accountId ?? ctx.account?.accountId ?? 'default';
53
- const account = resolveAccountFromCtx(ctx);
54
- if (!account.enabled || !account.apiKey) {
46
+ const { accountId, config } = ctx;
47
+ const account = resolveAccount(config, accountId);
48
+ if (!account.enabled || !account.apiKey || !account.apiSecret) {
55
49
  console.log(`[E-Claw] Account ${accountId} disabled or missing credentials, skipping`);
56
50
  return;
57
51
  }
@@ -60,59 +54,141 @@ export async function startAccount(ctx) {
60
54
  setClient(accountId, client);
61
55
  // Generate per-session callback token
62
56
  const callbackToken = randomBytes(32).toString('hex');
63
- // Webhook URL: account config > env var > warn
64
- const publicUrl = account.webhookUrl?.replace(/\/$/, '')
65
- || process.env.ECLAW_WEBHOOK_URL?.replace(/\/$/, '');
57
+ // Determine webhook configuration
58
+ const webhookPort = parseInt(process.env.ECLAW_WEBHOOK_PORT || '0') || 0;
59
+ const publicUrl = process.env.ECLAW_WEBHOOK_URL;
66
60
  if (!publicUrl) {
67
- console.warn('[E-Claw] Webhook URL not configured. ' +
68
- 'Run "openclaw configure" and enter your OpenClaw public URL, ' +
69
- 'or set ECLAW_WEBHOOK_URL env var. ' +
70
- 'Example: https://your-openclaw-domain.com');
61
+ console.warn('[E-Claw] ECLAW_WEBHOOK_URL not set. Set this to your public-facing URL ' +
62
+ 'so E-Claw can send messages to this plugin. Example: https://my-openclaw.example.com');
71
63
  }
72
- // The callback URL points to /eclaw-webhook on the main gateway HTTP server
73
- const callbackUrl = `${publicUrl || 'http://localhost'}/eclaw-webhook`;
74
- // Register handler in the per-token registry
75
- // Pass ctx.cfg so the handler can dispatch to the correct OpenClaw agent
76
- const handler = createWebhookHandler(callbackToken, accountId, ctx.cfg);
77
- registerWebhookToken(callbackToken, accountId, handler);
78
- console.log(`[E-Claw] Webhook registered at: ${callbackUrl}`);
79
- try {
80
- // Register callback with E-Claw backend
81
- const regData = await client.registerCallback(callbackUrl, callbackToken);
82
- console.log(`[E-Claw] Registered with E-Claw. Device: ${regData.deviceId}, Entities: ${regData.entities.length}`);
83
- // Bind entity via channel API.
84
- // /api/channel/bind is idempotent for the same channel account:
85
- // - Not bound → binds fresh, returns new botSecret
86
- // - Already bound via this channel account → returns existing botSecret (reconnect)
87
- // - Bound via different method → throws error (user must unbind first)
88
- // entityId is omitted here so the server auto-selects the best slot
89
- const bindData = await client.bindEntity(account.entityId, account.botName);
90
- const assignedEntityId = bindData.entityId;
91
- const entityInfo = regData.entities.find(e => e.entityId === assignedEntityId);
92
- const wasAlreadyBound = entityInfo?.isBound ?? false;
93
- console.log(wasAlreadyBound
94
- ? `[E-Claw] Entity ${assignedEntityId} reconnected (existing channel binding), publicCode: ${bindData.publicCode}`
95
- : `[E-Claw] Entity ${assignedEntityId} bound, publicCode: ${bindData.publicCode}`);
96
- console.log(`[E-Claw] Account ${accountId} ready!`);
97
- }
98
- catch (err) {
99
- console.error(`[E-Claw] Setup failed for account ${accountId}:`, err);
100
- unregisterWebhookToken(callbackToken);
101
- return;
102
- }
103
- // Keep the promise alive until abort signal fires
104
- return new Promise((resolve) => {
105
- const signal = ctx.abortSignal;
106
- if (signal) {
107
- signal.addEventListener('abort', () => {
108
- console.log(`[E-Claw] Shutting down account ${accountId}`);
109
- client.unregisterCallback().catch(() => { });
110
- unregisterWebhookToken(callbackToken);
111
- resolve();
64
+ // Create webhook handler
65
+ const handler = createWebhookHandler(callbackToken, accountId);
66
+ // Parse JSON body for incoming requests
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ const requestHandler = (req, res) => {
69
+ if (req.method === 'POST' && req.url?.startsWith('/eclaw-webhook')) {
70
+ let body = '';
71
+ req.on('data', (chunk) => { body += chunk.toString(); });
72
+ req.on('end', () => {
73
+ try {
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ req.body = JSON.parse(body);
76
+ }
77
+ catch {
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ req.body = {};
80
+ }
81
+ handler(req, res);
112
82
  });
113
83
  }
114
84
  else {
115
- resolve();
85
+ res.writeHead(404);
86
+ res.end('Not Found');
116
87
  }
88
+ };
89
+ const server = createServer(requestHandler);
90
+ return new Promise((resolve) => {
91
+ server.listen(webhookPort, async () => {
92
+ const addr = server.address();
93
+ const actualPort = typeof addr === 'object' && addr ? addr.port : webhookPort;
94
+ console.log(`[E-Claw] Webhook server listening on port ${actualPort}`);
95
+ const signal = ctx.abortSignal;
96
+ // ── Core setup: register callback + bind entity ─────────────────────
97
+ /**
98
+ * One full register+bind cycle. Returns true on success, false on failure.
99
+ * Reads ECLAW_WEBHOOK_URL fresh every call so a corrected env var takes
100
+ * effect automatically on the next reconnect.
101
+ */
102
+ async function attemptSetup() {
103
+ const callbackUrl = buildCallbackUrl(actualPort);
104
+ console.log(`[E-Claw][${accountId}] Registering callback: ${callbackUrl}`);
105
+ try {
106
+ const regData = await client.registerCallback(callbackUrl, callbackToken);
107
+ console.log(`[E-Claw][${accountId}] Registered. Device: ${regData.deviceId}, Entities: ${regData.entities.length}`);
108
+ const entity = regData.entities.find(e => e.entityId === account.entityId);
109
+ if (!entity?.isBound) {
110
+ console.log(`[E-Claw][${accountId}] Entity ${account.entityId} not bound, binding...`);
111
+ const bindData = await client.bindEntity(account.entityId, account.botName);
112
+ console.log(`[E-Claw][${accountId}] Bound entity ${account.entityId}, publicCode: ${bindData.publicCode}`);
113
+ }
114
+ else {
115
+ console.log(`[E-Claw][${accountId}] Entity ${account.entityId} already bound`);
116
+ // For already-bound entities, retrieve credentials via bind endpoint
117
+ const bindData = await client.bindEntity(account.entityId, account.botName);
118
+ console.log(`[E-Claw][${accountId}] Retrieved credentials for entity ${account.entityId}`);
119
+ void bindData; // credentials stored in client
120
+ }
121
+ return true;
122
+ }
123
+ catch (err) {
124
+ console.error(`[E-Claw][${accountId}] Setup attempt failed:`, err);
125
+ return false;
126
+ }
127
+ }
128
+ // ── Initial connect with exponential backoff ────────────────────────
129
+ let backoffMs = BACKOFF_INITIAL_MS;
130
+ let attempt = 0;
131
+ while (!signal?.aborted) {
132
+ attempt++;
133
+ const ok = await attemptSetup();
134
+ if (ok) {
135
+ console.log(`[E-Claw][${accountId}] Account ready! (attempt #${attempt})`);
136
+ break;
137
+ }
138
+ const delay = jitter(Math.min(backoffMs, BACKOFF_MAX_MS));
139
+ console.warn(`[E-Claw][${accountId}] Retrying in ${Math.round(delay / 1000)}s (attempt #${attempt})...`);
140
+ await sleep(delay, signal);
141
+ backoffMs = Math.min(backoffMs * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
142
+ }
143
+ if (signal?.aborted)
144
+ return;
145
+ // ── Periodic health check + auto-reconnect ──────────────────────────
146
+ // Re-register every 60 s. If it fails, enter a reconnect backoff loop.
147
+ // Guard flag prevents concurrent reconnect loops from stacking.
148
+ let isReconnecting = false;
149
+ async function runHealthCheck() {
150
+ if (isReconnecting)
151
+ return; // already reconnecting, skip this tick
152
+ const callbackUrl = buildCallbackUrl(actualPort);
153
+ try {
154
+ await client.registerCallback(callbackUrl, callbackToken);
155
+ // Silent success — no log spam when healthy
156
+ }
157
+ catch (err) {
158
+ if (isReconnecting)
159
+ return;
160
+ isReconnecting = true;
161
+ console.warn(`[E-Claw][${accountId}] Health check failed — starting reconnect loop:`, err);
162
+ let reconnBackoff = BACKOFF_INITIAL_MS;
163
+ let reconnAttempt = 0;
164
+ while (!signal?.aborted) {
165
+ reconnAttempt++;
166
+ const delay = jitter(Math.min(reconnBackoff, BACKOFF_MAX_MS));
167
+ console.warn(`[E-Claw][${accountId}] Reconnect attempt #${reconnAttempt} in ${Math.round(delay / 1000)}s...`);
168
+ await sleep(delay, signal);
169
+ if (signal?.aborted)
170
+ break;
171
+ const recovered = await attemptSetup();
172
+ if (recovered) {
173
+ console.log(`[E-Claw][${accountId}] Reconnected successfully after ${reconnAttempt} attempt(s)!`);
174
+ break;
175
+ }
176
+ reconnBackoff = Math.min(reconnBackoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
177
+ }
178
+ isReconnecting = false;
179
+ }
180
+ }
181
+ const healthTimer = setInterval(() => { void runHealthCheck(); }, HEALTH_CHECK_INTERVAL_MS);
182
+ // ── Cleanup on abort ────────────────────────────────────────────────
183
+ if (signal) {
184
+ signal.addEventListener('abort', () => {
185
+ console.log(`[E-Claw][${accountId}] Shutting down account`);
186
+ clearInterval(healthTimer);
187
+ client.unregisterCallback().catch(() => { });
188
+ server.close();
189
+ resolve();
190
+ });
191
+ }
192
+ });
117
193
  });
118
194
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,24 @@
1
+ /**
2
+ * E-Claw Channel Plugin for OpenClaw.
3
+ *
4
+ * Installation:
5
+ * npm install @eclaw/openclaw-channel
6
+ *
7
+ * Configuration (config.yaml):
8
+ * channels:
9
+ * eclaw:
10
+ * accounts:
11
+ * default:
12
+ * apiKey: "eck_..."
13
+ * apiSecret: "ecs_..."
14
+ * apiBase: "https://eclawbot.com"
15
+ * entityId: 0
16
+ * botName: "My Bot"
17
+ *
18
+ * Environment variables:
19
+ * ECLAW_WEBHOOK_URL - Public URL for receiving callbacks (required for production)
20
+ * ECLAW_WEBHOOK_PORT - Port for webhook server (default: random)
21
+ */
1
22
  declare const plugin: {
2
23
  id: string;
3
24
  name: string;
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { setPluginRuntime } from './runtime.js';
2
2
  import { eclawChannel } from './channel.js';
3
- import { dispatchWebhook } from './webhook-registry.js';
4
3
  /**
5
4
  * E-Claw Channel Plugin for OpenClaw.
6
5
  *
@@ -13,52 +12,24 @@ import { dispatchWebhook } from './webhook-registry.js';
13
12
  * accounts:
14
13
  * default:
15
14
  * apiKey: "eck_..."
15
+ * apiSecret: "ecs_..."
16
16
  * apiBase: "https://eclawbot.com"
17
17
  * entityId: 0
18
18
  * botName: "My Bot"
19
- * webhookUrl: "https://your-openclaw-domain.com"
20
19
  *
21
- * The plugin registers /eclaw-webhook on the main OpenClaw gateway HTTP server,
22
- * so no separate port is needed. Set webhookUrl to your OpenClaw public URL
23
- * (e.g. https://eclaw2.zeabur.app) so E-Claw knows where to push messages.
20
+ * Environment variables:
21
+ * ECLAW_WEBHOOK_URL - Public URL for receiving callbacks (required for production)
22
+ * ECLAW_WEBHOOK_PORT - Port for webhook server (default: random)
24
23
  */
25
- /** Parse JSON body from a raw incoming request */
26
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
- function parseBody(req) {
28
- return new Promise((resolve) => {
29
- let body = '';
30
- req.on('data', (chunk) => { body += chunk.toString(); });
31
- req.on('end', () => {
32
- try {
33
- req.body = JSON.parse(body);
34
- }
35
- catch {
36
- req.body = {};
37
- }
38
- resolve();
39
- });
40
- });
41
- }
42
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
25
  const plugin = {
44
- id: 'openclaw-channel',
26
+ id: 'eclaw',
45
27
  name: 'E-Claw',
46
- description: 'E-Claw AI chat platform channel plugin',
28
+ description: 'E-Claw AI Agent collaboration channel plugin',
47
29
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
30
  register(api) {
49
31
  console.log('[E-Claw] Plugin loaded');
50
32
  setPluginRuntime(api.runtime);
51
- // Register /eclaw-webhook on the main OpenClaw gateway HTTP server.
52
- // Token-based routing is handled in dispatchWebhook() — each account
53
- // registers its own handler keyed by a random per-session Bearer token.
54
- api.registerHttpRoute({
55
- path: '/eclaw-webhook',
56
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
- handler: async (req, res) => {
58
- await parseBody(req);
59
- await dispatchWebhook(req, res);
60
- },
61
- });
62
33
  api.registerChannel({ plugin: eclawChannel });
63
34
  },
64
35
  };
@@ -1,6 +1,4 @@
1
1
  import { EClawClient } from './client.js';
2
- export declare function setActiveEvent(accountId: string, event: string): void;
3
- export declare function clearActiveEvent(accountId: string): void;
4
2
  export declare function setClient(accountId: string, client: EClawClient): void;
5
3
  export declare function getClient(accountId: string): EClawClient | undefined;
6
4
  /** OpenClaw outbound: send text message to E-Claw user */
package/dist/outbound.js CHANGED
@@ -1,13 +1,5 @@
1
1
  /** Client instances keyed by accountId */
2
2
  const clients = new Map();
3
- /** Track current inbound event type per account to suppress duplicate sendMessage calls */
4
- const activeEvent = new Map();
5
- export function setActiveEvent(accountId, event) {
6
- activeEvent.set(accountId, event);
7
- }
8
- export function clearActiveEvent(accountId) {
9
- activeEvent.delete(accountId);
10
- }
11
3
  export function setClient(accountId, client) {
12
4
  clients.set(accountId, client);
13
5
  }
@@ -18,11 +10,6 @@ export function getClient(accountId) {
18
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
11
  export async function sendText(ctx) {
20
12
  const accountId = ctx.accountId ?? 'default';
21
- // Suppress duplicate delivery for bot-to-bot events — webhook-handler's deliver handles these
22
- const event = activeEvent.get(accountId) ?? 'message';
23
- if (event === 'entity_message' || event === 'broadcast') {
24
- return { channel: 'eclaw', messageId: '', chatId: '' };
25
- }
26
13
  const client = clients.get(accountId);
27
14
  if (!client) {
28
15
  return { channel: 'eclaw', messageId: '', chatId: '' };
@@ -45,11 +32,6 @@ export async function sendText(ctx) {
45
32
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
33
  export async function sendMedia(ctx) {
47
34
  const accountId = ctx.accountId ?? 'default';
48
- // Suppress duplicate delivery for bot-to-bot events — webhook-handler's deliver handles these
49
- const event = activeEvent.get(accountId) ?? 'message';
50
- if (event === 'entity_message' || event === 'broadcast') {
51
- return { channel: 'eclaw', messageId: '', chatId: '' };
52
- }
53
35
  const client = clients.get(accountId);
54
36
  if (!client) {
55
37
  return { channel: 'eclaw', messageId: '', chatId: '' };
package/dist/types.d.ts CHANGED
@@ -2,19 +2,10 @@
2
2
  export interface EClawAccountConfig {
3
3
  enabled: boolean;
4
4
  apiKey: string;
5
- apiSecret?: string;
5
+ apiSecret: string;
6
6
  apiBase: string;
7
- entityId?: number;
7
+ entityId: number;
8
8
  botName?: string;
9
- webhookUrl?: string;
10
- }
11
- /** Context block injected by E-Claw server for Channel Bot parity with Traditional Bot */
12
- export interface EClawContext {
13
- b2bRemaining?: number;
14
- b2bMax?: number;
15
- expectsReply?: boolean;
16
- missionHints?: string;
17
- silentToken?: string;
18
9
  }
19
10
  /** Inbound message from E-Claw callback webhook */
20
11
  export interface EClawInboundMessage {
@@ -33,7 +24,6 @@ export interface EClawInboundMessage {
33
24
  fromEntityId?: number;
34
25
  fromCharacter?: string;
35
26
  fromPublicCode?: string;
36
- eclaw_context?: EClawContext;
37
27
  }
38
28
  /** Entity info returned by channel register */
39
29
  export interface EClawEntityInfo {
@@ -59,13 +49,6 @@ export interface BindResponse {
59
49
  publicCode: string;
60
50
  bindingType: string;
61
51
  }
62
- /** Error response when all entity slots are full */
63
- export interface SlotsFullError {
64
- success: false;
65
- message: string;
66
- entities: EClawEntityInfo[];
67
- hint: string;
68
- }
69
52
  /** Response from POST /api/channel/message */
70
53
  export interface MessageResponse {
71
54
  success: boolean;
@@ -1,18 +1,7 @@
1
1
  /**
2
2
  * Create an HTTP request handler for inbound messages from E-Claw.
3
3
  *
4
- * Handles three event types:
5
- * - 'message' → Normal human message; reply via sendMessage()
6
- * - 'entity_message' → Bot-to-bot speak-to; reply via sendMessage() + speakTo(fromEntityId)
7
- * - 'broadcast' → Broadcast from another entity; reply via sendMessage() + speakTo(fromEntityId)
8
- *
9
- * The `deliver` callback routes AI response to the correct E-Claw endpoint
10
- * based on the inbound event type.
11
- *
12
- * Channel Bot Context Parity v1.0.17:
13
- * - Bot-to-bot / broadcast now calls sendMessage() to update own wallpaper AND speakTo() to reply
14
- * - Quota awareness via eclaw_context.b2bRemaining / b2bMax
15
- * - Mission context via eclaw_context.missionHints
16
- * - Silent suppression via silentToken (default "[SILENT]")
4
+ * When a user sends a message on E-Claw, the backend POSTs structured JSON
5
+ * to this webhook. We normalize it and dispatch to the OpenClaw agent.
17
6
  */
18
- export declare function createWebhookHandler(expectedToken: string, accountId: string, cfg: any): (req: any, res: any) => Promise<void>;
7
+ export declare function createWebhookHandler(expectedToken: string, accountId: string): (req: any, res: any) => Promise<void>;
@@ -1,26 +1,11 @@
1
1
  import { getPluginRuntime } from './runtime.js';
2
- import { getClient, setActiveEvent, clearActiveEvent } from './outbound.js';
3
2
  /**
4
3
  * Create an HTTP request handler for inbound messages from E-Claw.
5
4
  *
6
- * Handles three event types:
7
- * - 'message' → Normal human message; reply via sendMessage()
8
- * - 'entity_message' → Bot-to-bot speak-to; reply via sendMessage() + speakTo(fromEntityId)
9
- * - 'broadcast' → Broadcast from another entity; reply via sendMessage() + speakTo(fromEntityId)
10
- *
11
- * The `deliver` callback routes AI response to the correct E-Claw endpoint
12
- * based on the inbound event type.
13
- *
14
- * Channel Bot Context Parity v1.0.17:
15
- * - Bot-to-bot / broadcast now calls sendMessage() to update own wallpaper AND speakTo() to reply
16
- * - Quota awareness via eclaw_context.b2bRemaining / b2bMax
17
- * - Mission context via eclaw_context.missionHints
18
- * - Silent suppression via silentToken (default "[SILENT]")
5
+ * When a user sends a message on E-Claw, the backend POSTs structured JSON
6
+ * to this webhook. We normalize it and dispatch to the OpenClaw agent.
19
7
  */
20
- export function createWebhookHandler(expectedToken, accountId,
21
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
- cfg // full openclaw config (ctx.cfg from startAccount)
23
- ) {
8
+ export function createWebhookHandler(expectedToken, accountId) {
24
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
10
  return async (req, res) => {
26
11
  // Verify callback token
@@ -37,103 +22,36 @@ cfg // full openclaw config (ctx.cfg from startAccount)
37
22
  // Dispatch to OpenClaw agent
38
23
  try {
39
24
  const rt = getPluginRuntime();
40
- const client = getClient(accountId);
41
25
  const conversationId = msg.conversationId || `${msg.deviceId}:${msg.entityId}`;
42
- // Capture event context for deliver routing
43
- const event = msg.event || 'message';
44
- const fromEntityId = msg.fromEntityId;
45
- const fromCharacter = msg.fromCharacter;
46
- // Read server-injected context block (Channel Bot parity)
47
- const eclawCtx = msg.eclaw_context;
48
- const silentToken = eclawCtx?.silentToken ?? '[SILENT]';
49
- // Map E-Claw media type to OpenClaw media type
50
- const ocMediaType = msg.mediaType === 'photo' ? 'image'
51
- : msg.mediaType === 'voice' ? 'audio'
52
- : msg.mediaType === 'video' ? 'video'
53
- : msg.mediaType ? 'file'
54
- : undefined;
55
- // Build body — enrich with event context for bot-to-bot and broadcast
56
- let body = msg.text || '';
57
- if ((event === 'entity_message' || event === 'broadcast') && fromEntityId !== undefined) {
58
- const senderLabel = fromCharacter
59
- ? `Entity ${fromEntityId} (${fromCharacter})`
60
- : `Entity ${fromEntityId}`;
61
- const eventPrefix = event === 'broadcast'
62
- ? `[Broadcast from ${senderLabel}]`
63
- : `[Bot-to-Bot message from ${senderLabel}]`;
64
- const quotaLine = eclawCtx?.b2bRemaining !== undefined
65
- ? `[Quota: ${eclawCtx.b2bRemaining}/${eclawCtx.b2bMax ?? 8} remaining — output "${silentToken}" if no new info worth replying to]`
66
- : '';
67
- const missionBlock = eclawCtx?.missionHints ?? '';
68
- body = [eventPrefix, quotaLine, missionBlock, msg.text || '']
69
- .filter(Boolean)
70
- .join('\n');
26
+ // Map E-Claw media types to OpenClaw types
27
+ let media;
28
+ if (msg.mediaType && msg.mediaUrl) {
29
+ const type = msg.mediaType === 'photo' ? 'image'
30
+ : msg.mediaType === 'voice' ? 'audio'
31
+ : msg.mediaType === 'video' ? 'video'
32
+ : 'file';
33
+ media = { type, url: msg.mediaUrl };
71
34
  }
72
- // Build context in OpenClaw's native PascalCase format
73
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
35
  const inboundCtx = {
75
- Surface: 'eclaw',
76
- Provider: 'eclaw',
77
- OriginatingChannel: 'eclaw',
78
- AccountId: accountId,
79
- From: msg.from,
80
- To: conversationId,
81
- OriginatingTo: msg.from,
82
- SessionKey: conversationId,
83
- Body: body,
84
- RawBody: body,
85
- CommandBody: body,
86
- ChatType: 'direct',
87
- ...(ocMediaType && msg.mediaUrl ? {
88
- MediaType: ocMediaType,
89
- MediaUrl: msg.mediaUrl,
90
- } : {}),
36
+ channelId: 'eclaw',
37
+ accountId,
38
+ conversationId,
39
+ senderId: msg.from,
40
+ text: msg.text || '',
41
+ ...(media ? { media } : {}),
42
+ metadata: {
43
+ deviceId: msg.deviceId,
44
+ entityId: msg.entityId,
45
+ event: msg.event,
46
+ fromEntityId: msg.fromEntityId,
47
+ fromCharacter: msg.fromCharacter,
48
+ isBroadcast: msg.isBroadcast,
49
+ timestamp: msg.timestamp,
50
+ },
91
51
  };
92
- const ctxPayload = rt.channel.reply.finalizeInboundContext(inboundCtx);
93
- // Track event type so outbound.sendText() can suppress duplicate delivery
94
- setActiveEvent(accountId, event);
95
- try {
96
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
97
- ctx: ctxPayload,
98
- cfg,
99
- dispatcherOptions: {
100
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
- deliver: async (payload) => {
102
- if (!client)
103
- return;
104
- const text = typeof payload.text === 'string' ? payload.text.trim() : '';
105
- // [SILENT] token or empty → skip all API calls
106
- if (!text || text === silentToken)
107
- return;
108
- if ((event === 'entity_message' || event === 'broadcast') && fromEntityId !== undefined) {
109
- // Bot-to-bot / broadcast: update own wallpaper AND reply to sender
110
- await client.sendMessage(text, 'IDLE');
111
- await client.speakTo(fromEntityId, text, false);
112
- }
113
- else {
114
- // Normal human message: reply via channel message
115
- if (text) {
116
- await client.sendMessage(text, 'IDLE');
117
- }
118
- else if (payload.mediaUrl) {
119
- const rawType = typeof payload.mediaType === 'string' ? payload.mediaType : '';
120
- const mediaType = rawType === 'image' ? 'photo'
121
- : rawType === 'audio' ? 'voice'
122
- : rawType === 'video' ? 'video'
123
- : 'file';
124
- await client.sendMessage('', 'IDLE', mediaType, payload.mediaUrl);
125
- }
126
- }
127
- },
128
- onError: (err) => {
129
- console.error('[E-Claw] Reply delivery error:', err);
130
- },
131
- },
132
- });
133
- }
134
- finally {
135
- clearActiveEvent(accountId);
136
- }
52
+ // OpenClaw inbound dispatch pipeline
53
+ const ctx = await rt.channel.reply.finalizeInboundContext(inboundCtx);
54
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher(ctx);
137
55
  }
138
56
  catch (err) {
139
57
  console.error('[E-Claw] Webhook dispatch error:', err);