@eclaw/openclaw-channel 1.1.0 → 1.1.1
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 +155 -58
- package/dist/channel.d.ts +19 -0
- package/dist/channel.js +4 -2
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -5
- package/dist/config.js +15 -3
- package/dist/gateway.d.ts +3 -2
- package/dist/gateway.js +147 -140
- package/dist/index.d.ts +0 -21
- package/dist/index.js +35 -6
- package/dist/onboarding.d.ts +19 -0
- package/dist/onboarding.js +77 -0
- package/dist/types.d.ts +2 -1
- package/dist/webhook-handler.d.ts +5 -2
- package/dist/webhook-handler.js +62 -29
- package/dist/webhook-registry.d.ts +19 -0
- package/dist/webhook-registry.js +39 -0
- package/openclaw.plugin.json +27 -27
- package/package.json +60 -60
package/README.md
CHANGED
|
@@ -1,58 +1,155 @@
|
|
|
1
|
-
# @eclaw/openclaw-channel
|
|
2
|
-
|
|
3
|
-
OpenClaw channel plugin for [E-Claw](https://eclawbot.com) —
|
|
4
|
-
|
|
5
|
-
This plugin enables OpenClaw bots to communicate with E-Claw users as a native channel, alongside Telegram, Discord, and Slack.
|
|
6
|
-
|
|
7
|
-
## Installation
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install @eclaw/openclaw-channel
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Configuration
|
|
14
|
-
|
|
15
|
-
Add to your OpenClaw `config.yaml`:
|
|
16
|
-
|
|
17
|
-
```yaml
|
|
18
|
-
plugins:
|
|
19
|
-
- "@eclaw/openclaw-channel"
|
|
20
|
-
|
|
21
|
-
channels:
|
|
22
|
-
eclaw:
|
|
23
|
-
accounts:
|
|
24
|
-
default:
|
|
25
|
-
apiKey: "eck_..." # From E-Claw Portal → Settings → Channel API
|
|
26
|
-
apiSecret: "ecs_..." # From E-Claw Portal → Settings → Channel API
|
|
27
|
-
apiBase: "https://eclawbot.com"
|
|
28
|
-
entityId: 0 #
|
|
29
|
-
botName: "My Bot"
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Getting API Credentials
|
|
33
|
-
|
|
34
|
-
1. Log in to [E-Claw Portal](https://eclawbot.com/portal)
|
|
35
|
-
2. Go to **Settings → Channel API**
|
|
36
|
-
3. Copy your `API Key` and `API Secret`
|
|
37
|
-
|
|
38
|
-
## How It Works
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
User (Android) ──speaks──▶ E-Claw Backend ──webhook──▶ OpenClaw Agent
|
|
42
|
-
OpenClaw Agent ──replies──▶ POST /api/channel/message ──▶ User (Android)
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
- **Inbound**: E-Claw POSTs structured JSON to a webhook URL registered by this plugin
|
|
46
|
-
- **Outbound**: Plugin calls `POST /api/channel/message` with the bot reply
|
|
47
|
-
- **Auth**: `eck_`/`ecs_` channel credentials for API auth, per-entity `botSecret` for message auth
|
|
48
|
-
|
|
49
|
-
##
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
# @eclaw/openclaw-channel
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin for [E-Claw](https://eclawbot.com) — an AI chat platform for live wallpaper entities on Android.
|
|
4
|
+
|
|
5
|
+
This plugin enables OpenClaw bots to communicate with E-Claw users as a native channel, alongside Telegram, Discord, and Slack.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @eclaw/openclaw-channel
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Add to your OpenClaw `config.yaml`:
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
plugins:
|
|
19
|
+
- "@eclaw/openclaw-channel"
|
|
20
|
+
|
|
21
|
+
channels:
|
|
22
|
+
eclaw:
|
|
23
|
+
accounts:
|
|
24
|
+
default:
|
|
25
|
+
apiKey: "eck_..." # From E-Claw Portal → Settings → Channel API
|
|
26
|
+
apiSecret: "ecs_..." # From E-Claw Portal → Settings → Channel API
|
|
27
|
+
apiBase: "https://eclawbot.com"
|
|
28
|
+
entityId: 0 # Entity slot (0-3 free tier, 0-7 premium). Omit to auto-assign.
|
|
29
|
+
botName: "My Bot" # Display name in E-Claw (max 20 chars)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Getting API Credentials
|
|
33
|
+
|
|
34
|
+
1. Log in to [E-Claw Portal](https://eclawbot.com/portal)
|
|
35
|
+
2. Go to **Settings → Channel API**
|
|
36
|
+
3. Copy your `API Key` (`eck_...`) and `API Secret` (`ecs_...`)
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
User (Android) ──speaks──▶ E-Claw Backend ──webhook──▶ OpenClaw Agent
|
|
42
|
+
OpenClaw Agent ──replies──▶ POST /api/channel/message ──▶ User (Android)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **Inbound**: E-Claw POSTs structured JSON to a webhook URL registered by this plugin
|
|
46
|
+
- **Outbound**: Plugin calls `POST /api/channel/message` with the bot reply
|
|
47
|
+
- **Auth**: `eck_`/`ecs_` channel credentials for API auth, per-entity `botSecret` for message auth
|
|
48
|
+
|
|
49
|
+
## Inbound Message Structure
|
|
50
|
+
|
|
51
|
+
Every message delivered to your webhook has this shape:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"event": "message",
|
|
56
|
+
"from": "user",
|
|
57
|
+
"deviceId": "...",
|
|
58
|
+
"entityId": 0,
|
|
59
|
+
"conversationId": "...:0",
|
|
60
|
+
"text": "Hello!",
|
|
61
|
+
"timestamp": 1741234567890,
|
|
62
|
+
"isBroadcast": false,
|
|
63
|
+
"eclaw_context": {
|
|
64
|
+
"expectsReply": true,
|
|
65
|
+
"silentToken": "[SILENT]",
|
|
66
|
+
"missionHints": "..."
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `event` values
|
|
72
|
+
|
|
73
|
+
| Value | Description |
|
|
74
|
+
|-------|-------------|
|
|
75
|
+
| `message` | Normal message from the device user |
|
|
76
|
+
| `entity_message` | Bot-to-bot message (another entity spoke directly to yours) |
|
|
77
|
+
| `broadcast` | Broadcast from another entity (one-to-many) |
|
|
78
|
+
|
|
79
|
+
### `from` values
|
|
80
|
+
|
|
81
|
+
| Value | Description |
|
|
82
|
+
|-------|-------------|
|
|
83
|
+
| `user` | Human user on the Android device |
|
|
84
|
+
| `system` | Server-generated event (name change, entity moved, etc.) |
|
|
85
|
+
| `scheduled` | Scheduled message created by the device owner |
|
|
86
|
+
|
|
87
|
+
## `eclaw_context` — Channel Bot Parity
|
|
88
|
+
|
|
89
|
+
Since v1.0.17, every inbound push includes an `eclaw_context` block that gives your bot the same awareness as traditional push-based bots:
|
|
90
|
+
|
|
91
|
+
| Field | Type | Description |
|
|
92
|
+
|-------|------|-------------|
|
|
93
|
+
| `expectsReply` | `boolean` | `false` for system events and quota-exceeded bot messages — your bot should output `silentToken` to stay quiet |
|
|
94
|
+
| `silentToken` | `string` | Output this exact string to suppress all API calls (default: `"[SILENT]"`) |
|
|
95
|
+
| `missionHints` | `string` | API reference for reading/writing mission tasks (TODO, SKILL, RULE, SOUL) for this entity |
|
|
96
|
+
| `b2bRemaining` | `number` | Remaining bot-to-bot reply quota for this conversation (resets on human message) |
|
|
97
|
+
| `b2bMax` | `number` | Maximum bot-to-bot quota (currently 8) |
|
|
98
|
+
|
|
99
|
+
### Staying Silent
|
|
100
|
+
|
|
101
|
+
When `expectsReply` is `false`, output the `silentToken` to avoid sending an unwanted reply:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
User message: [SYSTEM:ENTITY_MOVED] Your entity slot has changed...
|
|
105
|
+
Bot reply: [SILENT] ← plugin suppresses all API calls
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The plugin checks the AI output and skips `sendMessage()` / `speakTo()` entirely when the reply equals `silentToken`.
|
|
109
|
+
|
|
110
|
+
## System Events
|
|
111
|
+
|
|
112
|
+
The E-Claw server automatically pushes system events to your bot so it can stay in sync. All system events have `from: "system"` and `eclaw_context.expectsReply: false`.
|
|
113
|
+
|
|
114
|
+
| Event tag in text | Trigger |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `[SYSTEM:ENTITY_MOVED]` | Device owner reordered entities — your bot's slot changed |
|
|
117
|
+
| `[SYSTEM:NAME_CHANGED]` | Device owner renamed this entity |
|
|
118
|
+
|
|
119
|
+
Example `ENTITY_MOVED` payload text:
|
|
120
|
+
```
|
|
121
|
+
[SYSTEM:ENTITY_MOVED] Your entity slot has changed from #1 to #2.
|
|
122
|
+
|
|
123
|
+
UPDATED CREDENTIALS:
|
|
124
|
+
- entityId: 2 (was 1)
|
|
125
|
+
- deviceId: ...
|
|
126
|
+
- botSecret: ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Bot-to-Bot Messages (`entity_message` / `broadcast`)
|
|
130
|
+
|
|
131
|
+
When another E-Claw entity sends your bot a message, the plugin automatically enriches the body before dispatching to your OpenClaw agent:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
[Bot-to-Bot message from Entity 2 (LOBSTER)]
|
|
135
|
+
[Quota: 7/8 remaining — output "[SILENT]" if no new info worth replying to]
|
|
136
|
+
<mission API hints>
|
|
137
|
+
Hello! How are you?
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
On reply, the plugin calls both `sendMessage()` (to update your own wallpaper state) and `speakTo(fromEntityId)` (to reply to the sender).
|
|
141
|
+
|
|
142
|
+
## Scheduled Messages
|
|
143
|
+
|
|
144
|
+
Device owners can schedule messages to be sent to your bot at a specific time (or on a repeating schedule). These arrive with `from: "scheduled"` and `eclaw_context.expectsReply: true` — your bot is expected to respond normally.
|
|
145
|
+
|
|
146
|
+
## Environment Variables
|
|
147
|
+
|
|
148
|
+
| Variable | Required | Description |
|
|
149
|
+
|----------|----------|-------------|
|
|
150
|
+
| `ECLAW_WEBHOOK_URL` | Production | Public URL for receiving inbound messages |
|
|
151
|
+
| `ECLAW_WEBHOOK_PORT` | Optional | Webhook server port (default: random) |
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
package/dist/channel.d.ts
CHANGED
|
@@ -40,4 +40,23 @@ export declare const eclawChannel: {
|
|
|
40
40
|
gateway: {
|
|
41
41
|
startAccount: typeof startAccount;
|
|
42
42
|
};
|
|
43
|
+
onboarding: {
|
|
44
|
+
channel: string;
|
|
45
|
+
getStatus: ({ cfg }: {
|
|
46
|
+
cfg: any;
|
|
47
|
+
}) => Promise<{
|
|
48
|
+
channel: string;
|
|
49
|
+
configured: boolean;
|
|
50
|
+
statusLines: string[];
|
|
51
|
+
selectionHint: string;
|
|
52
|
+
quickstartScore: number;
|
|
53
|
+
}>;
|
|
54
|
+
configure: ({ cfg, prompter }: {
|
|
55
|
+
cfg: any;
|
|
56
|
+
prompter: any;
|
|
57
|
+
}) => Promise<{
|
|
58
|
+
cfg: any;
|
|
59
|
+
accountId: string;
|
|
60
|
+
}>;
|
|
61
|
+
};
|
|
43
62
|
};
|
package/dist/channel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listAccountIds, resolveAccount } from './config.js';
|
|
2
2
|
import { sendText, sendMedia } from './outbound.js';
|
|
3
3
|
import { startAccount } from './gateway.js';
|
|
4
|
+
import { eclawOnboardingAdapter } from './onboarding.js';
|
|
4
5
|
/**
|
|
5
6
|
* E-Claw ChannelPlugin definition.
|
|
6
7
|
*
|
|
@@ -13,9 +14,9 @@ export const eclawChannel = {
|
|
|
13
14
|
meta: {
|
|
14
15
|
id: 'eclaw',
|
|
15
16
|
label: 'E-Claw',
|
|
16
|
-
selectionLabel: 'E-Claw (AI
|
|
17
|
+
selectionLabel: 'E-Claw (AI Live Wallpaper Chat)',
|
|
17
18
|
docsPath: '/channels/eclaw',
|
|
18
|
-
blurb: 'Connect OpenClaw to E-Claw —
|
|
19
|
+
blurb: 'Connect OpenClaw to E-Claw — an AI chat platform for live wallpaper entities on Android.',
|
|
19
20
|
aliases: ['eclaw', 'claw', 'e-claw'],
|
|
20
21
|
},
|
|
21
22
|
capabilities: {
|
|
@@ -40,4 +41,5 @@ export const eclawChannel = {
|
|
|
40
41
|
gateway: {
|
|
41
42
|
startAccount,
|
|
42
43
|
},
|
|
44
|
+
onboarding: eclawOnboardingAdapter,
|
|
43
45
|
};
|
package/dist/client.d.ts
CHANGED
package/dist/client.js
CHANGED
|
@@ -5,14 +5,12 @@
|
|
|
5
5
|
export class EClawClient {
|
|
6
6
|
apiBase;
|
|
7
7
|
apiKey;
|
|
8
|
-
apiSecret;
|
|
9
8
|
deviceId = null;
|
|
10
9
|
botSecret = null;
|
|
11
10
|
entityId;
|
|
12
11
|
constructor(config) {
|
|
13
12
|
this.apiBase = config.apiBase;
|
|
14
13
|
this.apiKey = config.apiKey;
|
|
15
|
-
this.apiSecret = config.apiSecret;
|
|
16
14
|
this.entityId = config.entityId;
|
|
17
15
|
}
|
|
18
16
|
/** Register callback URL with E-Claw backend */
|
|
@@ -22,7 +20,6 @@ export class EClawClient {
|
|
|
22
20
|
headers: { 'Content-Type': 'application/json' },
|
|
23
21
|
body: JSON.stringify({
|
|
24
22
|
channel_api_key: this.apiKey,
|
|
25
|
-
channel_api_secret: this.apiSecret,
|
|
26
23
|
callback_url: callbackUrl,
|
|
27
24
|
callback_token: callbackToken,
|
|
28
25
|
}),
|
|
@@ -41,7 +38,6 @@ export class EClawClient {
|
|
|
41
38
|
headers: { 'Content-Type': 'application/json' },
|
|
42
39
|
body: JSON.stringify({
|
|
43
40
|
channel_api_key: this.apiKey,
|
|
44
|
-
channel_api_secret: this.apiSecret,
|
|
45
41
|
entityId,
|
|
46
42
|
name: name || undefined,
|
|
47
43
|
}),
|
|
@@ -83,7 +79,6 @@ export class EClawClient {
|
|
|
83
79
|
headers: { 'Content-Type': 'application/json' },
|
|
84
80
|
body: JSON.stringify({
|
|
85
81
|
channel_api_key: this.apiKey,
|
|
86
|
-
channel_api_secret: this.apiSecret,
|
|
87
82
|
}),
|
|
88
83
|
});
|
|
89
84
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
/** Extract accounts map from full openclaw config or eclaw-specific config */
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
|
+
function getAccounts(cfg) {
|
|
4
|
+
// Full openclaw config: { channels: { eclaw: { accounts: {...} } } }
|
|
5
|
+
if (cfg?.channels?.eclaw?.accounts)
|
|
6
|
+
return cfg.channels.eclaw.accounts;
|
|
7
|
+
// Eclaw channel config: { accounts: {...} }
|
|
8
|
+
if (cfg?.accounts && typeof cfg.accounts === 'object')
|
|
9
|
+
return cfg.accounts;
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
1
12
|
/** List all configured account IDs from OpenClaw config */
|
|
2
13
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
14
|
export function listAccountIds(cfg) {
|
|
4
|
-
const accounts = cfg
|
|
15
|
+
const accounts = getAccounts(cfg);
|
|
5
16
|
if (!accounts || typeof accounts !== 'object')
|
|
6
17
|
return [];
|
|
7
18
|
return Object.keys(accounts);
|
|
@@ -9,15 +20,16 @@ export function listAccountIds(cfg) {
|
|
|
9
20
|
/** Resolve a specific account's config, with defaults */
|
|
10
21
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
22
|
export function resolveAccount(cfg, accountId) {
|
|
12
|
-
const accounts = cfg
|
|
23
|
+
const accounts = getAccounts(cfg);
|
|
13
24
|
const id = accountId ?? Object.keys(accounts)[0] ?? 'default';
|
|
14
25
|
const account = accounts[id];
|
|
15
26
|
return {
|
|
16
27
|
enabled: account?.enabled ?? true,
|
|
17
28
|
apiKey: account?.apiKey ?? '',
|
|
18
|
-
apiSecret: account?.apiSecret
|
|
29
|
+
apiSecret: account?.apiSecret,
|
|
19
30
|
apiBase: (account?.apiBase ?? 'https://eclawbot.com').replace(/\/$/, ''),
|
|
20
31
|
entityId: account?.entityId ?? 0,
|
|
21
32
|
botName: account?.botName,
|
|
33
|
+
webhookUrl: account?.webhookUrl,
|
|
22
34
|
};
|
|
23
35
|
}
|
package/dist/gateway.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gateway lifecycle: start an E-Claw account.
|
|
3
3
|
*
|
|
4
|
-
* 1.
|
|
5
|
-
* 2.
|
|
4
|
+
* 1. Resolve credentials from ctx.account or disk
|
|
5
|
+
* 2. Register a per-session handler in the webhook-registry (served by the
|
|
6
|
+
* main OpenClaw gateway HTTP server at /eclaw-webhook — no separate port)
|
|
6
7
|
* 3. Register callback URL with E-Claw backend (with exponential-backoff retry)
|
|
7
8
|
* 4. Auto-bind entity if not already bound
|
|
8
9
|
* 5. Periodically re-register to keep callback URL live (health check)
|
package/dist/gateway.js
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
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';
|
|
9
|
+
import { registerWebhookToken, unregisterWebhookToken } from './webhook-registry.js';
|
|
7
10
|
// ── Reconnect / health-check constants ───────────────────────────────────────
|
|
8
11
|
const HEALTH_CHECK_INTERVAL_MS = 60_000; // re-register every 60 s to stay live
|
|
9
12
|
const BACKOFF_INITIAL_MS = 5_000; // first retry after 5 s
|
|
10
13
|
const BACKOFF_MAX_MS = 300_000; // cap at 5 min
|
|
11
14
|
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
15
|
/** Sleep ms, but resolve early if abortSignal fires. */
|
|
19
16
|
function sleep(ms, signal) {
|
|
20
17
|
return new Promise((resolve) => {
|
|
@@ -30,11 +27,42 @@ function sleep(ms, signal) {
|
|
|
30
27
|
function jitter(ms) {
|
|
31
28
|
return Math.floor(ms * (0.8 + Math.random() * 0.4));
|
|
32
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve account from ctx.
|
|
32
|
+
*
|
|
33
|
+
* OpenClaw may pass a pre-resolved account object in ctx.account,
|
|
34
|
+
* or an empty config. Fall back to reading openclaw.json from disk.
|
|
35
|
+
*/
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
function resolveAccountFromCtx(ctx) {
|
|
38
|
+
// Preferred: OpenClaw passes the resolved account in ctx.account
|
|
39
|
+
if (ctx.account?.apiKey) {
|
|
40
|
+
return {
|
|
41
|
+
enabled: ctx.account.enabled ?? true,
|
|
42
|
+
apiKey: ctx.account.apiKey,
|
|
43
|
+
apiSecret: ctx.account.apiSecret,
|
|
44
|
+
apiBase: (ctx.account.apiBase ?? 'https://eclawbot.com').replace(/\/$/, ''),
|
|
45
|
+
entityId: ctx.account.entityId ?? 0,
|
|
46
|
+
botName: ctx.account.botName,
|
|
47
|
+
webhookUrl: ctx.account.webhookUrl,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Fallback: read config from disk (OpenClaw passes empty config object)
|
|
51
|
+
const configPath = process.env.OPENCLAW_CONFIG_PATH
|
|
52
|
+
|| join(homedir(), '.openclaw', 'openclaw.json');
|
|
53
|
+
let fullConfig = {};
|
|
54
|
+
try {
|
|
55
|
+
fullConfig = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
return resolveAccount(fullConfig, ctx.accountId ?? ctx.account?.accountId);
|
|
59
|
+
}
|
|
33
60
|
/**
|
|
34
61
|
* Gateway lifecycle: start an E-Claw account.
|
|
35
62
|
*
|
|
36
|
-
* 1.
|
|
37
|
-
* 2.
|
|
63
|
+
* 1. Resolve credentials from ctx.account or disk
|
|
64
|
+
* 2. Register a per-session handler in the webhook-registry (served by the
|
|
65
|
+
* main OpenClaw gateway HTTP server at /eclaw-webhook — no separate port)
|
|
38
66
|
* 3. Register callback URL with E-Claw backend (with exponential-backoff retry)
|
|
39
67
|
* 4. Auto-bind entity if not already bound
|
|
40
68
|
* 5. Periodically re-register to keep callback URL live (health check)
|
|
@@ -43,9 +71,9 @@ function jitter(ms) {
|
|
|
43
71
|
*/
|
|
44
72
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
73
|
export async function startAccount(ctx) {
|
|
46
|
-
const
|
|
47
|
-
const account =
|
|
48
|
-
if (!account.enabled || !account.apiKey
|
|
74
|
+
const accountId = ctx.accountId ?? ctx.account?.accountId ?? 'default';
|
|
75
|
+
const account = resolveAccountFromCtx(ctx);
|
|
76
|
+
if (!account.enabled || !account.apiKey) {
|
|
49
77
|
console.log(`[E-Claw] Account ${accountId} disabled or missing credentials, skipping`);
|
|
50
78
|
return;
|
|
51
79
|
}
|
|
@@ -54,141 +82,120 @@ export async function startAccount(ctx) {
|
|
|
54
82
|
setClient(accountId, client);
|
|
55
83
|
// Generate per-session callback token
|
|
56
84
|
const callbackToken = randomBytes(32).toString('hex');
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
|
|
85
|
+
// Webhook URL: account config > env var > warn
|
|
86
|
+
const publicUrl = account.webhookUrl?.replace(/\/$/, '')
|
|
87
|
+
|| process.env.ECLAW_WEBHOOK_URL?.replace(/\/$/, '');
|
|
60
88
|
if (!publicUrl) {
|
|
61
|
-
console.warn('[E-Claw]
|
|
62
|
-
'
|
|
89
|
+
console.warn('[E-Claw] Webhook URL not configured. ' +
|
|
90
|
+
'Run "openclaw configure" and enter your OpenClaw public URL, ' +
|
|
91
|
+
'or set ECLAW_WEBHOOK_URL env var. ' +
|
|
92
|
+
'Example: https://your-openclaw-domain.com');
|
|
63
93
|
}
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
// The callback URL points to /eclaw-webhook on the main gateway HTTP server
|
|
95
|
+
const callbackUrl = `${publicUrl || 'http://localhost'}/eclaw-webhook`;
|
|
96
|
+
// Register handler in the per-token registry
|
|
97
|
+
// Pass ctx.cfg so the handler can dispatch to the correct OpenClaw agent
|
|
98
|
+
const handler = createWebhookHandler(callbackToken, accountId, ctx.cfg);
|
|
99
|
+
registerWebhookToken(callbackToken, accountId, handler);
|
|
100
|
+
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;
|
|
83
123
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.error(`[E-Claw][${accountId}] Setup attempt failed:`, err);
|
|
126
|
+
return false;
|
|
87
127
|
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
// ── Initial connect with exponential backoff ────────────────────────
|
|
129
|
-
let backoffMs = BACKOFF_INITIAL_MS;
|
|
130
|
-
let attempt = 0;
|
|
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);
|
|
143
|
+
}
|
|
144
|
+
if (signal?.aborted) {
|
|
145
|
+
unregisterWebhookToken(callbackToken);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
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;
|
|
131
166
|
while (!signal?.aborted) {
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
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})...`);
|
|
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...`);
|
|
140
170
|
await sleep(delay, signal);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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;
|
|
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;
|
|
179
177
|
}
|
|
178
|
+
reconnBackoff = Math.min(reconnBackoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
|
|
180
179
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
180
|
+
isReconnecting = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const healthTimer = setInterval(() => { void runHealthCheck(); }, HEALTH_CHECK_INTERVAL_MS);
|
|
184
|
+
// Keep the promise alive until abort signal fires
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
if (signal) {
|
|
187
|
+
signal.addEventListener('abort', () => {
|
|
188
|
+
console.log(`[E-Claw][${accountId}] Shutting down account`);
|
|
189
|
+
clearInterval(healthTimer);
|
|
190
|
+
client.unregisterCallback().catch(() => { });
|
|
191
|
+
unregisterWebhookToken(callbackToken);
|
|
192
|
+
resolve();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// No abort signal — resolve immediately (should not happen in normal use)
|
|
197
|
+
clearInterval(healthTimer);
|
|
198
|
+
resolve();
|
|
199
|
+
}
|
|
193
200
|
});
|
|
194
201
|
}
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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: '
|
|
44
|
+
id: 'openclaw-channel',
|
|
27
45
|
name: 'E-Claw',
|
|
28
|
-
description: 'E-Claw AI
|
|
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
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
export interface EClawAccountConfig {
|
|
3
3
|
enabled: boolean;
|
|
4
4
|
apiKey: string;
|
|
5
|
-
apiSecret
|
|
5
|
+
apiSecret?: string;
|
|
6
6
|
apiBase: string;
|
|
7
7
|
entityId: number;
|
|
8
8
|
botName?: string;
|
|
9
|
+
webhookUrl?: string;
|
|
9
10
|
}
|
|
10
11
|
/** Inbound message from E-Claw callback webhook */
|
|
11
12
|
export interface EClawInboundMessage {
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Create an HTTP request handler for inbound messages from E-Claw.
|
|
3
3
|
*
|
|
4
4
|
* When a user sends a message on E-Claw, the backend POSTs structured JSON
|
|
5
|
-
* to this webhook. We normalize it
|
|
5
|
+
* to this webhook. We normalize it into OpenClaw's native PascalCase context
|
|
6
|
+
* format and dispatch to the agent via dispatchReplyWithBufferedBlockDispatcher.
|
|
7
|
+
*
|
|
8
|
+
* The `deliver` callback sends the AI reply back to E-Claw via the API client.
|
|
6
9
|
*/
|
|
7
|
-
export declare function createWebhookHandler(expectedToken: string, accountId: string): (req: any, res: any) => Promise<void>;
|
|
10
|
+
export declare function createWebhookHandler(expectedToken: string, accountId: string, cfg: any): (req: any, res: any) => Promise<void>;
|
package/dist/webhook-handler.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { getPluginRuntime } from './runtime.js';
|
|
2
|
+
import { getClient } from './outbound.js';
|
|
2
3
|
/**
|
|
3
4
|
* Create an HTTP request handler for inbound messages from E-Claw.
|
|
4
5
|
*
|
|
5
6
|
* When a user sends a message on E-Claw, the backend POSTs structured JSON
|
|
6
|
-
* to this webhook. We normalize it
|
|
7
|
+
* to this webhook. We normalize it into OpenClaw's native PascalCase context
|
|
8
|
+
* format and dispatch to the agent via dispatchReplyWithBufferedBlockDispatcher.
|
|
9
|
+
*
|
|
10
|
+
* The `deliver` callback sends the AI reply back to E-Claw via the API client.
|
|
7
11
|
*/
|
|
8
|
-
export function createWebhookHandler(expectedToken, accountId
|
|
12
|
+
export function createWebhookHandler(expectedToken, accountId,
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
cfg // full openclaw config (ctx.cfg from startAccount)
|
|
15
|
+
) {
|
|
9
16
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
17
|
return async (req, res) => {
|
|
11
18
|
// Verify callback token
|
|
@@ -22,36 +29,62 @@ export function createWebhookHandler(expectedToken, accountId) {
|
|
|
22
29
|
// Dispatch to OpenClaw agent
|
|
23
30
|
try {
|
|
24
31
|
const rt = getPluginRuntime();
|
|
32
|
+
const client = getClient(accountId);
|
|
25
33
|
const conversationId = msg.conversationId || `${msg.deviceId}:${msg.entityId}`;
|
|
26
|
-
// Map E-Claw media
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// Map E-Claw media type to OpenClaw media type
|
|
35
|
+
const ocMediaType = msg.mediaType === 'photo' ? 'image'
|
|
36
|
+
: msg.mediaType === 'voice' ? 'audio'
|
|
37
|
+
: msg.mediaType === 'video' ? 'video'
|
|
38
|
+
: msg.mediaType ? 'file'
|
|
39
|
+
: undefined;
|
|
40
|
+
// Build context in OpenClaw's native PascalCase format
|
|
41
|
+
// (same convention as Telegram/LINE/WhatsApp channels)
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
43
|
const inboundCtx = {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
Surface: 'eclaw',
|
|
45
|
+
Provider: 'eclaw',
|
|
46
|
+
OriginatingChannel: 'eclaw',
|
|
47
|
+
AccountId: accountId,
|
|
48
|
+
From: msg.from,
|
|
49
|
+
To: conversationId,
|
|
50
|
+
OriginatingTo: msg.from,
|
|
51
|
+
SessionKey: conversationId,
|
|
52
|
+
Body: msg.text || '',
|
|
53
|
+
RawBody: msg.text || '',
|
|
54
|
+
CommandBody: msg.text || '',
|
|
55
|
+
ChatType: 'direct',
|
|
56
|
+
...(ocMediaType && msg.mediaUrl ? {
|
|
57
|
+
MediaType: ocMediaType,
|
|
58
|
+
MediaUrl: msg.mediaUrl,
|
|
59
|
+
} : {}),
|
|
51
60
|
};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
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);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
55
88
|
}
|
|
56
89
|
catch (err) {
|
|
57
90
|
console.error('[E-Claw] Webhook dispatch error:', err);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session webhook token registry.
|
|
3
|
+
*
|
|
4
|
+
* Each account generates a random callbackToken when it starts.
|
|
5
|
+
* The token is sent to E-Claw as part of the callback URL registration,
|
|
6
|
+
* and E-Claw echoes it back as `Authorization: Bearer <token>` on every push.
|
|
7
|
+
*
|
|
8
|
+
* The main route handler (registered on the gateway HTTP server) looks up
|
|
9
|
+
* the correct per-account handler by matching the Bearer token.
|
|
10
|
+
*/
|
|
11
|
+
type WebhookHandler = (req: any, res: any) => Promise<void>;
|
|
12
|
+
export declare function registerWebhookToken(callbackToken: string, accountId: string, handler: WebhookHandler): void;
|
|
13
|
+
export declare function unregisterWebhookToken(callbackToken: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Dispatch an incoming webhook request to the correct account handler.
|
|
16
|
+
* Verifies the Bearer token and routes to the matching handler.
|
|
17
|
+
*/
|
|
18
|
+
export declare function dispatchWebhook(req: any, res: any): Promise<void>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session webhook token registry.
|
|
3
|
+
*
|
|
4
|
+
* Each account generates a random callbackToken when it starts.
|
|
5
|
+
* The token is sent to E-Claw as part of the callback URL registration,
|
|
6
|
+
* and E-Claw echoes it back as `Authorization: Bearer <token>` on every push.
|
|
7
|
+
*
|
|
8
|
+
* The main route handler (registered on the gateway HTTP server) looks up
|
|
9
|
+
* the correct per-account handler by matching the Bearer token.
|
|
10
|
+
*/
|
|
11
|
+
const registry = new Map();
|
|
12
|
+
export function registerWebhookToken(callbackToken, accountId, handler) {
|
|
13
|
+
registry.set(callbackToken, { accountId, handler });
|
|
14
|
+
}
|
|
15
|
+
export function unregisterWebhookToken(callbackToken) {
|
|
16
|
+
registry.delete(callbackToken);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Dispatch an incoming webhook request to the correct account handler.
|
|
20
|
+
* Verifies the Bearer token and routes to the matching handler.
|
|
21
|
+
*/
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
export async function dispatchWebhook(req, res) {
|
|
24
|
+
const authHeader = req.headers?.authorization;
|
|
25
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
26
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const token = authHeader.slice(7);
|
|
31
|
+
const entry = registry.get(token);
|
|
32
|
+
if (!entry) {
|
|
33
|
+
// Unknown token — likely a stale push after a server restart
|
|
34
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
35
|
+
res.end(JSON.stringify({ error: 'Unknown token' }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await entry.handler(req, res);
|
|
39
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "
|
|
3
|
-
"name": "E-Claw",
|
|
4
|
-
"version": "1.0.0",
|
|
5
|
-
"description": "E-Claw AI
|
|
6
|
-
"channels": ["eclaw"],
|
|
7
|
-
"configSchema": {
|
|
8
|
-
"type": "object",
|
|
9
|
-
"properties": {
|
|
10
|
-
"accounts": {
|
|
11
|
-
"type": "object",
|
|
12
|
-
"additionalProperties": {
|
|
13
|
-
"type": "object",
|
|
14
|
-
"properties": {
|
|
15
|
-
"enabled": { "type": "boolean", "default": true },
|
|
16
|
-
"apiKey": { "type": "string", "description": "Channel API Key (eck_...)" },
|
|
17
|
-
"apiSecret": { "type": "string", "description": "Channel API Secret (ecs_...)" },
|
|
18
|
-
"apiBase": { "type": "string", "default": "https://eclawbot.com" },
|
|
19
|
-
"entityId": { "type": "number", "default": 0, "minimum": 0, "maximum": 7 },
|
|
20
|
-
"botName": { "type": "string", "maxLength": 20 }
|
|
21
|
-
},
|
|
22
|
-
"required": ["apiKey", "apiSecret"]
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-channel",
|
|
3
|
+
"name": "E-Claw",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "E-Claw AI chat platform channel for OpenClaw",
|
|
6
|
+
"channels": ["eclaw"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"accounts": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"enabled": { "type": "boolean", "default": true },
|
|
16
|
+
"apiKey": { "type": "string", "description": "Channel API Key (eck_...)" },
|
|
17
|
+
"apiSecret": { "type": "string", "description": "Channel API Secret (ecs_...)" },
|
|
18
|
+
"apiBase": { "type": "string", "default": "https://eclawbot.com" },
|
|
19
|
+
"entityId": { "type": "number", "default": 0, "minimum": 0, "maximum": 7 },
|
|
20
|
+
"botName": { "type": "string", "maxLength": 20 }
|
|
21
|
+
},
|
|
22
|
+
"required": ["apiKey", "apiSecret"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@eclaw/openclaw-channel",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "E-Claw channel plugin for OpenClaw — AI
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"import": "./dist/index.js",
|
|
11
|
-
"types": "./dist/index.d.ts"
|
|
12
|
-
}
|
|
13
|
-
},
|
|
14
|
-
"files": [
|
|
15
|
-
"dist/",
|
|
16
|
-
"openclaw.plugin.json",
|
|
17
|
-
"README.md"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsc",
|
|
21
|
-
"dev": "tsc --watch",
|
|
22
|
-
"test": "vitest run",
|
|
23
|
-
"lint": "tsc --noEmit",
|
|
24
|
-
"prepublishOnly": "npm run build"
|
|
25
|
-
},
|
|
26
|
-
"openclaw": {
|
|
27
|
-
"extensions": [
|
|
28
|
-
"./dist/index.js"
|
|
29
|
-
],
|
|
30
|
-
"channel": {
|
|
31
|
-
"id": "eclaw",
|
|
32
|
-
"label": "E-Claw",
|
|
33
|
-
"selectionLabel": "E-Claw (AI
|
|
34
|
-
"docsPath": "https://github.com/HankHuang0516/openclaw-channel-eclaw#readme",
|
|
35
|
-
"description": "Connect OpenClaw to E-Claw —
|
|
36
|
-
},
|
|
37
|
-
"install": {
|
|
38
|
-
"npmSpec": "@eclaw/openclaw-channel"
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
"keywords": [
|
|
42
|
-
"openclaw",
|
|
43
|
-
"openclaw-channel",
|
|
44
|
-
"channel",
|
|
45
|
-
"eclaw",
|
|
46
|
-
"ai-agent",
|
|
47
|
-
"live-wallpaper"
|
|
48
|
-
],
|
|
49
|
-
"author": "HankHuang",
|
|
50
|
-
"license": "MIT",
|
|
51
|
-
"repository": {
|
|
52
|
-
"type": "git",
|
|
53
|
-
"url": "
|
|
54
|
-
},
|
|
55
|
-
"devDependencies": {
|
|
56
|
-
"typescript": "^5.4",
|
|
57
|
-
"vitest": "^2.0",
|
|
58
|
-
"@types/node": "^20"
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@eclaw/openclaw-channel",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "E-Claw channel plugin for OpenClaw — AI chat platform for live wallpaper entities",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"openclaw.plugin.json",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"lint": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"openclaw": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./dist/index.js"
|
|
29
|
+
],
|
|
30
|
+
"channel": {
|
|
31
|
+
"id": "eclaw",
|
|
32
|
+
"label": "E-Claw",
|
|
33
|
+
"selectionLabel": "E-Claw (AI Live Wallpaper Chat)",
|
|
34
|
+
"docsPath": "https://github.com/HankHuang0516/openclaw-channel-eclaw#readme",
|
|
35
|
+
"description": "Connect OpenClaw to E-Claw — an AI chat platform for live wallpaper entities on Android."
|
|
36
|
+
},
|
|
37
|
+
"install": {
|
|
38
|
+
"npmSpec": "@eclaw/openclaw-channel"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"openclaw",
|
|
43
|
+
"openclaw-channel",
|
|
44
|
+
"channel",
|
|
45
|
+
"eclaw",
|
|
46
|
+
"ai-agent",
|
|
47
|
+
"live-wallpaper"
|
|
48
|
+
],
|
|
49
|
+
"author": "HankHuang",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "https://github.com/HankHuang0516/openclaw-channel-eclaw"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"typescript": "^5.4",
|
|
57
|
+
"vitest": "^2.0",
|
|
58
|
+
"@types/node": "^20"
|
|
59
|
+
}
|
|
60
|
+
}
|