@eclaw/openclaw-channel 1.0.15 → 1.0.17

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
@@ -16,8 +16,12 @@ export declare class EClawClient {
16
16
  * If entityId is omitted, the backend auto-selects the first free slot.
17
17
  */
18
18
  bindEntity(entityId?: number, name?: string): Promise<BindResponse>;
19
- /** Send bot message to user */
19
+ /** Send bot message to user (updates own entity state on wallpaper) */
20
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>;
21
25
  /** Unregister callback on shutdown */
22
26
  unregisterCallback(): Promise<void>;
23
27
  get currentDeviceId(): string | null;
package/dist/client.js CHANGED
@@ -64,7 +64,7 @@ export class EClawClient {
64
64
  this.entityId = data.entityId; // Use server-assigned slot
65
65
  return data;
66
66
  }
67
- /** Send bot message to user */
67
+ /** Send bot message to user (updates own entity state on wallpaper) */
68
68
  async sendMessage(message, state = 'IDLE', mediaType, mediaUrl) {
69
69
  if (!this.deviceId || !this.botSecret) {
70
70
  throw new Error('Not bound — call bindEntity() first');
@@ -85,6 +85,41 @@ export class EClawClient {
85
85
  });
86
86
  return await res.json();
87
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
+ }
88
123
  /** Unregister callback on shutdown */
89
124
  async unregisterCallback() {
90
125
  await fetch(`${this.apiBase}/api/channel/register`, {
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) {
@@ -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
@@ -8,6 +8,14 @@ export interface EClawAccountConfig {
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 {
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eclaw/openclaw-channel",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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",