@eclaw/openclaw-channel 1.1.1 → 1.1.2

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/README.md CHANGED
@@ -150,6 +150,95 @@ Device owners can schedule messages to be sent to your bot at a specific time (o
150
150
  | `ECLAW_WEBHOOK_URL` | Production | Public URL for receiving inbound messages |
151
151
  | `ECLAW_WEBHOOK_PORT` | Optional | Webhook server port (default: random) |
152
152
 
153
+ ## Troubleshooting
154
+
155
+ ### `Config invalid: channels.eclaw unknown channel id`
156
+
157
+ **Cause**: OpenClaw validates the config before loading plugins. If `channels.eclaw` is already in the config but the plugin hasn't loaded yet (e.g. after upgrade), validation fails.
158
+
159
+ **Fix**: Run this script in the Zeabur terminal, then do a **full container restart** from the Zeabur Dashboard (not SIGUSR1 in-process restart):
160
+
161
+ ```bash
162
+ cat > /tmp/fix-cfg.js << 'EOF'
163
+ var fs = require('fs');
164
+ var p = '/home/node/.openclaw/openclaw.json';
165
+ var cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
166
+ if (cfg.plugins && cfg.plugins.installs) {
167
+ delete cfg.plugins.installs['openclaw-channel'];
168
+ }
169
+ if (cfg.plugins && cfg.plugins.entries) {
170
+ delete cfg.plugins.entries['openclaw-channel'];
171
+ }
172
+ cfg.plugins = cfg.plugins || {};
173
+ cfg.plugins.allow = cfg.plugins.allow || [];
174
+ if (!cfg.plugins.allow.includes('openclaw-channel')) {
175
+ cfg.plugins.allow.push('openclaw-channel');
176
+ }
177
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
178
+ console.log('Done:', JSON.stringify(cfg.plugins, null, 2));
179
+ EOF
180
+ node /tmp/fix-cfg.js
181
+ ```
182
+
183
+ ---
184
+
185
+ ### `plugin already exists: delete it first` (on upgrade)
186
+
187
+ Running `openclaw plugins install @eclaw/openclaw-channel@X.Y.Z` directly fails when an older version is present. Use this full upgrade script instead:
188
+
189
+ ```bash
190
+ cat > /tmp/upgrade-eclaw.js << 'EOF'
191
+ var fs = require('fs'), { execSync } = require('child_process');
192
+ var p = '/home/node/.openclaw/openclaw.json';
193
+ var cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
194
+
195
+ // 1. Save eclaw channel config
196
+ var saved = cfg.channels && cfg.channels.eclaw;
197
+
198
+ // 2. Strip entries that cause validation to fail
199
+ if (cfg.channels) delete cfg.channels.eclaw;
200
+ if (cfg.plugins) {
201
+ if (cfg.plugins.entries) delete cfg.plugins.entries['openclaw-channel'];
202
+ if (cfg.plugins.allow) cfg.plugins.allow = cfg.plugins.allow.filter(x => x !== 'openclaw-channel');
203
+ if (cfg.plugins.installs) delete cfg.plugins.installs['openclaw-channel'];
204
+ }
205
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
206
+
207
+ // 3. Remove old plugin files
208
+ execSync('rm -rf /home/node/.openclaw/extensions/openclaw-channel');
209
+
210
+ // 4. Install new version (update version number below)
211
+ var out = execSync('openclaw plugins install @eclaw/openclaw-channel@1.0.18 2>&1', { encoding: 'utf8' });
212
+ console.log(out);
213
+
214
+ // 5. Restore channel config
215
+ cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
216
+ if (saved) { cfg.channels = cfg.channels || {}; cfg.channels.eclaw = saved; }
217
+ cfg.plugins.allow = cfg.plugins.allow || [];
218
+ if (!cfg.plugins.allow.includes('openclaw-channel')) cfg.plugins.allow.push('openclaw-channel');
219
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
220
+ console.log('Done — restart the service from Zeabur Dashboard.');
221
+ EOF
222
+ node /tmp/upgrade-eclaw.js
223
+ ```
224
+
225
+ After the script completes, do a **full service restart** from Zeabur Dashboard.
226
+
227
+ ---
228
+
229
+ ### In-process restart (`SIGUSR1`) doesn't apply channel config changes
230
+
231
+ In-process restart validates the config before loading plugins, so `channels.eclaw` appears as an unknown channel and the restart fails. Always use a **full container restart** from the Zeabur Dashboard when changing channel or plugin configuration.
232
+
233
+ ---
234
+
235
+ ### Bot doesn't receive messages / webhook not called
236
+
237
+ 1. Check `ECLAW_WEBHOOK_URL` is a publicly reachable URL (not `localhost`)
238
+ 2. Verify the callback was registered: the plugin logs `Account default ready!` on startup
239
+ 3. In E-Claw Portal, confirm the entity shows as channel-bound (green dot)
240
+ 4. Check server logs: `curl "https://eclawbot.com/api/logs?deviceId=...&deviceSecret=...&limit=20"`
241
+
153
242
  ## License
154
243
 
155
244
  MIT
package/dist/client.d.ts CHANGED
@@ -12,13 +12,19 @@ export declare class EClawClient {
12
12
  constructor(config: EClawAccountConfig);
13
13
  /** Register callback URL with E-Claw backend */
14
14
  registerCallback(callbackUrl: string, callbackToken: string): Promise<RegisterResponse>;
15
- /** Bind an entity via channel API (bypasses 6-digit code) */
16
- bindEntity(entityId: number, name?: string): Promise<BindResponse>;
17
- /** Send bot message to user */
15
+ /** Bind an entity via channel API (bypasses 6-digit code).
16
+ * If entityId is omitted, the backend auto-selects the first free slot.
17
+ */
18
+ bindEntity(entityId?: number, name?: string): Promise<BindResponse>;
19
+ /** Send bot message to user (updates own entity state on wallpaper) */
18
20
  sendMessage(message: string, state?: string, mediaType?: string, mediaUrl?: string): Promise<MessageResponse>;
21
+ /** Send bot-to-bot message to another entity (speak-to) */
22
+ speakTo(toEntityId: number, text: string, expectsReply?: boolean): Promise<void>;
23
+ /** Broadcast message to all other bound entities */
24
+ broadcastToAll(text: string, expectsReply?: boolean): Promise<void>;
19
25
  /** Unregister callback on shutdown */
20
26
  unregisterCallback(): Promise<void>;
21
27
  get currentDeviceId(): string | null;
22
28
  get currentBotSecret(): string | null;
23
- get currentEntityId(): number;
29
+ get currentEntityId(): number | undefined;
24
30
  }
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@ export class EClawClient {
11
11
  constructor(config) {
12
12
  this.apiBase = config.apiBase;
13
13
  this.apiKey = config.apiKey;
14
- this.entityId = config.entityId;
14
+ this.entityId = config.entityId; // undefined until assigned by bindEntity
15
15
  }
16
16
  /** Register callback URL with E-Claw backend */
17
17
  async registerCallback(callbackUrl, callbackToken) {
@@ -31,27 +31,40 @@ export class EClawClient {
31
31
  this.deviceId = data.deviceId;
32
32
  return data;
33
33
  }
34
- /** Bind an entity via channel API (bypasses 6-digit code) */
34
+ /** Bind an entity via channel API (bypasses 6-digit code).
35
+ * If entityId is omitted, the backend auto-selects the first free slot.
36
+ */
35
37
  async bindEntity(entityId, name) {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const body = { channel_api_key: this.apiKey };
40
+ if (entityId !== undefined)
41
+ body.entityId = entityId;
42
+ if (name)
43
+ body.name = name;
36
44
  const res = await fetch(`${this.apiBase}/api/channel/bind`, {
37
45
  method: 'POST',
38
46
  headers: { 'Content-Type': 'application/json' },
39
- body: JSON.stringify({
40
- channel_api_key: this.apiKey,
41
- entityId,
42
- name: name || undefined,
43
- }),
47
+ body: JSON.stringify(body),
44
48
  });
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
50
  const data = await res.json();
46
51
  if (!data.success) {
52
+ // Build a detailed error message when all slots are full
53
+ if (res.status === 409 && data.entities) {
54
+ const list = data.entities
55
+ .map((e) => ` slot ${e.entityId} (${e.character})${e.name ? ` "${e.name}"` : ''}`)
56
+ .join('\n');
57
+ throw new Error(`${data.message}\nCurrent entities:\n${list}\n` +
58
+ 'Add entityId to your channel config to target a specific slot after unbinding it.');
59
+ }
47
60
  throw new Error(data.message || `Bind failed (HTTP ${res.status})`);
48
61
  }
49
62
  this.botSecret = data.botSecret;
50
63
  this.deviceId = data.deviceId;
51
- this.entityId = entityId;
64
+ this.entityId = data.entityId; // Use server-assigned slot
52
65
  return data;
53
66
  }
54
- /** Send bot message to user */
67
+ /** Send bot message to user (updates own entity state on wallpaper) */
55
68
  async sendMessage(message, state = 'IDLE', mediaType, mediaUrl) {
56
69
  if (!this.deviceId || !this.botSecret) {
57
70
  throw new Error('Not bound — call bindEntity() first');
@@ -72,6 +85,41 @@ export class EClawClient {
72
85
  });
73
86
  return await res.json();
74
87
  }
88
+ /** Send bot-to-bot message to another entity (speak-to) */
89
+ async speakTo(toEntityId, text, expectsReply = false) {
90
+ if (!this.deviceId || !this.botSecret) {
91
+ throw new Error('Not bound — call bindEntity() first');
92
+ }
93
+ await fetch(`${this.apiBase}/api/entity/speak-to`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({
97
+ deviceId: this.deviceId,
98
+ fromEntityId: this.entityId,
99
+ toEntityId,
100
+ botSecret: this.botSecret,
101
+ text,
102
+ expects_reply: expectsReply,
103
+ }),
104
+ });
105
+ }
106
+ /** Broadcast message to all other bound entities */
107
+ async broadcastToAll(text, expectsReply = false) {
108
+ if (!this.deviceId || !this.botSecret) {
109
+ throw new Error('Not bound — call bindEntity() first');
110
+ }
111
+ await fetch(`${this.apiBase}/api/entity/broadcast`, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({
115
+ deviceId: this.deviceId,
116
+ fromEntityId: this.entityId,
117
+ botSecret: this.botSecret,
118
+ text,
119
+ expects_reply: expectsReply,
120
+ }),
121
+ });
122
+ }
75
123
  /** Unregister callback on shutdown */
76
124
  async unregisterCallback() {
77
125
  await fetch(`${this.apiBase}/api/channel/register`, {
package/dist/config.js CHANGED
@@ -28,7 +28,7 @@ export function resolveAccount(cfg, accountId) {
28
28
  apiKey: account?.apiKey ?? '',
29
29
  apiSecret: account?.apiSecret,
30
30
  apiBase: (account?.apiBase ?? 'https://eclawbot.com').replace(/\/$/, ''),
31
- entityId: account?.entityId ?? 0,
31
+ entityId: account?.entityId, // undefined = auto-assign
32
32
  botName: account?.botName,
33
33
  webhookUrl: account?.webhookUrl,
34
34
  };
package/dist/gateway.d.ts CHANGED
@@ -4,10 +4,8 @@
4
4
  * 1. Resolve credentials from ctx.account or disk
5
5
  * 2. Register a per-session handler in the webhook-registry (served by the
6
6
  * main OpenClaw gateway HTTP server at /eclaw-webhook — no separate port)
7
- * 3. Register callback URL with E-Claw backend (with exponential-backoff retry)
7
+ * 3. Register callback URL with E-Claw backend
8
8
  * 4. Auto-bind entity if not already bound
9
- * 5. Periodically re-register to keep callback URL live (health check)
10
- * 6. On health-check failure, reconnect with exponential backoff
11
- * 7. Keep the promise alive until abort signal fires
9
+ * 5. Keep the promise alive until abort signal fires
12
10
  */
13
11
  export declare function startAccount(ctx: any): Promise<void>;
package/dist/gateway.js CHANGED
@@ -7,26 +7,6 @@ import { EClawClient } from './client.js';
7
7
  import { setClient } from './outbound.js';
8
8
  import { createWebhookHandler } from './webhook-handler.js';
9
9
  import { registerWebhookToken, unregisterWebhookToken } from './webhook-registry.js';
10
- // ── Reconnect / health-check constants ───────────────────────────────────────
11
- const HEALTH_CHECK_INTERVAL_MS = 60_000; // re-register every 60 s to stay live
12
- const BACKOFF_INITIAL_MS = 5_000; // first retry after 5 s
13
- const BACKOFF_MAX_MS = 300_000; // cap at 5 min
14
- const BACKOFF_MULTIPLIER = 2;
15
- /** Sleep ms, but resolve early if abortSignal fires. */
16
- function sleep(ms, signal) {
17
- return new Promise((resolve) => {
18
- if (signal?.aborted) {
19
- resolve();
20
- return;
21
- }
22
- const timer = setTimeout(resolve, ms);
23
- signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
24
- });
25
- }
26
- /** Add ±20 % random jitter to reduce thundering-herd on reconnect. */
27
- function jitter(ms) {
28
- return Math.floor(ms * (0.8 + Math.random() * 0.4));
29
- }
30
10
  /**
31
11
  * Resolve account from ctx.
32
12
  *
@@ -42,7 +22,7 @@ function resolveAccountFromCtx(ctx) {
42
22
  apiKey: ctx.account.apiKey,
43
23
  apiSecret: ctx.account.apiSecret,
44
24
  apiBase: (ctx.account.apiBase ?? 'https://eclawbot.com').replace(/\/$/, ''),
45
- entityId: ctx.account.entityId ?? 0,
25
+ entityId: ctx.account.entityId, // undefined = auto-select
46
26
  botName: ctx.account.botName,
47
27
  webhookUrl: ctx.account.webhookUrl,
48
28
  };
@@ -63,11 +43,9 @@ function resolveAccountFromCtx(ctx) {
63
43
  * 1. Resolve credentials from ctx.account or disk
64
44
  * 2. Register a per-session handler in the webhook-registry (served by the
65
45
  * main OpenClaw gateway HTTP server at /eclaw-webhook — no separate port)
66
- * 3. Register callback URL with E-Claw backend (with exponential-backoff retry)
46
+ * 3. Register callback URL with E-Claw backend
67
47
  * 4. Auto-bind entity if not already bound
68
- * 5. Periodically re-register to keep callback URL live (health check)
69
- * 6. On health-check failure, reconnect with exponential backoff
70
- * 7. Keep the promise alive until abort signal fires
48
+ * 5. Keep the promise alive until abort signal fires
71
49
  */
72
50
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
51
  export async function startAccount(ctx) {
@@ -98,103 +76,42 @@ export async function startAccount(ctx) {
98
76
  const handler = createWebhookHandler(callbackToken, accountId, ctx.cfg);
99
77
  registerWebhookToken(callbackToken, accountId, handler);
100
78
  console.log(`[E-Claw] Webhook registered at: ${callbackUrl}`);
101
- const signal = ctx.abortSignal;
102
- // ── Core setup: register callback + bind entity ───────────────────────────
103
- /**
104
- * One full register+bind cycle. Returns true on success, false on failure.
105
- */
106
- async function attemptSetup() {
107
- try {
108
- const regData = await client.registerCallback(callbackUrl, callbackToken);
109
- console.log(`[E-Claw][${accountId}] Registered. Device: ${regData.deviceId}, Entities: ${regData.entities.length}`);
110
- const entity = regData.entities.find(e => e.entityId === account.entityId);
111
- if (!entity?.isBound) {
112
- console.log(`[E-Claw][${accountId}] Entity ${account.entityId} not bound, binding...`);
113
- const bindData = await client.bindEntity(account.entityId, account.botName);
114
- console.log(`[E-Claw][${accountId}] Bound entity ${account.entityId}, publicCode: ${bindData.publicCode}`);
115
- }
116
- else {
117
- console.log(`[E-Claw][${accountId}] Entity ${account.entityId} already bound`);
118
- const bindData = await client.bindEntity(account.entityId, account.botName);
119
- console.log(`[E-Claw][${accountId}] Retrieved credentials for entity ${account.entityId}`);
120
- void bindData;
121
- }
122
- return true;
123
- }
124
- catch (err) {
125
- console.error(`[E-Claw][${accountId}] Setup attempt failed:`, err);
126
- return false;
127
- }
128
- }
129
- // ── Initial connect with exponential backoff ──────────────────────────────
130
- let backoffMs = BACKOFF_INITIAL_MS;
131
- let attempt = 0;
132
- while (!signal?.aborted) {
133
- attempt++;
134
- const ok = await attemptSetup();
135
- if (ok) {
136
- console.log(`[E-Claw][${accountId}] Account ready! (attempt #${attempt})`);
137
- break;
138
- }
139
- const delay = jitter(Math.min(backoffMs, BACKOFF_MAX_MS));
140
- console.warn(`[E-Claw][${accountId}] Retrying in ${Math.round(delay / 1000)}s (attempt #${attempt})...`);
141
- await sleep(delay, signal);
142
- backoffMs = Math.min(backoffMs * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
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!`);
143
97
  }
144
- if (signal?.aborted) {
98
+ catch (err) {
99
+ console.error(`[E-Claw] Setup failed for account ${accountId}:`, err);
145
100
  unregisterWebhookToken(callbackToken);
146
101
  return;
147
102
  }
148
- // ── Periodic health check + auto-reconnect ────────────────────────────────
149
- // Re-register every 60 s. If it fails, enter a reconnect backoff loop.
150
- // Guard flag prevents concurrent reconnect loops from stacking.
151
- let isReconnecting = false;
152
- async function runHealthCheck() {
153
- if (isReconnecting)
154
- return;
155
- try {
156
- await client.registerCallback(callbackUrl, callbackToken);
157
- // Silent success — no log spam when healthy
158
- }
159
- catch (err) {
160
- if (isReconnecting)
161
- return;
162
- isReconnecting = true;
163
- console.warn(`[E-Claw][${accountId}] Health check failed — starting reconnect loop:`, err);
164
- let reconnBackoff = BACKOFF_INITIAL_MS;
165
- let reconnAttempt = 0;
166
- while (!signal?.aborted) {
167
- reconnAttempt++;
168
- const delay = jitter(Math.min(reconnBackoff, BACKOFF_MAX_MS));
169
- console.warn(`[E-Claw][${accountId}] Reconnect attempt #${reconnAttempt} in ${Math.round(delay / 1000)}s...`);
170
- await sleep(delay, signal);
171
- if (signal?.aborted)
172
- break;
173
- const recovered = await attemptSetup();
174
- if (recovered) {
175
- console.log(`[E-Claw][${accountId}] Reconnected successfully after ${reconnAttempt} attempt(s)!`);
176
- break;
177
- }
178
- reconnBackoff = Math.min(reconnBackoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
179
- }
180
- isReconnecting = false;
181
- }
182
- }
183
- const healthTimer = setInterval(() => { void runHealthCheck(); }, HEALTH_CHECK_INTERVAL_MS);
184
103
  // Keep the promise alive until abort signal fires
185
104
  return new Promise((resolve) => {
105
+ const signal = ctx.abortSignal;
186
106
  if (signal) {
187
107
  signal.addEventListener('abort', () => {
188
- console.log(`[E-Claw][${accountId}] Shutting down account`);
189
- clearInterval(healthTimer);
108
+ console.log(`[E-Claw] Shutting down account ${accountId}`);
190
109
  client.unregisterCallback().catch(() => { });
191
110
  unregisterWebhookToken(callbackToken);
192
111
  resolve();
193
112
  });
194
113
  }
195
114
  else {
196
- // No abort signal — resolve immediately (should not happen in normal use)
197
- clearInterval(healthTimer);
198
115
  resolve();
199
116
  }
200
117
  });
@@ -1,4 +1,6 @@
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;
2
4
  export declare function setClient(accountId: string, client: EClawClient): void;
3
5
  export declare function getClient(accountId: string): EClawClient | undefined;
4
6
  /** OpenClaw outbound: send text message to E-Claw user */
package/dist/outbound.js CHANGED
@@ -1,5 +1,13 @@
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
+ }
3
11
  export function setClient(accountId, client) {
4
12
  clients.set(accountId, client);
5
13
  }
@@ -10,6 +18,11 @@ export function getClient(accountId) {
10
18
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
19
  export async function sendText(ctx) {
12
20
  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
+ }
13
26
  const client = clients.get(accountId);
14
27
  if (!client) {
15
28
  return { channel: 'eclaw', messageId: '', chatId: '' };
@@ -32,6 +45,11 @@ export async function sendText(ctx) {
32
45
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
46
  export async function sendMedia(ctx) {
34
47
  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
+ }
35
53
  const client = clients.get(accountId);
36
54
  if (!client) {
37
55
  return { channel: 'eclaw', messageId: '', chatId: '' };
package/dist/types.d.ts CHANGED
@@ -4,10 +4,18 @@ export interface EClawAccountConfig {
4
4
  apiKey: string;
5
5
  apiSecret?: string;
6
6
  apiBase: string;
7
- entityId: number;
7
+ entityId?: number;
8
8
  botName?: string;
9
9
  webhookUrl?: string;
10
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
+ }
11
19
  /** Inbound message from E-Claw callback webhook */
12
20
  export interface EClawInboundMessage {
13
21
  event: 'message' | 'entity_message' | 'broadcast' | 'cross_device_message';
@@ -25,6 +33,7 @@ export interface EClawInboundMessage {
25
33
  fromEntityId?: number;
26
34
  fromCharacter?: string;
27
35
  fromPublicCode?: string;
36
+ eclaw_context?: EClawContext;
28
37
  }
29
38
  /** Entity info returned by channel register */
30
39
  export interface EClawEntityInfo {
@@ -50,6 +59,13 @@ export interface BindResponse {
50
59
  publicCode: string;
51
60
  bindingType: string;
52
61
  }
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
+ }
53
69
  /** Response from POST /api/channel/message */
54
70
  export interface MessageResponse {
55
71
  success: boolean;
@@ -1,10 +1,18 @@
1
1
  /**
2
2
  * Create an HTTP request handler for inbound messages from E-Claw.
3
3
  *
4
- * When a user sends a message on E-Claw, the backend POSTs structured JSON
5
- * to this webhook. We normalize it into OpenClaw's native PascalCase context
6
- * format and dispatch to the agent via dispatchReplyWithBufferedBlockDispatcher.
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)
7
8
  *
8
- * The `deliver` callback sends the AI reply back to E-Claw via the API client.
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]")
9
17
  */
10
18
  export declare function createWebhookHandler(expectedToken: string, accountId: string, cfg: any): (req: any, res: any) => Promise<void>;
@@ -1,13 +1,21 @@
1
1
  import { getPluginRuntime } from './runtime.js';
2
- import { getClient } from './outbound.js';
2
+ import { getClient, setActiveEvent, clearActiveEvent } from './outbound.js';
3
3
  /**
4
4
  * Create an HTTP request handler for inbound messages from E-Claw.
5
5
  *
6
- * When a user sends a message on E-Claw, the backend POSTs structured JSON
7
- * to this webhook. We normalize it into OpenClaw's native PascalCase context
8
- * format and dispatch to the agent via dispatchReplyWithBufferedBlockDispatcher.
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)
9
10
  *
10
- * The `deliver` callback sends the AI reply back to E-Claw via the API client.
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]")
11
19
  */
12
20
  export function createWebhookHandler(expectedToken, accountId,
13
21
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -31,14 +39,37 @@ cfg // full openclaw config (ctx.cfg from startAccount)
31
39
  const rt = getPluginRuntime();
32
40
  const client = getClient(accountId);
33
41
  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]';
34
49
  // Map E-Claw media type to OpenClaw media type
35
50
  const ocMediaType = msg.mediaType === 'photo' ? 'image'
36
51
  : msg.mediaType === 'voice' ? 'audio'
37
52
  : msg.mediaType === 'video' ? 'video'
38
53
  : msg.mediaType ? 'file'
39
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');
71
+ }
40
72
  // Build context in OpenClaw's native PascalCase format
41
- // (same convention as Telegram/LINE/WhatsApp channels)
42
73
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
74
  const inboundCtx = {
44
75
  Surface: 'eclaw',
@@ -49,9 +80,9 @@ cfg // full openclaw config (ctx.cfg from startAccount)
49
80
  To: conversationId,
50
81
  OriginatingTo: msg.from,
51
82
  SessionKey: conversationId,
52
- Body: msg.text || '',
53
- RawBody: msg.text || '',
54
- CommandBody: msg.text || '',
83
+ Body: body,
84
+ RawBody: body,
85
+ CommandBody: body,
55
86
  ChatType: 'direct',
56
87
  ...(ocMediaType && msg.mediaUrl ? {
57
88
  MediaType: ocMediaType,
@@ -59,32 +90,50 @@ cfg // full openclaw config (ctx.cfg from startAccount)
59
90
  } : {}),
60
91
  };
61
92
  const ctxPayload = rt.channel.reply.finalizeInboundContext(inboundCtx);
62
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
63
- ctx: ctxPayload,
64
- cfg,
65
- dispatcherOptions: {
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
- deliver: async (payload) => {
68
- if (!client)
69
- return;
70
- const text = typeof payload.text === 'string' ? payload.text.trim() : '';
71
- if (text) {
72
- await client.sendMessage(text, 'IDLE');
73
- }
74
- else if (payload.mediaUrl) {
75
- const rawType = typeof payload.mediaType === 'string' ? payload.mediaType : '';
76
- const mediaType = rawType === 'image' ? 'photo'
77
- : rawType === 'audio' ? 'voice'
78
- : rawType === 'video' ? 'video'
79
- : 'file';
80
- await client.sendMessage('', 'IDLE', mediaType, payload.mediaUrl);
81
- }
82
- },
83
- onError: (err) => {
84
- console.error('[E-Claw] Reply delivery error:', err);
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
+ },
85
131
  },
86
- },
87
- });
132
+ });
133
+ }
134
+ finally {
135
+ clearActiveEvent(accountId);
136
+ }
88
137
  }
89
138
  catch (err) {
90
139
  console.error('[E-Claw] Webhook dispatch error:', err);
@@ -16,7 +16,7 @@
16
16
  "apiKey": { "type": "string", "description": "Channel API Key (eck_...)" },
17
17
  "apiSecret": { "type": "string", "description": "Channel API Secret (ecs_...)" },
18
18
  "apiBase": { "type": "string", "default": "https://eclawbot.com" },
19
- "entityId": { "type": "number", "default": 0, "minimum": 0, "maximum": 7 },
19
+ "entityId": { "type": "number", "minimum": 0, "maximum": 7, "description": "Optional: entity slot to use (0-7). If omitted, auto-assigned to first free slot." },
20
20
  "botName": { "type": "string", "maxLength": 20 }
21
21
  },
22
22
  "required": ["apiKey", "apiSecret"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eclaw/openclaw-channel",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "E-Claw channel plugin for OpenClaw — AI chat platform for live wallpaper entities",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",