@bowong/clawshow-gateway 2026.3.13 → 2026.3.16-10
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 +9 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -7
- package/dist/index.js.map +1 -1
- package/dist/src/config.d.ts +2 -2
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +19 -10
- package/dist/src/config.js.map +1 -1
- package/dist/src/gateway.d.ts +4 -3
- package/dist/src/gateway.d.ts.map +1 -1
- package/dist/src/gateway.js +211 -100
- package/dist/src/gateway.js.map +1 -1
- package/dist/src/index.d.ts +4 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/outbound.d.ts +1 -1
- package/dist/src/outbound.d.ts.map +1 -1
- package/dist/src/outbound.js +7 -7
- package/dist/src/outbound.js.map +1 -1
- package/dist/src/plugin.d.ts +2 -2
- package/dist/src/plugin.d.ts.map +1 -1
- package/dist/src/plugin.js +14 -14
- package/dist/src/plugin.js.map +1 -1
- package/dist/src/protocol.d.ts +45 -14
- package/dist/src/protocol.d.ts.map +1 -1
- package/dist/src/protocol.js +138 -3
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/security.d.ts +2 -2
- package/dist/src/security.d.ts.map +1 -1
- package/dist/src/security.js +4 -4
- package/dist/src/security.js.map +1 -1
- package/dist/src/setup.d.ts +1 -1
- package/dist/src/setup.d.ts.map +1 -1
- package/dist/src/setup.js +10 -8
- package/dist/src/setup.js.map +1 -1
- package/dist/src/types.d.ts +1 -1
- package/dist/src/types.d.ts.map +1 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +8 -7
- package/dist/src/runtime.d.ts +0 -9
- package/dist/src/runtime.d.ts.map +0 -1
- package/dist/src/runtime.js +0 -9
- package/dist/src/runtime.js.map +0 -1
- package/index.ts +0 -17
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @bowong/clawshow-gateway
|
|
2
2
|
|
|
3
|
-
OpenClaw
|
|
3
|
+
OpenClaw Clawshow 频道插件 —— 通过 WebSocket 中继服务器实现浏览器端聊天。
|
|
4
4
|
|
|
5
5
|
## 安装到 OpenClaw
|
|
6
6
|
|
|
@@ -39,14 +39,14 @@ openclaw plugins install ./packages/openclaw-channel-web
|
|
|
39
39
|
|
|
40
40
|
### 配置频道
|
|
41
41
|
|
|
42
|
-
这个插件是 channel plugin,配置写在 `channels.
|
|
42
|
+
这个插件是 channel plugin,配置写在 `channels.clawshow`,不是 `plugins.entries.clawshow-gateway.config`。
|
|
43
43
|
|
|
44
44
|
编辑 `~/.openclaw/openclaw.json`,添加:
|
|
45
45
|
|
|
46
46
|
```json5
|
|
47
47
|
{
|
|
48
48
|
channels: {
|
|
49
|
-
|
|
49
|
+
clawshow: {
|
|
50
50
|
enabled: true,
|
|
51
51
|
relayUrl: "wss://your-relay-server.example.com",
|
|
52
52
|
authToken: "your-secret-token",
|
|
@@ -71,7 +71,7 @@ openclaw plugins install ./packages/openclaw-channel-web
|
|
|
71
71
|
WEB_RELAY_AUTH_TOKEN=your-secret-token
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
对应代码里会优先读取 `channels.
|
|
74
|
+
对应代码里会优先读取 `channels.clawshow.authToken`,否则回退到 `WEB_RELAY_AUTH_TOKEN`。
|
|
75
75
|
|
|
76
76
|
### 本地 relay 联调示例
|
|
77
77
|
|
|
@@ -87,7 +87,7 @@ npm run dev
|
|
|
87
87
|
```json5
|
|
88
88
|
{
|
|
89
89
|
channels: {
|
|
90
|
-
|
|
90
|
+
clawshow: {
|
|
91
91
|
enabled: true,
|
|
92
92
|
relayUrl: "ws://192.168.0.117:8787",
|
|
93
93
|
authToken: "your-secret-token",
|
|
@@ -117,7 +117,7 @@ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
|
117
117
|
|
|
118
118
|
然后把同一个值配置到:
|
|
119
119
|
|
|
120
|
-
- `channels.
|
|
120
|
+
- `channels.clawshow.authToken` 或 `WEB_RELAY_AUTH_TOKEN`
|
|
121
121
|
- `apps/web-relay` 的 `OPENCLAW_AUTH_TOKEN`
|
|
122
122
|
|
|
123
123
|
### 多账户配置
|
|
@@ -125,7 +125,7 @@ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
|
125
125
|
```json5
|
|
126
126
|
{
|
|
127
127
|
channels: {
|
|
128
|
-
|
|
128
|
+
clawshow: {
|
|
129
129
|
enabled: true,
|
|
130
130
|
relayUrl: "wss://relay-1.example.com",
|
|
131
131
|
authToken: "token-1",
|
|
@@ -155,4 +155,4 @@ openclaw plugins list
|
|
|
155
155
|
openclaw plugins info web
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
如果 `
|
|
158
|
+
如果 `clawshow` 频道显示为已配置并且连接成功,说明安装完成。
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAI5D,QAAA,MAAM,MAAM;;;;;kBAKI,iBAAiB;CAGhC,CAAA;AAED,eAAe,MAAM,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { emptyPluginConfigSchema } from
|
|
2
|
-
import { webChannelPlugin } from
|
|
3
|
-
import { setWebRuntime } from "./src/runtime.js";
|
|
1
|
+
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
|
|
2
|
+
import { webChannelPlugin } from './src/plugin.js';
|
|
4
3
|
const plugin = {
|
|
5
|
-
id:
|
|
6
|
-
name:
|
|
7
|
-
description:
|
|
4
|
+
id: 'clawshow-gateway',
|
|
5
|
+
name: 'clawshow-gateway',
|
|
6
|
+
description: 'Web channel plugin — browser chat via WebSocket relay',
|
|
8
7
|
configSchema: emptyPluginConfigSchema(),
|
|
9
8
|
register(api) {
|
|
10
|
-
setWebRuntime(api.runtime);
|
|
11
9
|
api.registerChannel({ plugin: webChannelPlugin });
|
|
12
10
|
},
|
|
13
11
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAElD,MAAM,MAAM,GAAG;IACb,EAAE,EAAE,kBAAkB;IACtB,IAAI,EAAE,kBAAkB;IACxB,WAAW,EAAE,uDAAuD;IACpE,YAAY,EAAE,uBAAuB,EAAE;IACvC,QAAQ,CAAC,GAAsB;QAC7B,GAAG,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAA;IACnD,CAAC;CACF,CAAA;AAED,eAAe,MAAM,CAAA"}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Web channel configuration adapter.
|
|
3
3
|
* Resolves account configuration from OpenClaw config.
|
|
4
4
|
*/
|
|
5
|
-
import type { ChannelConfigAdapter, OpenClawConfig } from
|
|
6
|
-
import type { ResolvedWebAccount } from
|
|
5
|
+
import type { ChannelConfigAdapter, OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
6
|
+
import type { ResolvedWebAccount } from './types.js';
|
|
7
7
|
export declare function listWebAccountIds(cfg: OpenClawConfig): string[];
|
|
8
8
|
export declare function resolveWebAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedWebAccount;
|
|
9
9
|
export declare function isWebConfigured(account: ResolvedWebAccount): boolean;
|
package/dist/src/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAEV,oBAAoB,EACpB,cAAc,EACf,MAAM,qBAAqB,
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAEV,oBAAoB,EACpB,cAAc,EACf,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,kBAAkB,EAAoB,MAAM,YAAY,CAAA;AAQtE,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,EAAE,CAkB/D;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,cAAc,EACnB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GACxB,kBAAkB,CAgCpB;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAEpE;AAED,eAAO,MAAM,gBAAgB,EAAE,oBAAoB,CAAC,kBAAkB,CAuBrE,CAAA"}
|
package/dist/src/config.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Web channel configuration adapter.
|
|
3
3
|
* Resolves account configuration from OpenClaw config.
|
|
4
4
|
*/
|
|
5
|
-
const DEFAULT_ACCOUNT_ID =
|
|
5
|
+
const DEFAULT_ACCOUNT_ID = 'default';
|
|
6
6
|
export function listWebAccountIds(cfg) {
|
|
7
|
-
const webConfig = cfg.channels?.
|
|
7
|
+
const webConfig = cfg.channels?.clawshow;
|
|
8
8
|
if (!webConfig)
|
|
9
9
|
return [];
|
|
10
10
|
const ids = [];
|
|
@@ -24,20 +24,29 @@ export function listWebAccountIds(cfg) {
|
|
|
24
24
|
}
|
|
25
25
|
export function resolveWebAccount(cfg, accountId) {
|
|
26
26
|
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
27
|
-
const webConfig = cfg.channels?.
|
|
27
|
+
const webConfig = cfg.channels?.clawshow;
|
|
28
28
|
const accountConfig = id !== DEFAULT_ACCOUNT_ID ? webConfig?.accounts?.[id] : undefined;
|
|
29
|
-
const base = webConfig
|
|
29
|
+
const base = webConfig
|
|
30
|
+
? {
|
|
31
|
+
enabled: webConfig.enabled,
|
|
32
|
+
relayUrl: webConfig.relayUrl,
|
|
33
|
+
authToken: webConfig.authToken,
|
|
34
|
+
dmPolicy: webConfig.dmPolicy,
|
|
35
|
+
allowFrom: webConfig.allowFrom,
|
|
36
|
+
name: webConfig.name,
|
|
37
|
+
}
|
|
38
|
+
: {};
|
|
30
39
|
const merged = {
|
|
31
|
-
relayUrl:
|
|
40
|
+
relayUrl: '',
|
|
32
41
|
...base,
|
|
33
42
|
...accountConfig,
|
|
34
43
|
};
|
|
35
|
-
const authToken = merged.authToken ?? process.env.WEB_RELAY_AUTH_TOKEN ??
|
|
44
|
+
const authToken = merged.authToken ?? process.env.WEB_RELAY_AUTH_TOKEN ?? '';
|
|
36
45
|
return {
|
|
37
46
|
accountId: id,
|
|
38
47
|
name: merged.name,
|
|
39
48
|
enabled: merged.enabled !== false,
|
|
40
|
-
relayUrl: merged.relayUrl ??
|
|
49
|
+
relayUrl: merged.relayUrl ?? '',
|
|
41
50
|
authToken,
|
|
42
51
|
config: merged,
|
|
43
52
|
};
|
|
@@ -53,10 +62,10 @@ export const webConfigAdapter = {
|
|
|
53
62
|
isConfigured: (account, _cfg) => isWebConfigured(account),
|
|
54
63
|
unconfiguredReason: (account, _cfg) => {
|
|
55
64
|
if (!account.relayUrl)
|
|
56
|
-
return
|
|
65
|
+
return 'relayUrl not configured';
|
|
57
66
|
if (!account.authToken)
|
|
58
|
-
return
|
|
59
|
-
return
|
|
67
|
+
return 'authToken not configured (set WEB_RELAY_AUTH_TOKEN or channels.clawshow.authToken)';
|
|
68
|
+
return 'not configured';
|
|
60
69
|
},
|
|
61
70
|
describeAccount: (account, _cfg) => ({
|
|
62
71
|
accountId: account.accountId,
|
package/dist/src/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AASH,MAAM,kBAAkB,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/src/gateway.d.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* - Listens for browser messages → dispatches AI reply
|
|
7
7
|
* - Auto-reconnects with exponential backoff
|
|
8
8
|
* - Responds to AbortSignal for graceful shutdown
|
|
9
|
+
* - Detects zombie connections via pong timeout
|
|
9
10
|
*/
|
|
10
|
-
import WebSocket from
|
|
11
|
-
import type { ChannelGatewayAdapter, ChannelGatewayContext } from
|
|
12
|
-
import type { ResolvedWebAccount } from
|
|
11
|
+
import WebSocket from 'ws';
|
|
12
|
+
import type { ChannelGatewayAdapter, ChannelGatewayContext } from 'openclaw/plugin-sdk';
|
|
13
|
+
import type { ResolvedWebAccount } from './types.js';
|
|
13
14
|
export declare function getActiveWebSocket(accountId?: string): WebSocket | null;
|
|
14
15
|
export declare function startWebGateway(ctx: ChannelGatewayContext<ResolvedWebAccount>): Promise<void>;
|
|
15
16
|
export declare function sendTextToSession(sessionId: string, text: string, accountId?: string): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../src/gateway.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../src/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,SAAS,MAAM,IAAI,CAAA;AAC1B,OAAO,KAAK,EAEV,qBAAqB,EACrB,qBAAqB,EACtB,MAAM,qBAAqB,CAAA;AAQ5B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAYpD,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAOvE;AAED,wBAAsB,eAAe,CACnC,GAAG,EAAE,qBAAqB,CAAC,kBAAkB,CAAC,GAC7C,OAAO,CAAC,IAAI,CAAC,CA0Pf;AAyGD,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAmBT;AAED,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAiBT;AAED,eAAO,MAAM,iBAAiB,EAAE,qBAAqB,CAAC,kBAAkB,CAIvE,CAAA"}
|
package/dist/src/gateway.js
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
* - Listens for browser messages → dispatches AI reply
|
|
7
7
|
* - Auto-reconnects with exponential backoff
|
|
8
8
|
* - Responds to AbortSignal for graceful shutdown
|
|
9
|
+
* - Detects zombie connections via pong timeout
|
|
9
10
|
*/
|
|
10
|
-
import WebSocket from
|
|
11
|
-
import { generateMessageId, HEARTBEAT_INTERVAL_MS, parseRelayMessage, serializeRelayMessage, } from
|
|
11
|
+
import WebSocket from 'ws';
|
|
12
|
+
import { generateMessageId, HEARTBEAT_INTERVAL_MS, parseRelayMessage, serializeRelayMessage, } from './protocol.js';
|
|
12
13
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
13
14
|
const INITIAL_RECONNECT_MS = 2_000;
|
|
14
15
|
const MAX_RECONNECT_MS = 60_000;
|
|
15
16
|
const BACKOFF_FACTOR = 2;
|
|
17
|
+
const PONG_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5; // 75s — allow 2 missed pongs
|
|
18
|
+
const COOLDOWN_MS = 5 * 60_000; // 5 min cooldown after max reconnect attempts
|
|
16
19
|
/** Per-account gateway WebSocket references for outbound message sending. */
|
|
17
20
|
const activeWsMap = new Map();
|
|
18
21
|
export function getActiveWebSocket(accountId) {
|
|
@@ -29,17 +32,21 @@ export async function startWebGateway(ctx) {
|
|
|
29
32
|
const { account, abortSignal, log } = ctx;
|
|
30
33
|
let reconnectAttempts = 0;
|
|
31
34
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
|
35
|
+
let reconnectTimer = null; // [R3] track timer for abort cleanup
|
|
32
36
|
const connect = () => new Promise((resolve, reject) => {
|
|
33
37
|
if (abortSignal.aborted) {
|
|
34
38
|
resolve();
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
37
|
-
const baseUrl = account.relayUrl.replace(/\/+$/,
|
|
38
|
-
const wsUrl = baseUrl.endsWith(
|
|
41
|
+
const baseUrl = account.relayUrl.replace(/\/+$/, '');
|
|
42
|
+
const wsUrl = baseUrl.endsWith('/openclaw')
|
|
43
|
+
? baseUrl
|
|
44
|
+
: `${baseUrl}/openclaw`;
|
|
39
45
|
log?.info(`[web:${account.accountId}] connecting to relay: ${wsUrl}`);
|
|
40
46
|
const ws = new WebSocket(wsUrl);
|
|
41
47
|
let heartbeatTimer = null;
|
|
42
48
|
let authenticated = false;
|
|
49
|
+
let lastPongAt = Date.now(); // [H1] track last pong for zombie detection
|
|
43
50
|
const cleanup = () => {
|
|
44
51
|
activeWsMap.delete(account.accountId);
|
|
45
52
|
if (heartbeatTimer) {
|
|
@@ -53,74 +60,114 @@ export async function startWebGateway(ctx) {
|
|
|
53
60
|
lastStopAt: Date.now(),
|
|
54
61
|
});
|
|
55
62
|
};
|
|
56
|
-
ws.on(
|
|
63
|
+
ws.on('open', () => {
|
|
57
64
|
log?.info(`[web:${account.accountId}] connected, authenticating...`);
|
|
58
65
|
// Send auth handshake
|
|
59
66
|
ws.send(serializeRelayMessage({
|
|
60
|
-
type:
|
|
61
|
-
role:
|
|
67
|
+
type: 'auth',
|
|
68
|
+
role: 'openclaw',
|
|
62
69
|
token: account.authToken,
|
|
63
70
|
}));
|
|
64
71
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (msg
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
72
|
+
// [E1] Wrap entire message handler in void async IIFE with try/catch
|
|
73
|
+
// to prevent unhandled promise rejections from crashing the process
|
|
74
|
+
ws.on('message', (data) => {
|
|
75
|
+
void (async () => {
|
|
76
|
+
try {
|
|
77
|
+
const raw = typeof data === 'string' ? data : data.toString('utf-8');
|
|
78
|
+
const msg = parseRelayMessage(raw);
|
|
79
|
+
if (!msg)
|
|
80
|
+
return;
|
|
81
|
+
switch (msg.type) {
|
|
82
|
+
case 'auth_result': {
|
|
83
|
+
if (msg.ok) {
|
|
84
|
+
log?.info(`[web:${account.accountId}] authenticated successfully`);
|
|
85
|
+
authenticated = true;
|
|
86
|
+
activeWsMap.set(account.accountId, ws);
|
|
87
|
+
reconnectAttempts = 0;
|
|
88
|
+
reconnectMs = INITIAL_RECONNECT_MS;
|
|
89
|
+
lastPongAt = Date.now(); // [H1] reset pong timer on fresh connection
|
|
90
|
+
ctx.setStatus({
|
|
91
|
+
...ctx.getStatus(),
|
|
92
|
+
connected: true,
|
|
93
|
+
running: true,
|
|
94
|
+
lastStartAt: Date.now(),
|
|
95
|
+
lastError: null,
|
|
96
|
+
restartPending: false, // [M2] clear restartPending on successful reconnect
|
|
97
|
+
});
|
|
98
|
+
// [H1] Start heartbeat with pong timeout detection
|
|
99
|
+
heartbeatTimer = setInterval(() => {
|
|
100
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
101
|
+
return;
|
|
102
|
+
// Check if pong timed out — zombie connection detection
|
|
103
|
+
const elapsed = Date.now() - lastPongAt;
|
|
104
|
+
if (elapsed > PONG_TIMEOUT_MS) {
|
|
105
|
+
log?.error(`[web:${account.accountId}] pong timeout (${elapsed}ms elapsed, limit ${PONG_TIMEOUT_MS}ms), forcing reconnect`);
|
|
106
|
+
ws.close(4000, 'Pong timeout');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
ws.send(serializeRelayMessage({
|
|
110
|
+
type: 'ping',
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
}));
|
|
113
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
89
114
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
115
|
+
else {
|
|
116
|
+
log?.error(`[web:${account.accountId}] auth failed: ${msg.error}`);
|
|
117
|
+
ws.close(4001, 'Authentication failed');
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'message': {
|
|
122
|
+
if (!authenticated)
|
|
123
|
+
break;
|
|
124
|
+
// Incoming message from browser → dispatch to AI
|
|
125
|
+
await handleIncomingMessage(ctx, msg);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'typing': {
|
|
129
|
+
// Browser typing indicator — can log or pass through
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'pong': {
|
|
133
|
+
// [H1] Update last pong timestamp for zombie detection
|
|
134
|
+
lastPongAt = Date.now();
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'user_status': {
|
|
138
|
+
// Browser user online/offline notification
|
|
139
|
+
log?.info(`[web:${account.accountId}] user_status: ${msg.action} userId=${msg.userId} sessionId=${msg.sessionId}${msg.userName ? ` name=${msg.userName}` : ''}`);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
break;
|
|
95
144
|
}
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
case "message": {
|
|
99
|
-
if (!authenticated)
|
|
100
|
-
break;
|
|
101
|
-
// Incoming message from browser → dispatch to AI
|
|
102
|
-
await handleIncomingMessage(ctx, msg);
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
case "typing": {
|
|
106
|
-
// Browser typing indicator — can log or pass through
|
|
107
|
-
break;
|
|
108
145
|
}
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
146
|
+
catch (err) {
|
|
147
|
+
// [E1] Catch all async errors to prevent unhandled promise rejection
|
|
148
|
+
log?.error(`[web:${account.accountId}] message handler error: ${err instanceof Error ? err.message : String(err)}`);
|
|
112
149
|
}
|
|
113
|
-
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
150
|
+
})();
|
|
116
151
|
});
|
|
117
|
-
ws.on(
|
|
118
|
-
log?.warn(`[web:${account.accountId}] disconnected (code=${code}, reason=${reason.toString(
|
|
152
|
+
ws.on('close', (code, reason) => {
|
|
153
|
+
log?.warn(`[web:${account.accountId}] disconnected (code=${code}, reason=${reason.toString('utf-8')})`);
|
|
119
154
|
cleanup();
|
|
120
155
|
if (abortSignal.aborted) {
|
|
121
156
|
resolve();
|
|
122
157
|
return;
|
|
123
158
|
}
|
|
159
|
+
// [R1] Authentication failure is unrecoverable — do not reconnect
|
|
160
|
+
if (code === 4001) {
|
|
161
|
+
const errMessage = 'Authentication failed — not reconnecting';
|
|
162
|
+
log?.error(`[web:${account.accountId}] ${errMessage}`);
|
|
163
|
+
ctx.setStatus({
|
|
164
|
+
...ctx.getStatus(),
|
|
165
|
+
lastError: errMessage,
|
|
166
|
+
running: false,
|
|
167
|
+
});
|
|
168
|
+
reject(new Error(errMessage));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
124
171
|
// Schedule reconnect
|
|
125
172
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
126
173
|
reconnectAttempts++;
|
|
@@ -132,36 +179,58 @@ export async function startWebGateway(ctx) {
|
|
|
132
179
|
restartPending: true,
|
|
133
180
|
reconnectAttempts,
|
|
134
181
|
});
|
|
135
|
-
setTimeout(() => {
|
|
182
|
+
reconnectTimer = setTimeout(() => {
|
|
183
|
+
reconnectTimer = null;
|
|
136
184
|
reconnectMs = Math.min(reconnectMs * BACKOFF_FACTOR, MAX_RECONNECT_MS);
|
|
137
185
|
connect().then(resolve, reject);
|
|
138
186
|
}, delay);
|
|
139
187
|
}
|
|
140
188
|
else {
|
|
141
|
-
|
|
142
|
-
log?.
|
|
189
|
+
// [R2] Max attempts reached — cooldown then retry instead of permanent failure
|
|
190
|
+
log?.warn(`[web:${account.accountId}] max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, cooling down for ${COOLDOWN_MS / 1000}s`);
|
|
143
191
|
ctx.setStatus({
|
|
144
192
|
...ctx.getStatus(),
|
|
145
|
-
lastError:
|
|
146
|
-
|
|
193
|
+
lastError: `Max reconnect attempts reached, cooling down for ${COOLDOWN_MS / 1000}s`,
|
|
194
|
+
restartPending: true,
|
|
147
195
|
});
|
|
148
|
-
|
|
196
|
+
reconnectTimer = setTimeout(() => {
|
|
197
|
+
reconnectTimer = null;
|
|
198
|
+
if (abortSignal.aborted) {
|
|
199
|
+
resolve();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
log?.info(`[web:${account.accountId}] cooldown complete, resetting reconnect counter`);
|
|
203
|
+
reconnectAttempts = 0;
|
|
204
|
+
reconnectMs = INITIAL_RECONNECT_MS;
|
|
205
|
+
connect().then(resolve, reject);
|
|
206
|
+
}, COOLDOWN_MS);
|
|
149
207
|
}
|
|
150
208
|
});
|
|
151
|
-
|
|
209
|
+
// [M1] Update lastError status in error handler
|
|
210
|
+
ws.on('error', err => {
|
|
152
211
|
log?.error(`[web:${account.accountId}] WebSocket error: ${err.message}`);
|
|
212
|
+
ctx.setStatus({
|
|
213
|
+
...ctx.getStatus(),
|
|
214
|
+
lastError: err.message,
|
|
215
|
+
});
|
|
153
216
|
// Error triggers close event, so reconnect logic runs there
|
|
154
217
|
});
|
|
155
218
|
// Listen for abort signal
|
|
156
219
|
const onAbort = () => {
|
|
157
220
|
log?.info(`[web:${account.accountId}] shutting down (abort signal)`);
|
|
158
221
|
cleanup();
|
|
159
|
-
|
|
160
|
-
|
|
222
|
+
// [R3] Clear pending reconnect timer on abort
|
|
223
|
+
if (reconnectTimer) {
|
|
224
|
+
clearTimeout(reconnectTimer);
|
|
225
|
+
reconnectTimer = null;
|
|
226
|
+
}
|
|
227
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
228
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
229
|
+
ws.close(1000, 'Shutting down');
|
|
161
230
|
}
|
|
162
231
|
resolve();
|
|
163
232
|
};
|
|
164
|
-
abortSignal.addEventListener(
|
|
233
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
165
234
|
});
|
|
166
235
|
return connect();
|
|
167
236
|
}
|
|
@@ -173,61 +242,103 @@ async function handleIncomingMessage(ctx, msg) {
|
|
|
173
242
|
...ctx.getStatus(),
|
|
174
243
|
lastInboundAt: Date.now(),
|
|
175
244
|
});
|
|
176
|
-
|
|
177
|
-
// the inbound message shape. Cast required because the SDK type exposes the
|
|
178
|
-
// internal dispatch-from-config signature, not the plugin-facing wrapper.
|
|
179
|
-
const dispatch = channelRuntime?.reply
|
|
180
|
-
?.dispatchReplyFromConfig;
|
|
181
|
-
if (!dispatch) {
|
|
245
|
+
if (!channelRuntime) {
|
|
182
246
|
log?.warn(`[web:${ctx.account.accountId}] channelRuntime not available — cannot dispatch AI reply`);
|
|
183
|
-
|
|
184
|
-
sendTextToSession(msg.sessionId, "AI is not available at the moment. Please try again later.", ctx.accountId);
|
|
247
|
+
void sendTextToSession(msg.sessionId, 'AI is not available at the moment. Please try again later.', ctx.accountId);
|
|
185
248
|
return;
|
|
186
249
|
}
|
|
250
|
+
const core = channelRuntime;
|
|
187
251
|
try {
|
|
188
|
-
|
|
252
|
+
// 1. Resolve agent routing
|
|
253
|
+
const route = core.routing.resolveAgentRoute({
|
|
189
254
|
cfg: ctx.cfg,
|
|
190
|
-
channel:
|
|
191
|
-
chatType: "direct",
|
|
192
|
-
from: msg.sessionId,
|
|
193
|
-
to: msg.sessionId,
|
|
194
|
-
text: msg.text,
|
|
255
|
+
channel: 'clawshow',
|
|
195
256
|
accountId: ctx.accountId,
|
|
196
|
-
|
|
257
|
+
peer: { kind: 'direct', id: msg.sessionId },
|
|
258
|
+
});
|
|
259
|
+
log?.info(`[web:${ctx.account.accountId}] route: agent=${route.agentId} session=${route.sessionKey}`);
|
|
260
|
+
// 2. Build MsgContext with PascalCase fields (will be finalized by dispatchInboundMessageWithBufferedDispatcher)
|
|
261
|
+
const ctxPayload = {
|
|
262
|
+
Body: msg.text,
|
|
263
|
+
BodyForAgent: msg.text,
|
|
264
|
+
RawBody: msg.text,
|
|
265
|
+
CommandBody: msg.text,
|
|
266
|
+
From: `web:${msg.sessionId}`,
|
|
267
|
+
To: msg.sessionId,
|
|
268
|
+
SessionKey: route.sessionKey,
|
|
269
|
+
AccountId: ctx.accountId,
|
|
270
|
+
ChatType: 'direct',
|
|
271
|
+
Provider: 'clawshow',
|
|
272
|
+
Surface: 'clawshow',
|
|
273
|
+
MessageSid: msg.id,
|
|
274
|
+
Timestamp: msg.timestamp || Date.now(),
|
|
275
|
+
CommandAuthorized: false,
|
|
276
|
+
};
|
|
277
|
+
// 3. Use the high-level dispatch API that handles all lifecycle internally
|
|
278
|
+
sendTypingToSession(msg.sessionId, true, ctx.accountId);
|
|
279
|
+
const result = await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
280
|
+
ctx: ctxPayload,
|
|
281
|
+
cfg: ctx.cfg,
|
|
282
|
+
dispatcherOptions: {
|
|
283
|
+
deliver: async (payload, _info) => {
|
|
284
|
+
if (payload.text) {
|
|
285
|
+
void sendTextToSession(msg.sessionId, payload.text, ctx.accountId);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
onError: (err, info) => {
|
|
289
|
+
log?.error(`[web:${ctx.account.accountId}] reply delivery error (${info.kind}): ${err}`);
|
|
290
|
+
},
|
|
291
|
+
},
|
|
197
292
|
});
|
|
293
|
+
sendTypingToSession(msg.sessionId, false, ctx.accountId);
|
|
294
|
+
log?.info(`[web:${ctx.account.accountId}] dispatch complete (queuedFinal=${result.queuedFinal}, replies=${result.counts.final})`);
|
|
198
295
|
}
|
|
199
296
|
catch (err) {
|
|
200
|
-
|
|
201
|
-
|
|
297
|
+
sendTypingToSession(msg.sessionId, false, ctx.accountId);
|
|
298
|
+
// [M3] Log full stack trace but only send generic error to user
|
|
299
|
+
const fullMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
|
|
300
|
+
log?.error(`[web:${ctx.account.accountId}] dispatch error: ${fullMsg}`);
|
|
301
|
+
void sendTextToSession(msg.sessionId, `Sorry, an error occurred while processing your message. Please try again.`, ctx.accountId);
|
|
202
302
|
}
|
|
203
303
|
}
|
|
204
304
|
// ── Outbound Helpers ────────────────────────────────────────────────
|
|
305
|
+
// [E2] Try/catch around ws.send to handle race between readyState check and send
|
|
205
306
|
export function sendTextToSession(sessionId, text, accountId) {
|
|
206
307
|
const ws = getActiveWebSocket(accountId);
|
|
207
308
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
208
309
|
return false;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
310
|
+
try {
|
|
311
|
+
const msg = {
|
|
312
|
+
type: 'message',
|
|
313
|
+
id: generateMessageId(),
|
|
314
|
+
sessionId,
|
|
315
|
+
from: 'ai',
|
|
316
|
+
text,
|
|
317
|
+
timestamp: Date.now(),
|
|
318
|
+
};
|
|
319
|
+
ws.send(serializeRelayMessage(msg));
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
219
325
|
}
|
|
220
326
|
export function sendTypingToSession(sessionId, isTyping, accountId) {
|
|
221
327
|
const ws = getActiveWebSocket(accountId);
|
|
222
328
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
223
329
|
return false;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
330
|
+
try {
|
|
331
|
+
ws.send(serializeRelayMessage({
|
|
332
|
+
type: 'typing',
|
|
333
|
+
sessionId,
|
|
334
|
+
from: 'ai',
|
|
335
|
+
isTyping,
|
|
336
|
+
}));
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
231
342
|
}
|
|
232
343
|
export const webGatewayAdapter = {
|
|
233
344
|
startAccount: async (ctx) => {
|