@eclaw/openclaw-channel 1.1.0 → 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/dist/gateway.js CHANGED
@@ -1,51 +1,57 @@
1
- import { createServer } from 'node:http';
2
1
  import { randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
3
5
  import { resolveAccount } from './config.js';
4
6
  import { EClawClient } from './client.js';
5
7
  import { setClient } from './outbound.js';
6
8
  import { createWebhookHandler } from './webhook-handler.js';
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));
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);
32
39
  }
33
40
  /**
34
41
  * Gateway lifecycle: start an E-Claw account.
35
42
  *
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)
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
39
47
  * 4. Auto-bind entity if not already bound
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
48
+ * 5. Keep the promise alive until abort signal fires
43
49
  */
44
50
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
51
  export async function startAccount(ctx) {
46
- const { accountId, config } = ctx;
47
- const account = resolveAccount(config, accountId);
48
- if (!account.enabled || !account.apiKey || !account.apiSecret) {
52
+ const accountId = ctx.accountId ?? ctx.account?.accountId ?? 'default';
53
+ const account = resolveAccountFromCtx(ctx);
54
+ if (!account.enabled || !account.apiKey) {
49
55
  console.log(`[E-Claw] Account ${accountId} disabled or missing credentials, skipping`);
50
56
  return;
51
57
  }
@@ -54,141 +60,59 @@ export async function startAccount(ctx) {
54
60
  setClient(accountId, client);
55
61
  // Generate per-session callback token
56
62
  const callbackToken = randomBytes(32).toString('hex');
57
- // Determine webhook configuration
58
- const webhookPort = parseInt(process.env.ECLAW_WEBHOOK_PORT || '0') || 0;
59
- const publicUrl = process.env.ECLAW_WEBHOOK_URL;
63
+ // Webhook URL: account config > env var > warn
64
+ const publicUrl = account.webhookUrl?.replace(/\/$/, '')
65
+ || process.env.ECLAW_WEBHOOK_URL?.replace(/\/$/, '');
60
66
  if (!publicUrl) {
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');
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');
63
71
  }
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);
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();
82
112
  });
83
113
  }
84
114
  else {
85
- res.writeHead(404);
86
- res.end('Not Found');
115
+ resolve();
87
116
  }
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
- });
193
117
  });
194
118
  }
package/dist/index.d.ts CHANGED
@@ -1,24 +1,3 @@
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
- */
22
1
  declare const plugin: {
23
2
  id: string;
24
3
  name: string;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { setPluginRuntime } from './runtime.js';
2
2
  import { eclawChannel } from './channel.js';
3
+ import { dispatchWebhook } from './webhook-registry.js';
3
4
  /**
4
5
  * E-Claw Channel Plugin for OpenClaw.
5
6
  *
@@ -12,24 +13,52 @@ import { eclawChannel } from './channel.js';
12
13
  * accounts:
13
14
  * default:
14
15
  * 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"
19
20
  *
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)
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.
23
24
  */
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
+ }
24
42
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
43
  const plugin = {
26
- id: 'eclaw',
44
+ id: 'openclaw-channel',
27
45
  name: 'E-Claw',
28
- description: 'E-Claw AI Agent collaboration channel plugin',
46
+ description: 'E-Claw AI chat platform channel plugin',
29
47
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
48
  register(api) {
31
49
  console.log('[E-Claw] Plugin loaded');
32
50
  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
+ });
33
62
  api.registerChannel({ plugin: eclawChannel });
34
63
  },
35
64
  };
