@eclaw/openclaw-channel 1.0.14 → 1.0.16

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/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.js CHANGED
@@ -22,7 +22,7 @@ function resolveAccountFromCtx(ctx) {
22
22
  apiKey: ctx.account.apiKey,
23
23
  apiSecret: ctx.account.apiSecret,
24
24
  apiBase: (ctx.account.apiBase ?? 'https://eclawbot.com').replace(/\/$/, ''),
25
- entityId: ctx.account.entityId ?? 0,
25
+ entityId: ctx.account.entityId, // undefined = auto-select
26
26
  botName: ctx.account.botName,
27
27
  webhookUrl: ctx.account.webhookUrl,
28
28
  };
@@ -83,14 +83,16 @@ export async function startAccount(ctx) {
83
83
  // Bind entity via channel API.
84
84
  // /api/channel/bind is idempotent for the same channel account:
85
85
  // - Not bound → binds fresh, returns new botSecret
86
- // - Already bound via this channel account → returns existing botSecret
86
+ // - Already bound via this channel account → returns existing botSecret (reconnect)
87
87
  // - Bound via different method → throws error (user must unbind first)
88
- const entityInfo = regData.entities.find(e => e.entityId === account.entityId);
89
- const alreadyBound = entityInfo?.isBound ?? false;
88
+ // entityId is omitted here so the server auto-selects the best slot
90
89
  const bindData = await client.bindEntity(account.entityId, account.botName);
91
- console.log(alreadyBound
92
- ? `[E-Claw] Entity ${account.entityId} reconnected (existing channel binding), publicCode: ${bindData.publicCode}`
93
- : `[E-Claw] Entity ${account.entityId} bound, publicCode: ${bindData.publicCode}`);
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}`);
94
96
  console.log(`[E-Claw] Account ${accountId} ready!`);
95
97
  }
96
98
  catch (err) {
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ 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
  }
@@ -50,6 +50,13 @@ export interface BindResponse {
50
50
  publicCode: string;
51
51
  bindingType: string;
52
52
  }
53
+ /** Error response when all entity slots are full */
54
+ export interface SlotsFullError {
55
+ success: false;
56
+ message: string;
57
+ entities: EClawEntityInfo[];
58
+ hint: string;
59
+ }
53
60
  /** Response from POST /api/channel/message */
54
61
  export interface MessageResponse {
55
62
  success: boolean;
@@ -1,10 +1,12 @@
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 speakTo(fromEntityId)
7
+ * - 'broadcast' → Broadcast from another entity; reply via 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.
9
11
  */
10
12
  export declare function createWebhookHandler(expectedToken: string, accountId: string, cfg: any): (req: any, res: any) => Promise<void>;
@@ -3,11 +3,13 @@ import { getClient } 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 speakTo(fromEntityId)
9
+ * - 'broadcast' → Broadcast from another entity; reply via 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.
11
13
  */
12
14
  export function createWebhookHandler(expectedToken, accountId,
13
15
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -31,14 +33,28 @@ cfg // full openclaw config (ctx.cfg from startAccount)
31
33
  const rt = getPluginRuntime();
32
34
  const client = getClient(accountId);
33
35
  const conversationId = msg.conversationId || `${msg.deviceId}:${msg.entityId}`;
36
+ // Capture event context for deliver routing
37
+ const event = msg.event || 'message';
38
+ const fromEntityId = msg.fromEntityId;
39
+ const fromCharacter = msg.fromCharacter;
34
40
  // Map E-Claw media type to OpenClaw media type
35
41
  const ocMediaType = msg.mediaType === 'photo' ? 'image'
36
42
  : msg.mediaType === 'voice' ? 'audio'
37
43
  : msg.mediaType === 'video' ? 'video'
38
44
  : msg.mediaType ? 'file'
39
45
  : undefined;
46
+ // Build body — enrich with event context for bot-to-bot and broadcast
47
+ let body = msg.text || '';
48
+ if ((event === 'entity_message' || event === 'broadcast') && fromEntityId !== undefined) {
49
+ const senderLabel = fromCharacter
50
+ ? `Entity ${fromEntityId} (${fromCharacter})`
51
+ : `Entity ${fromEntityId}`;
52
+ const prefix = event === 'broadcast'
53
+ ? `[Broadcast from ${senderLabel}]`
54
+ : `[Bot-to-Bot message from ${senderLabel}]`;
55
+ body = `${prefix}\n${msg.text || ''}`;
56
+ }
40
57
  // Build context in OpenClaw's native PascalCase format
41
- // (same convention as Telegram/LINE/WhatsApp channels)
42
58
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
59
  const inboundCtx = {
44
60
  Surface: 'eclaw',
@@ -49,9 +65,9 @@ cfg // full openclaw config (ctx.cfg from startAccount)
49
65
  To: conversationId,
50
66
  OriginatingTo: msg.from,
51
67
  SessionKey: conversationId,
52
- Body: msg.text || '',
53
- RawBody: msg.text || '',
54
- CommandBody: msg.text || '',
68
+ Body: body,
69
+ RawBody: body,
70
+ CommandBody: body,
55
71
  ChatType: 'direct',
56
72
  ...(ocMediaType && msg.mediaUrl ? {
57
73
  MediaType: ocMediaType,
@@ -68,16 +84,26 @@ cfg // full openclaw config (ctx.cfg from startAccount)
68
84
  if (!client)
69
85
  return;
70
86
  const text = typeof payload.text === 'string' ? payload.text.trim() : '';
71
- if (text) {
72
- await client.sendMessage(text, 'IDLE');
87
+ if ((event === 'entity_message' || event === 'broadcast') && fromEntityId !== undefined) {
88
+ // Bot-to-bot or broadcast: reply via speak-to to the sender
89
+ // Do NOT call sendMessage to avoid duplicate chat history entries
90
+ if (text) {
91
+ await client.speakTo(fromEntityId, text, false);
92
+ }
73
93
  }
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);
94
+ else {
95
+ // Normal human message: reply via channel message
96
+ if (text) {
97
+ await client.sendMessage(text, 'IDLE');
98
+ }
99
+ else if (payload.mediaUrl) {
100
+ const rawType = typeof payload.mediaType === 'string' ? payload.mediaType : '';
101
+ const mediaType = rawType === 'image' ? 'photo'
102
+ : rawType === 'audio' ? 'voice'
103
+ : rawType === 'video' ? 'video'
104
+ : 'file';
105
+ await client.sendMessage('', 'IDLE', mediaType, payload.mediaUrl);
106
+ }
81
107
  }
82
108
  },
83
109
  onError: (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.0.14",
3
+ "version": "1.0.16",
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",