@@ -0,0 +1,19 @@
1
+ export declare const eclawOnboardingAdapter: {
2
+ channel: string;
3
+ getStatus: ({ cfg }: {
4
+ cfg: any;
5
+ }) => Promise<{
6
+ channel: string;
7
+ configured: boolean;
8
+ statusLines: string[];
9
+ selectionHint: string;
10
+ quickstartScore: number;
11
+ }>;
12
+ configure: ({ cfg, prompter }: {
13
+ cfg: any;
14
+ prompter: any;
15
+ }) => Promise<{
16
+ cfg: any;
17
+ accountId: string;
18
+ }>;
19
+ };
@@ -0,0 +1,77 @@
1
+ import { listAccountIds, resolveAccount } from './config.js';
2
+ const DEFAULT_ACCOUNT_ID = 'default';
3
+ export const eclawOnboardingAdapter = {
4
+ channel: 'eclaw',
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ getStatus: async ({ cfg }) => {
7
+ const ids = listAccountIds(cfg);
8
+ const configured = ids.some((id) => {
9
+ const acc = resolveAccount(cfg, id);
10
+ return Boolean(acc.apiKey);
11
+ });
12
+ return {
13
+ channel: 'eclaw',
14
+ configured,
15
+ statusLines: [`E-Claw: ${configured ? 'configured' : 'not configured'}`],
16
+ selectionHint: configured ? 'configured' : 'E-Claw (AI Live Wallpaper Chat)',
17
+ quickstartScore: configured ? 1 : 3,
18
+ };
19
+ },
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ configure: async ({ cfg, prompter }) => {
22
+ const accountId = DEFAULT_ACCOUNT_ID;
23
+ const resolved = resolveAccount(cfg, accountId);
24
+ await prompter.note([
25
+ '1. Log in to https://eclawbot.com',
26
+ '2. Go to Portal → Settings → Channel API',
27
+ '3. Create an API Key',
28
+ '4. Enter the credentials below',
29
+ ].join('\n'), 'E-Claw Setup');
30
+ const apiKey = await prompter.text({
31
+ message: 'Channel API Key',
32
+ placeholder: 'eck_...',
33
+ initialValue: resolved.apiKey || '',
34
+ validate: (v) => (String(v ?? '').trim() ? undefined : 'Required'),
35
+ });
36
+ const entityIdStr = await prompter.text({
37
+ message: 'Entity ID (0–3)',
38
+ placeholder: '0',
39
+ initialValue: String(resolved.entityId ?? 0),
40
+ validate: (v) => {
41
+ const n = Number(v);
42
+ return Number.isInteger(n) && n >= 0 && n <= 3 ? undefined : 'Must be 0–3';
43
+ },
44
+ });
45
+ const botName = await prompter.text({
46
+ message: 'Bot display name (optional)',
47
+ placeholder: 'My Bot',
48
+ initialValue: resolved.botName ?? '',
49
+ });
50
+ const webhookUrl = await prompter.text({
51
+ message: 'Webhook URL (your OpenClaw public URL, e.g. https://openclaw.example.com)',
52
+ placeholder: 'https://your-openclaw-domain.com',
53
+ initialValue: resolved.webhookUrl ?? '',
54
+ });
55
+ const nextCfg = {
56
+ ...cfg,
57
+ channels: {
58
+ ...(cfg.channels ?? {}),
59
+ eclaw: {
60
+ ...(cfg.channels?.eclaw ?? {}),
61
+ accounts: {
62
+ ...(cfg.channels?.eclaw?.accounts ?? {}), // eslint-disable-line @typescript-eslint/no-explicit-any
63
+ [accountId]: {
64
+ apiKey: String(apiKey).trim(),
65
+ apiBase: resolved.apiBase || 'https://eclawbot.com',
66
+ entityId: Number(entityIdStr),
67
+ botName: String(botName).trim() || undefined,
68
+ webhookUrl: String(webhookUrl).trim() || undefined,
69
+ enabled: true,
70
+ },
71
+ },
72
+ },
73
+ },
74
+ };
75
+ return { cfg: nextCfg, accountId };
76
+ },
77
+ };
@@ -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
@@ -2,10 +2,19 @@
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;
9
18
  }
10
19
  /** Inbound message from E-Claw callback webhook */
11
20
  export interface EClawInboundMessage {
@@ -24,6 +33,7 @@ export interface EClawInboundMessage {
24
33
  fromEntityId?: number;
25
34
  fromCharacter?: string;
26
35
  fromPublicCode?: string;
36
+ eclaw_context?: EClawContext;
27
37
  }
28
38
  /** Entity info returned by channel register */
29
39
  export interface EClawEntityInfo {
@@ -49,6 +59,13 @@ export interface BindResponse {
49
59
  publicCode: string;
50
60
  bindingType: string;
51
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
+ }
52
69
  /** Response from POST /api/channel/message */
53
70
  export interface MessageResponse {
54
71
  success: boolean;
@@ -1,7 +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 and dispatch to the OpenClaw agent.
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]")
6
17
  */
7
- export declare function createWebhookHandler(expectedToken: string, accountId: string): (req: any, res: any) => Promise<void>;
18
+ export declare function createWebhookHandler(expectedToken: string, accountId: string, cfg: any): (req: any, res: any) => Promise<void>;