feat: import openclaw channel web package
This commit is contained in:
125
README.md
Normal file
125
README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: clawshow-gateway-connect
|
||||||
|
description: Install and activate @bowong/clawshow-gateway in OpenClaw, then migrate existing Gateway channel configuration to ClawShow with rollback safety. Use when a user wants one-step plugin install plus config migration without repeating requirements each run, applying changes via Config RPC config.apply full replace with minimal file access and no default outbound test traffic.
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
primaryEnv: CLAWSHOW_AUTH_TOKEN
|
||||||
|
requires:
|
||||||
|
bins:
|
||||||
|
- openclaw
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clawshow Gateway Connect
|
||||||
|
|
||||||
|
Execute a safe, minimal install-and-migrate flow from current OpenClaw settings to ClawShowGateway without breaking unrelated configuration.
|
||||||
|
|
||||||
|
## Runtime Inputs
|
||||||
|
|
||||||
|
- Required: `authToken`
|
||||||
|
- Optional: `relayUrl` (default `wss://clawshow-api.bowong.cc`)
|
||||||
|
- If `authToken` is missing, ask once and stop before editing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Install ws dependency
|
||||||
|
- Run `npm i ws` in the project root before proceeding.
|
||||||
|
- Confirm installation succeeds before continuing.
|
||||||
|
|
||||||
|
2. Identify current config source
|
||||||
|
- Search only OpenClaw gateway config files (`*.json`, `*.yaml`, `*.yml`) in the active project path.
|
||||||
|
- Do not read `.env`, secrets files, deployment manifests, or unrelated workspace files.
|
||||||
|
- Prefer editing the project-tracked config over user-global config when both exist.
|
||||||
|
- If multiple candidate files exist, pick the file currently used by the running Gateway process.
|
||||||
|
|
||||||
|
3. Create rollback copy before edits
|
||||||
|
- Save a sibling backup copy of the target config file with timestamp suffix (for example `.bak.20260313-1405`).
|
||||||
|
- Never delete the backup in the same task.
|
||||||
|
|
||||||
|
4. Check OpenClaw version gate (before plugin install)
|
||||||
|
- Query current OpenClaw version using the supported command only:
|
||||||
|
- `openclaw version`
|
||||||
|
- Compare against `2026.3.2` using OpenClaw's version ordering.
|
||||||
|
- If current version is earlier than `2026.3.2`, upgrade OpenClaw using `openclaw update` first, then re-run:
|
||||||
|
- `openclaw version`
|
||||||
|
- Continue only after version check/upgrade is complete.
|
||||||
|
|
||||||
|
5. Install plugin/package
|
||||||
|
- Install using OpenClaw plugin manager only:
|
||||||
|
- `openclaw plugins install @bowong/clawshow-gateway`
|
||||||
|
- Verify installation:
|
||||||
|
- `openclaw plugins list`
|
||||||
|
- Confirm package id is exactly `@bowong/clawshow-gateway`.
|
||||||
|
- If already installed, do not reinstall unless version repair is needed.
|
||||||
|
|
||||||
|
6. Migrate channel config to ClawShow
|
||||||
|
- Ensure package id references `@bowong/clawshow-gateway` where plugin/package declarations are required.
|
||||||
|
- Ensure channel section exists under `channels.clawshow`.
|
||||||
|
- Set:
|
||||||
|
- `channels.clawshow.enabled: true` (required)
|
||||||
|
- `channels.clawshow.relayUrl: "wss://clawshow-api.bowong.cc"` (optional)
|
||||||
|
- `channels.clawshow.authToken: "<real token>"` (required)
|
||||||
|
- `channels.clawshow.name: "ClawShow Gateway"` (required)
|
||||||
|
- `channels.clawshow.dmPolicy: "open"` (required)
|
||||||
|
- `channels.clawshow.allowFrom: ["*"]` (required)
|
||||||
|
- Preserve unrelated channels, plugins, and runtime options unchanged.
|
||||||
|
|
||||||
|
7. Remove conflicting legacy routing only when necessary
|
||||||
|
- If older channel config routes the same outbound traffic target, disable only the overlapping route.
|
||||||
|
- Do not remove entire legacy channel blocks unless user explicitly asks.
|
||||||
|
|
||||||
|
8. Validate config shape
|
||||||
|
- Confirm JSON/YAML syntax after edit.
|
||||||
|
- Confirm required keys exist for ClawShow (`authToken` at minimum).
|
||||||
|
- Confirm package/plugin id and channel id are consistent (`clawshow`).
|
||||||
|
- Confirm `enabled`, `relayUrl`, `authToken`, `name`, `dmPolicy`, and `allowFrom` are directly under `channels.clawshow` (no `default` layer).
|
||||||
|
|
||||||
|
9. Apply config (Config RPC only)
|
||||||
|
- Follow `Config RPC (programmatic updates)` exactly, using `config.apply (full replace)`.
|
||||||
|
- Do not use direct file-edit-only activation, `config.patch`, or manual restart as the primary path.
|
||||||
|
- Execute:
|
||||||
|
- `openclaw gateway call config.get --params '{}'`
|
||||||
|
- Capture `payload.hash` as `baseHash`.
|
||||||
|
- Build the full post-migration config as one JSON5 string in `raw`.
|
||||||
|
- `openclaw gateway call config.apply --params '{ "raw": "<full-json5-config>", "baseHash": "<hash>", "note": "migrate to @bowong/clawshow-gateway" }'`
|
||||||
|
- Respect control-plane rate limits for write RPCs (`config.apply`, `config.patch`, `update.run`): 3 requests per 60 seconds per `deviceId+clientIp`.
|
||||||
|
- If apply fails due to stale hash/conflict, call `config.get` again, rebase on newest config, and retry once.
|
||||||
|
|
||||||
|
10. Verify runtime behavior
|
||||||
|
- Do not force a manual restart here; `config.apply` already validates, writes, and restarts in one step.
|
||||||
|
- Run status/probe command if available.
|
||||||
|
- Do not send outbound test messages by default.
|
||||||
|
- Only send an external test message if the user explicitly asks for network verification.
|
||||||
|
|
||||||
|
11. Report outcome
|
||||||
|
- Include the backup file path.
|
||||||
|
- Include verification command results.
|
||||||
|
- If verification is blocked, state the blocker and provide the exact next command.
|
||||||
|
|
||||||
|
## Canonical Target Snippet
|
||||||
|
|
||||||
|
Use this structure when writing or repairing JSON config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"clawshow": {
|
||||||
|
"enabled": true,
|
||||||
|
"relayUrl": "wss://clawshow-api.bowong.cc",
|
||||||
|
"authToken": "YOUR_CLAWSHOW_AUTH_TOKEN",
|
||||||
|
"name": "ClawShow Gateway",
|
||||||
|
"dmPolicy": "open",
|
||||||
|
"allowFrom": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Keep edits minimal and reversible.
|
||||||
|
- Never invent secrets; leave placeholders for missing keys.
|
||||||
|
- Never exfiltrate secrets from local files.
|
||||||
|
- Never rewrite formatting style of the whole file.
|
||||||
|
- Never remove unrelated keys to "clean up".
|
||||||
15
index.ts
Normal file
15
index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
|
||||||
|
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk'
|
||||||
|
import { webChannelPlugin } from './src/plugin.js'
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: 'clawshow-gateway',
|
||||||
|
name: 'clawshow-gateway',
|
||||||
|
description: 'Web channel plugin — browser chat via WebSocket relay',
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: OpenClawPluginApi) {
|
||||||
|
api.registerChannel({ plugin: webChannelPlugin })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
9
openclaw.plugin.json
Normal file
9
openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "clawshow-gateway",
|
||||||
|
"channels": ["clawshow"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@bowong/clawshow-gateway",
|
||||||
|
"version": "2026.3.16-12",
|
||||||
|
"description": "OpenClaw Web channel plugin — browser-based chat via WebSocket relay",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/src/index.js",
|
||||||
|
"types": "dist/src/index.d.ts",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/src/index.js",
|
||||||
|
"types": "./dist/src/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"openclaw.plugin.json",
|
||||||
|
"package.json",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": [
|
||||||
|
"./dist/index.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\" && bun x tsc -p tsconfig.build.json",
|
||||||
|
"typecheck": "bun x tsc --noEmit",
|
||||||
|
"clean": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\""
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"openclaw": "^2026.3.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ws": "^8.18.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/config.ts
Normal file
103
src/config.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Web channel configuration adapter.
|
||||||
|
* Resolves account configuration from OpenClaw config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelConfigAdapter,
|
||||||
|
OpenClawConfig,
|
||||||
|
} from 'openclaw/plugin-sdk'
|
||||||
|
import type { ResolvedWebAccount, WebAccountConfig } from './types.js'
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_ID = 'default'
|
||||||
|
|
||||||
|
type WebChannelConfig = WebAccountConfig & {
|
||||||
|
accounts?: Record<string, WebAccountConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWebAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
|
const webConfig = cfg.channels?.clawshow as WebChannelConfig | undefined
|
||||||
|
if (!webConfig) return []
|
||||||
|
|
||||||
|
const ids: string[] = []
|
||||||
|
// Always include default if top-level config exists
|
||||||
|
if (webConfig.relayUrl) {
|
||||||
|
ids.push(DEFAULT_ACCOUNT_ID)
|
||||||
|
}
|
||||||
|
// Additional named accounts
|
||||||
|
if (webConfig.accounts) {
|
||||||
|
for (const id of Object.keys(webConfig.accounts)) {
|
||||||
|
if (!ids.includes(id)) {
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWebAccount(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
accountId?: string | null,
|
||||||
|
): ResolvedWebAccount {
|
||||||
|
const id = accountId ?? DEFAULT_ACCOUNT_ID
|
||||||
|
const webConfig = cfg.channels?.clawshow as WebChannelConfig | undefined
|
||||||
|
const accountConfig =
|
||||||
|
id !== DEFAULT_ACCOUNT_ID ? webConfig?.accounts?.[id] : undefined
|
||||||
|
|
||||||
|
const base = webConfig
|
||||||
|
? {
|
||||||
|
enabled: webConfig.enabled,
|
||||||
|
relayUrl: webConfig.relayUrl,
|
||||||
|
authToken: webConfig.authToken,
|
||||||
|
dmPolicy: webConfig.dmPolicy,
|
||||||
|
allowFrom: webConfig.allowFrom,
|
||||||
|
name: webConfig.name,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
const merged: WebAccountConfig = {
|
||||||
|
relayUrl: '',
|
||||||
|
...base,
|
||||||
|
...accountConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = merged.authToken ?? process.env.WEB_RELAY_AUTH_TOKEN ?? ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: id,
|
||||||
|
name: merged.name,
|
||||||
|
enabled: merged.enabled !== false,
|
||||||
|
relayUrl: merged.relayUrl ?? '',
|
||||||
|
authToken,
|
||||||
|
config: merged,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebConfigured(account: ResolvedWebAccount): boolean {
|
||||||
|
return Boolean(account.relayUrl && account.authToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webConfigAdapter: ChannelConfigAdapter<ResolvedWebAccount> = {
|
||||||
|
listAccountIds: listWebAccountIds,
|
||||||
|
resolveAccount: resolveWebAccount,
|
||||||
|
defaultAccountId: (_cfg: OpenClawConfig) => DEFAULT_ACCOUNT_ID,
|
||||||
|
isEnabled: (account: ResolvedWebAccount, _cfg: OpenClawConfig) =>
|
||||||
|
account.enabled,
|
||||||
|
isConfigured: (account: ResolvedWebAccount, _cfg: OpenClawConfig) =>
|
||||||
|
isWebConfigured(account),
|
||||||
|
unconfiguredReason: (account: ResolvedWebAccount, _cfg: OpenClawConfig) => {
|
||||||
|
if (!account.relayUrl) return 'relayUrl not configured'
|
||||||
|
if (!account.authToken)
|
||||||
|
return 'authToken not configured (set WEB_RELAY_AUTH_TOKEN or channels.clawshow.authToken)'
|
||||||
|
return 'not configured'
|
||||||
|
},
|
||||||
|
describeAccount: (
|
||||||
|
account: ResolvedWebAccount,
|
||||||
|
_cfg: OpenClawConfig,
|
||||||
|
): ChannelAccountSnapshot => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: isWebConfigured(account),
|
||||||
|
}),
|
||||||
|
}
|
||||||
580
src/gateway.ts
Normal file
580
src/gateway.ts
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
/**
|
||||||
|
* Web channel gateway — reverse WebSocket connection to relay.
|
||||||
|
*
|
||||||
|
* Follows the Telegram monitorTelegramProvider pattern:
|
||||||
|
* - Connects to relay as "openclaw" role
|
||||||
|
* - Listens for browser messages → dispatches AI reply
|
||||||
|
* - Auto-reconnects with exponential backoff
|
||||||
|
* - Responds to AbortSignal for graceful shutdown
|
||||||
|
* - Detects zombie connections via pong timeout
|
||||||
|
*/
|
||||||
|
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelGatewayAdapter,
|
||||||
|
ChannelGatewayContext,
|
||||||
|
} from 'openclaw/plugin-sdk'
|
||||||
|
import {
|
||||||
|
type ChatMessage,
|
||||||
|
generateMessageId,
|
||||||
|
HEARTBEAT_INTERVAL_MS,
|
||||||
|
parseRelayMessage,
|
||||||
|
type SessionEventMessage,
|
||||||
|
type SessionQueueMode,
|
||||||
|
serializeRelayMessage,
|
||||||
|
} from './protocol.js'
|
||||||
|
import type { ResolvedWebAccount } from './types.js'
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 10
|
||||||
|
const INITIAL_RECONNECT_MS = 2_000
|
||||||
|
const MAX_RECONNECT_MS = 60_000
|
||||||
|
const BACKOFF_FACTOR = 2
|
||||||
|
const PONG_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5 // 75s — allow 2 missed pongs
|
||||||
|
const COOLDOWN_MS = 5 * 60_000 // 5 min cooldown after max reconnect attempts
|
||||||
|
|
||||||
|
/** Per-account gateway WebSocket references for outbound message sending. */
|
||||||
|
const activeWsMap = new Map<string, WebSocket>()
|
||||||
|
const sessionActiveDispatches = new Map<string, number>()
|
||||||
|
const sessionQueuedDispatches = new Map<string, number>()
|
||||||
|
|
||||||
|
const KNOWN_QUEUE_MODES = new Set<SessionQueueMode>([
|
||||||
|
'collect',
|
||||||
|
'followup',
|
||||||
|
'steer',
|
||||||
|
'interrupt',
|
||||||
|
'steer-backlog',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function getActiveWebSocket(accountId?: string): WebSocket | null {
|
||||||
|
if (accountId) return activeWsMap.get(accountId) ?? null
|
||||||
|
// Fallback: return the first connected one (backward compat for single-account)
|
||||||
|
for (const ws of activeWsMap.values()) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) return ws
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWebGateway(
|
||||||
|
ctx: ChannelGatewayContext<ResolvedWebAccount>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { account, abortSignal, log } = ctx
|
||||||
|
|
||||||
|
let reconnectAttempts = 0
|
||||||
|
let reconnectMs = INITIAL_RECONNECT_MS
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null // [R3] track timer for abort cleanup
|
||||||
|
|
||||||
|
const connect = (): Promise<void> =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = account.relayUrl.replace(/\/+$/, '')
|
||||||
|
const wsUrl = baseUrl.endsWith('/openclaw')
|
||||||
|
? baseUrl
|
||||||
|
: `${baseUrl}/openclaw`
|
||||||
|
log?.info(`[web:${account.accountId}] connecting to relay: ${wsUrl}`)
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let authenticated = false
|
||||||
|
let lastPongAt = Date.now() // [H1] track last pong for zombie detection
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
activeWsMap.delete(account.accountId)
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
connected: false,
|
||||||
|
running: true,
|
||||||
|
lastStopAt: Date.now(),
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
log?.info(`[web:${account.accountId}] connected, authenticating...`)
|
||||||
|
|
||||||
|
// Send auth handshake
|
||||||
|
ws.send(
|
||||||
|
serializeRelayMessage({
|
||||||
|
type: 'auth',
|
||||||
|
role: 'openclaw',
|
||||||
|
token: account.authToken,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// [E1] Wrap entire message handler in void async IIFE with try/catch
|
||||||
|
// to prevent unhandled promise rejections from crashing the process
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const raw = typeof data === 'string' ? data : data.toString('utf-8')
|
||||||
|
const msg = parseRelayMessage(raw)
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'auth_result': {
|
||||||
|
if (msg.ok) {
|
||||||
|
log?.info(`[web:${account.accountId}] authenticated successfully`)
|
||||||
|
authenticated = true
|
||||||
|
activeWsMap.set(account.accountId, ws)
|
||||||
|
reconnectAttempts = 0
|
||||||
|
reconnectMs = INITIAL_RECONNECT_MS
|
||||||
|
lastPongAt = Date.now() // [H1] reset pong timer on fresh connection
|
||||||
|
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
connected: true,
|
||||||
|
running: true,
|
||||||
|
lastStartAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
restartPending: false, // [M2] clear restartPending on successful reconnect
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
|
||||||
|
// [H1] Start heartbeat with pong timeout detection
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return
|
||||||
|
|
||||||
|
// Check if pong timed out — zombie connection detection
|
||||||
|
const elapsed = Date.now() - lastPongAt
|
||||||
|
if (elapsed > PONG_TIMEOUT_MS) {
|
||||||
|
log?.error(
|
||||||
|
`[web:${account.accountId}] pong timeout (${elapsed}ms elapsed, limit ${PONG_TIMEOUT_MS}ms), forcing reconnect`,
|
||||||
|
)
|
||||||
|
ws.close(4000, 'Pong timeout')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
serializeRelayMessage({
|
||||||
|
type: 'ping',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, HEARTBEAT_INTERVAL_MS)
|
||||||
|
} else {
|
||||||
|
log?.error(`[web:${account.accountId}] auth failed: ${msg.error}`)
|
||||||
|
ws.close(4001, 'Authentication failed')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'message': {
|
||||||
|
if (!authenticated) break
|
||||||
|
// Incoming message from browser → dispatch to AI
|
||||||
|
await handleIncomingMessage(ctx, msg as ChatMessage)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'typing': {
|
||||||
|
// Browser typing indicator — can log or pass through
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pong': {
|
||||||
|
// [H1] Update last pong timestamp for zombie detection
|
||||||
|
lastPongAt = Date.now()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user_status': {
|
||||||
|
// Browser user online/offline notification
|
||||||
|
log?.info(
|
||||||
|
`[web:${account.accountId}] user_status: ${msg.action} userId=${msg.userId} sessionId=${msg.sessionId}${msg.userName ? ` name=${msg.userName}` : ''}`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// [E1] Catch all async errors to prevent unhandled promise rejection
|
||||||
|
log?.error(
|
||||||
|
`[web:${account.accountId}] message handler error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
log?.warn(
|
||||||
|
`[web:${account.accountId}] disconnected (code=${code}, reason=${reason.toString('utf-8')})`,
|
||||||
|
)
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// [R1] Authentication failure is unrecoverable — do not reconnect
|
||||||
|
if (code === 4001) {
|
||||||
|
const errMessage = 'Authentication failed — not reconnecting'
|
||||||
|
log?.error(`[web:${account.accountId}] ${errMessage}`)
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
lastError: errMessage,
|
||||||
|
running: false,
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
reject(new Error(errMessage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule reconnect
|
||||||
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
reconnectAttempts++
|
||||||
|
const jitter = Math.random() * 0.2 * reconnectMs
|
||||||
|
const delay = reconnectMs + jitter
|
||||||
|
log?.info(
|
||||||
|
`[web:${account.accountId}] reconnecting in ${Math.round(delay)}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
restartPending: true,
|
||||||
|
reconnectAttempts,
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
reconnectMs = Math.min(
|
||||||
|
reconnectMs * BACKOFF_FACTOR,
|
||||||
|
MAX_RECONNECT_MS,
|
||||||
|
)
|
||||||
|
connect().then(resolve, reject)
|
||||||
|
}, delay)
|
||||||
|
} else {
|
||||||
|
// [R2] Max attempts reached — cooldown then retry instead of permanent failure
|
||||||
|
log?.warn(
|
||||||
|
`[web:${account.accountId}] max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, cooling down for ${COOLDOWN_MS / 1000}s`,
|
||||||
|
)
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
lastError: `Max reconnect attempts reached, cooling down for ${COOLDOWN_MS / 1000}s`,
|
||||||
|
restartPending: true,
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log?.info(`[web:${account.accountId}] cooldown complete, resetting reconnect counter`)
|
||||||
|
reconnectAttempts = 0
|
||||||
|
reconnectMs = INITIAL_RECONNECT_MS
|
||||||
|
connect().then(resolve, reject)
|
||||||
|
}, COOLDOWN_MS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// [M1] Update lastError status in error handler
|
||||||
|
ws.on('error', err => {
|
||||||
|
log?.error(`[web:${account.accountId}] WebSocket error: ${err.message}`)
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
lastError: err.message,
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
// Error triggers close event, so reconnect logic runs there
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for abort signal
|
||||||
|
const onAbort = () => {
|
||||||
|
log?.info(`[web:${account.accountId}] shutting down (abort signal)`)
|
||||||
|
cleanup()
|
||||||
|
// [R3] Clear pending reconnect timer on abort
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ws.readyState === WebSocket.OPEN ||
|
||||||
|
ws.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
ws.close(1000, 'Shutting down')
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inbound Message Handling ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleIncomingMessage(
|
||||||
|
ctx: ChannelGatewayContext<ResolvedWebAccount>,
|
||||||
|
msg: ChatMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
const { log } = ctx
|
||||||
|
const sessionKey = buildSessionStateKey(ctx.accountId, msg.sessionId)
|
||||||
|
const queueMode = resolveQueueMode(ctx)
|
||||||
|
const activeCount = sessionActiveDispatches.get(sessionKey) ?? 0
|
||||||
|
const isBusy = activeCount > 0
|
||||||
|
const nextQueuedCount = isBusy
|
||||||
|
? (sessionQueuedDispatches.get(sessionKey) ?? 0) + 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (isBusy) {
|
||||||
|
sessionQueuedDispatches.set(sessionKey, nextQueuedCount)
|
||||||
|
sendSessionEventToSession(
|
||||||
|
msg.sessionId,
|
||||||
|
{
|
||||||
|
event: 'queue_status',
|
||||||
|
state: 'enqueued',
|
||||||
|
mode: queueMode,
|
||||||
|
depth: nextQueuedCount,
|
||||||
|
messageId: msg.id,
|
||||||
|
},
|
||||||
|
ctx.accountId,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sendSessionEventToSession(
|
||||||
|
msg.sessionId,
|
||||||
|
{
|
||||||
|
event: 'queue_status',
|
||||||
|
state: 'processing',
|
||||||
|
mode: queueMode,
|
||||||
|
depth: 0,
|
||||||
|
messageId: msg.id,
|
||||||
|
},
|
||||||
|
ctx.accountId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionActiveDispatches.set(sessionKey, activeCount + 1)
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
`[web:${ctx.account.accountId}] message from session ${msg.sessionId}: ${msg.text.slice(0, 80)}...`,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.setStatus({
|
||||||
|
...ctx.getStatus(),
|
||||||
|
lastInboundAt: Date.now(),
|
||||||
|
} satisfies ChannelAccountSnapshot)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const core = ctx.channelRuntime
|
||||||
|
if (!core) {
|
||||||
|
log?.warn(
|
||||||
|
`[web:${ctx.account.accountId}] channelRuntime not available — cannot dispatch AI reply`,
|
||||||
|
)
|
||||||
|
// 版本太低 openclaw 版本太低去升级吧
|
||||||
|
void sendTextToSession(
|
||||||
|
msg.sessionId,
|
||||||
|
'AI is not available at the moment. Please try again later.',
|
||||||
|
ctx.accountId,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve agent routing
|
||||||
|
const route = core.routing.resolveAgentRoute({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
channel: 'clawshow',
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
peer: { kind: 'direct', id: msg.sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
`[web:${ctx.account.accountId}] route: agent=${route.agentId} session=${route.sessionKey}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Build MsgContext with PascalCase fields (will be finalized by dispatchInboundMessageWithBufferedDispatcher)
|
||||||
|
const ctxPayload = {
|
||||||
|
Body: msg.text,
|
||||||
|
BodyForAgent: msg.text,
|
||||||
|
RawBody: msg.text,
|
||||||
|
CommandBody: msg.text,
|
||||||
|
From: `web:${msg.sessionId}`,
|
||||||
|
To: msg.sessionId,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: ctx.accountId,
|
||||||
|
ChatType: 'direct' as const,
|
||||||
|
Provider: 'clawshow',
|
||||||
|
Surface: 'clawshow',
|
||||||
|
MessageSid: msg.id,
|
||||||
|
Timestamp: msg.timestamp || Date.now(),
|
||||||
|
CommandAuthorized: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Use the high-level dispatch API that handles all lifecycle internally
|
||||||
|
sendTypingToSession(msg.sessionId, true, ctx.accountId)
|
||||||
|
const result = await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload: { text?: string }, _info: { kind: string }) => {
|
||||||
|
if (payload.text) {
|
||||||
|
void sendTextToSession(msg.sessionId, payload.text, ctx.accountId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: unknown, info: { kind: string }) => {
|
||||||
|
log?.error(
|
||||||
|
`[web:${ctx.account.accountId}] reply delivery error (${info.kind}): ${err}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
sendTypingToSession(msg.sessionId, false, ctx.accountId)
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
`[web:${ctx.account.accountId}] dispatch complete (queuedFinal=${result.queuedFinal}, replies=${result.counts.final})`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
sendTypingToSession(msg.sessionId, false, ctx.accountId)
|
||||||
|
// [M3] Log full stack trace but only send generic error to user
|
||||||
|
const fullMsg =
|
||||||
|
err instanceof Error ? `${err.message}\n${err.stack}` : String(err)
|
||||||
|
log?.error(`[web:${ctx.account.accountId}] dispatch error: ${fullMsg}`)
|
||||||
|
void sendTextToSession(
|
||||||
|
msg.sessionId,
|
||||||
|
`Sorry, an error occurred while processing your message. Please try again.`,
|
||||||
|
ctx.accountId,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
const remainingActive = Math.max(
|
||||||
|
0,
|
||||||
|
(sessionActiveDispatches.get(sessionKey) ?? 1) - 1,
|
||||||
|
)
|
||||||
|
if (remainingActive > 0) {
|
||||||
|
sessionActiveDispatches.set(sessionKey, remainingActive)
|
||||||
|
} else {
|
||||||
|
sessionActiveDispatches.delete(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusy) {
|
||||||
|
const remainingQueued = Math.max(
|
||||||
|
0,
|
||||||
|
(sessionQueuedDispatches.get(sessionKey) ?? 1) - 1,
|
||||||
|
)
|
||||||
|
if (remainingQueued > 0) {
|
||||||
|
sessionQueuedDispatches.set(sessionKey, remainingQueued)
|
||||||
|
} else {
|
||||||
|
sessionQueuedDispatches.delete(sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingActive === 0) {
|
||||||
|
sendSessionEventToSession(
|
||||||
|
msg.sessionId,
|
||||||
|
{
|
||||||
|
event: 'queue_status',
|
||||||
|
state: 'idle',
|
||||||
|
mode: queueMode,
|
||||||
|
depth: sessionQueuedDispatches.get(sessionKey) ?? 0,
|
||||||
|
messageId: msg.id,
|
||||||
|
},
|
||||||
|
ctx.accountId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outbound Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// [E2] Try/catch around ws.send to handle race between readyState check and send
|
||||||
|
export function sendTextToSession(
|
||||||
|
sessionId: string,
|
||||||
|
text: string,
|
||||||
|
accountId?: string,
|
||||||
|
): boolean {
|
||||||
|
const ws = getActiveWebSocket(accountId)
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg: ChatMessage = {
|
||||||
|
type: 'message',
|
||||||
|
id: generateMessageId(),
|
||||||
|
sessionId,
|
||||||
|
from: 'ai',
|
||||||
|
text,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(serializeRelayMessage(msg))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTypingToSession(
|
||||||
|
sessionId: string,
|
||||||
|
isTyping: boolean,
|
||||||
|
accountId?: string,
|
||||||
|
): boolean {
|
||||||
|
const ws = getActiveWebSocket(accountId)
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws.send(
|
||||||
|
serializeRelayMessage({
|
||||||
|
type: 'typing',
|
||||||
|
sessionId,
|
||||||
|
from: 'ai',
|
||||||
|
isTyping,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendSessionEventToSession(
|
||||||
|
sessionId: string,
|
||||||
|
payload: SessionEventMessage['payload'],
|
||||||
|
accountId?: string,
|
||||||
|
): boolean {
|
||||||
|
const ws = getActiveWebSocket(accountId)
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg: SessionEventMessage = {
|
||||||
|
type: 'session_event',
|
||||||
|
sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(serializeRelayMessage(msg))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQueueMode(
|
||||||
|
ctx: ChannelGatewayContext<ResolvedWebAccount>,
|
||||||
|
): SessionQueueMode {
|
||||||
|
const byChannel = ctx.cfg.messages?.queue?.byChannel as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
const rawMode = byChannel?.clawshow ?? ctx.cfg.messages?.queue?.mode
|
||||||
|
|
||||||
|
if (typeof rawMode !== 'string') return 'collect'
|
||||||
|
return KNOWN_QUEUE_MODES.has(rawMode as SessionQueueMode)
|
||||||
|
? (rawMode as SessionQueueMode)
|
||||||
|
: 'collect'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionStateKey(accountId: string, sessionId: string): string {
|
||||||
|
return `${accountId}:${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webGatewayAdapter: ChannelGatewayAdapter<ResolvedWebAccount> = {
|
||||||
|
startAccount: async (ctx: ChannelGatewayContext<ResolvedWebAccount>) => {
|
||||||
|
return startWebGateway(ctx)
|
||||||
|
},
|
||||||
|
}
|
||||||
23
src/index.ts
Normal file
23
src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @sker-pro/openclaw-channel-web
|
||||||
|
*
|
||||||
|
* OpenClaw ChannelPlugin for browser-based chat via WebSocket relay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { webChannelPlugin } from './plugin.js'
|
||||||
|
export type { ResolvedWebAccount, WebAccountConfig } from './types.js'
|
||||||
|
export {
|
||||||
|
sendTextToSession,
|
||||||
|
sendTypingToSession,
|
||||||
|
getActiveWebSocket,
|
||||||
|
} from './gateway.js'
|
||||||
|
export {
|
||||||
|
type RelayMessage,
|
||||||
|
type ChatMessage,
|
||||||
|
type AuthMessage,
|
||||||
|
type SessionEventMessage,
|
||||||
|
generateMessageId,
|
||||||
|
parseRelayMessage,
|
||||||
|
serializeRelayMessage,
|
||||||
|
HEARTBEAT_INTERVAL_MS,
|
||||||
|
} from './protocol.js'
|
||||||
33
src/outbound.ts
Normal file
33
src/outbound.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Web channel outbound adapter — sends messages via the gateway WebSocket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChannelOutboundAdapter,
|
||||||
|
ChannelOutboundContext,
|
||||||
|
} from 'openclaw/plugin-sdk'
|
||||||
|
import { generateMessageId } from './protocol.js'
|
||||||
|
import { sendTextToSession } from './gateway.js'
|
||||||
|
|
||||||
|
export const webOutboundAdapter: ChannelOutboundAdapter = {
|
||||||
|
deliveryMode: 'gateway' as const,
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
|
||||||
|
sendText: async (ctx: ChannelOutboundContext) => {
|
||||||
|
const ok = sendTextToSession(ctx.to, ctx.text, ctx.accountId ?? undefined)
|
||||||
|
return {
|
||||||
|
channel: 'clawshow',
|
||||||
|
messageId: ok ? generateMessageId() : '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMedia: async (ctx: ChannelOutboundContext) => {
|
||||||
|
// Web channel doesn't support native media — send as text link
|
||||||
|
const text = ctx.mediaUrl ? `${ctx.text}\n\n${ctx.mediaUrl}` : ctx.text
|
||||||
|
const ok = sendTextToSession(ctx.to, text, ctx.accountId ?? undefined)
|
||||||
|
return {
|
||||||
|
channel: 'clawshow',
|
||||||
|
messageId: ok ? generateMessageId() : '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
44
src/plugin.ts
Normal file
44
src/plugin.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Web ChannelPlugin — full plugin definition following the ChannelPlugin<ResolvedWebAccount> interface.
|
||||||
|
*
|
||||||
|
* Pattern follows Telegram/WhatsApp channel plugins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChannelPlugin } from 'openclaw/plugin-sdk'
|
||||||
|
import { webConfigAdapter } from './config.js'
|
||||||
|
import { webGatewayAdapter } from './gateway.js'
|
||||||
|
import { webOutboundAdapter } from './outbound.js'
|
||||||
|
import { webSecurityAdapter } from './security.js'
|
||||||
|
import { webSetupAdapter } from './setup.js'
|
||||||
|
import type { ResolvedWebAccount } from './types.js'
|
||||||
|
|
||||||
|
export const webChannelPlugin: ChannelPlugin<ResolvedWebAccount> = {
|
||||||
|
id: 'clawshow',
|
||||||
|
meta: {
|
||||||
|
id: 'clawshow',
|
||||||
|
label: 'Clawshow',
|
||||||
|
selectionLabel: 'Clawshow (browser chat)',
|
||||||
|
docsPath: '/channels/clawshow',
|
||||||
|
blurb: 'Browser chat via WebSocket relay',
|
||||||
|
order: 50,
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ['direct'],
|
||||||
|
media: false,
|
||||||
|
reactions: false,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
queue: {
|
||||||
|
debounceMs: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ['channels.clawshow'] },
|
||||||
|
config: webConfigAdapter,
|
||||||
|
gateway: webGatewayAdapter,
|
||||||
|
outbound: webOutboundAdapter,
|
||||||
|
security: webSecurityAdapter,
|
||||||
|
setup: webSetupAdapter,
|
||||||
|
pairing: {
|
||||||
|
idLabel: 'sessionId',
|
||||||
|
},
|
||||||
|
}
|
||||||
394
src/protocol.ts
Normal file
394
src/protocol.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* Shared WebSocket message protocol for Web Channel.
|
||||||
|
* Used by: web-relay (Cloudflare Worker), openclaw-channel-web (plugin), web-chat (frontend).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Roles ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type WebSocketRole = 'browser' | 'openclaw'
|
||||||
|
|
||||||
|
// ── Message Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AuthMessage = {
|
||||||
|
type: 'auth'
|
||||||
|
role: WebSocketRole
|
||||||
|
token: string
|
||||||
|
sessionId?: string // required for browser role
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthResultMessage = {
|
||||||
|
type: 'auth_result'
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
sessionId?: string
|
||||||
|
userId?: string
|
||||||
|
userName?: string
|
||||||
|
lobsterName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
type: 'message'
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
from: 'user' | 'ai'
|
||||||
|
text: string
|
||||||
|
timestamp: number
|
||||||
|
userId?: string
|
||||||
|
userName?: string
|
||||||
|
targetLobsterId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AckMessage = {
|
||||||
|
type: 'ack'
|
||||||
|
messageId: string
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypingMessage = {
|
||||||
|
type: 'typing'
|
||||||
|
sessionId: string
|
||||||
|
from: 'user' | 'ai'
|
||||||
|
isTyping: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryRequestMessage = {
|
||||||
|
type: 'history_request'
|
||||||
|
sessionId: string
|
||||||
|
before?: number // timestamp cursor for pagination
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryResponseMessage = {
|
||||||
|
type: 'history_response'
|
||||||
|
sessionId: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PingMessage = {
|
||||||
|
type: 'ping'
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PongMessage = {
|
||||||
|
type: 'pong'
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorMessage = {
|
||||||
|
type: 'error'
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionQueueMode =
|
||||||
|
| 'collect'
|
||||||
|
| 'followup'
|
||||||
|
| 'steer'
|
||||||
|
| 'interrupt'
|
||||||
|
| 'steer-backlog'
|
||||||
|
|
||||||
|
export type QueueStatusState = 'processing' | 'enqueued' | 'idle'
|
||||||
|
|
||||||
|
export type QueueStatusSessionEvent = {
|
||||||
|
event: 'queue_status'
|
||||||
|
state: QueueStatusState
|
||||||
|
mode?: SessionQueueMode
|
||||||
|
depth?: number
|
||||||
|
messageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionEventPayload = QueueStatusSessionEvent
|
||||||
|
|
||||||
|
export type SessionEventMessage = {
|
||||||
|
type: 'session_event'
|
||||||
|
sessionId: string
|
||||||
|
timestamp: number
|
||||||
|
payload: SessionEventPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lobster 上/下线推送给 browser
|
||||||
|
export type LobsterStatusMessage = {
|
||||||
|
type: 'lobster_status'
|
||||||
|
action: 'online' | 'offline' | 'list'
|
||||||
|
lobsters: Array<{ lobsterId: string; lobsterName: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser 上/下线推送给 openclaw
|
||||||
|
export type UserStatusMessage = {
|
||||||
|
type: 'user_status'
|
||||||
|
action: 'online' | 'offline'
|
||||||
|
sessionId: string
|
||||||
|
userId: string
|
||||||
|
userName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryClearMessage = {
|
||||||
|
type: 'history_clear'
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryClearResultMessage = {
|
||||||
|
type: 'history_clear_result'
|
||||||
|
sessionId: string
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Union Type ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RelayMessage =
|
||||||
|
| AuthMessage
|
||||||
|
| AuthResultMessage
|
||||||
|
| ChatMessage
|
||||||
|
| AckMessage
|
||||||
|
| TypingMessage
|
||||||
|
| HistoryRequestMessage
|
||||||
|
| HistoryResponseMessage
|
||||||
|
| PingMessage
|
||||||
|
| PongMessage
|
||||||
|
| ErrorMessage
|
||||||
|
| SessionEventMessage
|
||||||
|
| LobsterStatusMessage
|
||||||
|
| UserStatusMessage
|
||||||
|
| HistoryClearMessage
|
||||||
|
| HistoryClearResultMessage
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function parseRelayMessage(raw: string): RelayMessage | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || !('type' in parsed)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = parsed as { type: string }
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'auth':
|
||||||
|
return isValidAuthMessage(parsed) ? (parsed as AuthMessage) : null
|
||||||
|
case 'auth_result':
|
||||||
|
return isValidAuthResultMessage(parsed) ? (parsed as AuthResultMessage) : null
|
||||||
|
case 'message':
|
||||||
|
return isValidChatMessage(parsed) ? (parsed as ChatMessage) : null
|
||||||
|
case 'ack':
|
||||||
|
return isValidAckMessage(parsed) ? (parsed as AckMessage) : null
|
||||||
|
case 'typing':
|
||||||
|
return isValidTypingMessage(parsed) ? (parsed as TypingMessage) : null
|
||||||
|
case 'history_request':
|
||||||
|
return isValidHistoryRequestMessage(parsed) ? (parsed as HistoryRequestMessage) : null
|
||||||
|
case 'history_response':
|
||||||
|
return isValidHistoryResponseMessage(parsed) ? (parsed as HistoryResponseMessage) : null
|
||||||
|
case 'ping':
|
||||||
|
return isValidPingMessage(parsed) ? (parsed as PingMessage) : null
|
||||||
|
case 'pong':
|
||||||
|
return isValidPongMessage(parsed) ? (parsed as PongMessage) : null
|
||||||
|
case 'error':
|
||||||
|
return isValidErrorMessage(parsed) ? (parsed as ErrorMessage) : null
|
||||||
|
case 'session_event':
|
||||||
|
return isValidSessionEventMessage(parsed) ? (parsed as SessionEventMessage) : null
|
||||||
|
case 'lobster_status':
|
||||||
|
return isValidLobsterStatusMessage(parsed) ? (parsed as LobsterStatusMessage) : null
|
||||||
|
case 'user_status':
|
||||||
|
return isValidUserStatusMessage(parsed) ? (parsed as UserStatusMessage) : null
|
||||||
|
case 'history_clear':
|
||||||
|
return isValidHistoryClearMessage(parsed) ? (parsed as HistoryClearMessage) : null
|
||||||
|
case 'history_clear_result':
|
||||||
|
return isValidHistoryClearResultMessage(parsed) ? (parsed as HistoryClearResultMessage) : null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guards for each message type
|
||||||
|
function isValidAuthMessage(msg: unknown): msg is AuthMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as AuthMessage).type === 'auth' &&
|
||||||
|
typeof (msg as AuthMessage).role === 'string' &&
|
||||||
|
['browser', 'openclaw'].includes((msg as AuthMessage).role) &&
|
||||||
|
typeof (msg as AuthMessage).token === 'string' &&
|
||||||
|
((msg as AuthMessage).sessionId === undefined || typeof (msg as AuthMessage).sessionId === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidAuthResultMessage(msg: unknown): msg is AuthResultMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as AuthResultMessage).type === 'auth_result' &&
|
||||||
|
typeof (msg as AuthResultMessage).ok === 'boolean' &&
|
||||||
|
((msg as AuthResultMessage).error === undefined || typeof (msg as AuthResultMessage).error === 'string') &&
|
||||||
|
((msg as AuthResultMessage).sessionId === undefined || typeof (msg as AuthResultMessage).sessionId === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidChatMessage(msg: unknown): msg is ChatMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as ChatMessage).type === 'message' &&
|
||||||
|
typeof (msg as ChatMessage).id === 'string' &&
|
||||||
|
typeof (msg as ChatMessage).sessionId === 'string' &&
|
||||||
|
['user', 'ai'].includes((msg as ChatMessage).from) &&
|
||||||
|
typeof (msg as ChatMessage).text === 'string' &&
|
||||||
|
typeof (msg as ChatMessage).timestamp === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidAckMessage(msg: unknown): msg is AckMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as AckMessage).type === 'ack' &&
|
||||||
|
typeof (msg as AckMessage).messageId === 'string' &&
|
||||||
|
typeof (msg as AckMessage).sessionId === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTypingMessage(msg: unknown): msg is TypingMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as TypingMessage).type === 'typing' &&
|
||||||
|
typeof (msg as TypingMessage).sessionId === 'string' &&
|
||||||
|
['user', 'ai'].includes((msg as TypingMessage).from) &&
|
||||||
|
typeof (msg as TypingMessage).isTyping === 'boolean'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHistoryRequestMessage(msg: unknown): msg is HistoryRequestMessage {
|
||||||
|
const m = msg as HistoryRequestMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'history_request' &&
|
||||||
|
typeof m.sessionId === 'string' &&
|
||||||
|
(m.before === undefined || typeof m.before === 'number') &&
|
||||||
|
(m.limit === undefined || typeof m.limit === 'number')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHistoryResponseMessage(msg: unknown): msg is HistoryResponseMessage {
|
||||||
|
const m = msg as HistoryResponseMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'history_response' &&
|
||||||
|
typeof m.sessionId === 'string' &&
|
||||||
|
Array.isArray(m.messages) &&
|
||||||
|
typeof m.hasMore === 'boolean'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPingMessage(msg: unknown): msg is PingMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as PingMessage).type === 'ping' &&
|
||||||
|
typeof (msg as PingMessage).timestamp === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPongMessage(msg: unknown): msg is PongMessage {
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
(msg as PongMessage).type === 'pong' &&
|
||||||
|
typeof (msg as PongMessage).timestamp === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidErrorMessage(msg: unknown): msg is ErrorMessage {
|
||||||
|
const m = msg as ErrorMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'error' &&
|
||||||
|
typeof m.code === 'string' &&
|
||||||
|
typeof m.message === 'string' &&
|
||||||
|
(m.sessionId === undefined || typeof m.sessionId === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSessionEventMessage(msg: unknown): msg is SessionEventMessage {
|
||||||
|
const m = msg as SessionEventMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
m.type === 'session_event' &&
|
||||||
|
typeof m.sessionId === 'string' &&
|
||||||
|
typeof m.timestamp === 'number' &&
|
||||||
|
isValidSessionEventPayload(m.payload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSessionEventPayload(payload: unknown): payload is SessionEventPayload {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return false
|
||||||
|
const typed = payload as SessionEventPayload
|
||||||
|
if (typed.event !== 'queue_status') return false
|
||||||
|
if (!['processing', 'enqueued', 'idle'].includes(typed.state)) return false
|
||||||
|
if (
|
||||||
|
typed.mode !== undefined &&
|
||||||
|
!['collect', 'followup', 'steer', 'interrupt', 'steer-backlog'].includes(typed.mode)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(typed.depth === undefined || typeof typed.depth === 'number') &&
|
||||||
|
(typed.messageId === undefined || typeof typed.messageId === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLobsterStatusMessage(msg: unknown): msg is LobsterStatusMessage {
|
||||||
|
const m = msg as LobsterStatusMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'lobster_status' &&
|
||||||
|
typeof m.action === 'string' &&
|
||||||
|
['online', 'offline', 'list'].includes(m.action) &&
|
||||||
|
Array.isArray(m.lobsters)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUserStatusMessage(msg: unknown): msg is UserStatusMessage {
|
||||||
|
const m = msg as UserStatusMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'user_status' &&
|
||||||
|
typeof m.action === 'string' &&
|
||||||
|
['online', 'offline'].includes(m.action) &&
|
||||||
|
typeof m.sessionId === 'string' &&
|
||||||
|
typeof m.userId === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHistoryClearMessage(msg: unknown): msg is HistoryClearMessage {
|
||||||
|
const m = msg as HistoryClearMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'history_clear' &&
|
||||||
|
typeof m.sessionId === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHistoryClearResultMessage(msg: unknown): msg is HistoryClearResultMessage {
|
||||||
|
const m = msg as HistoryClearResultMessage
|
||||||
|
return (
|
||||||
|
typeof msg === 'object' && msg !== null &&
|
||||||
|
m.type === 'history_clear_result' &&
|
||||||
|
typeof m.sessionId === 'string' &&
|
||||||
|
typeof m.ok === 'boolean' &&
|
||||||
|
(m.error === undefined || typeof m.error === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRelayMessage(msg: RelayMessage): string {
|
||||||
|
return JSON.stringify(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMessageId(): string {
|
||||||
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const HEARTBEAT_INTERVAL_MS = 30_000
|
||||||
|
export const MAX_HISTORY_PER_SESSION = 200
|
||||||
|
export const DEFAULT_HISTORY_LIMIT = 50
|
||||||
25
src/security.ts
Normal file
25
src/security.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Web channel DM security adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChannelSecurityAdapter,
|
||||||
|
ChannelSecurityContext,
|
||||||
|
ChannelSecurityDmPolicy,
|
||||||
|
} from 'openclaw/plugin-sdk'
|
||||||
|
import type { ResolvedWebAccount } from './types.js'
|
||||||
|
|
||||||
|
export const webSecurityAdapter: ChannelSecurityAdapter<ResolvedWebAccount> = {
|
||||||
|
resolveDmPolicy: (
|
||||||
|
ctx: ChannelSecurityContext<ResolvedWebAccount>,
|
||||||
|
): ChannelSecurityDmPolicy => {
|
||||||
|
const policy = ctx.account.config.dmPolicy ?? 'open'
|
||||||
|
return {
|
||||||
|
policy,
|
||||||
|
allowFrom: ctx.account.config.allowFrom ?? null,
|
||||||
|
policyPath: 'channels.clawshow.dmPolicy',
|
||||||
|
allowFromPath: 'channels.clawshow.allowFrom',
|
||||||
|
approveHint: 'Add the session ID to channels.clawshow.allowFrom',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
77
src/setup.ts
Normal file
77
src/setup.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Web channel setup adapter — applies account configuration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChannelSetupAdapter,
|
||||||
|
ChannelSetupInput,
|
||||||
|
OpenClawConfig,
|
||||||
|
} from 'openclaw/plugin-sdk'
|
||||||
|
|
||||||
|
export const webSetupAdapter: ChannelSetupAdapter = {
|
||||||
|
applyAccountConfig: (params: {
|
||||||
|
cfg: OpenClawConfig
|
||||||
|
accountId: string
|
||||||
|
input: ChannelSetupInput
|
||||||
|
}): OpenClawConfig => {
|
||||||
|
const { cfg, accountId, input } = params
|
||||||
|
|
||||||
|
if (accountId === 'default') {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
clawshow: {
|
||||||
|
...(cfg.channels?.clawshow as Record<string, unknown> | undefined),
|
||||||
|
enabled: true,
|
||||||
|
...(input.url ? { relayUrl: input.url } : {}),
|
||||||
|
...(input.token ? { authToken: input.token } : {}),
|
||||||
|
...(input.name ? { name: input.name } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const webConfig = (cfg.channels?.clawshow as Record<string, unknown>) ?? {}
|
||||||
|
const accounts = (webConfig.accounts as Record<string, unknown>) ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
clawshow: {
|
||||||
|
...webConfig,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...(accounts[accountId] as Record<string, unknown> | undefined),
|
||||||
|
enabled: true,
|
||||||
|
...(input.url ? { relayUrl: input.url } : {}),
|
||||||
|
...(input.token ? { authToken: input.token } : {}),
|
||||||
|
...(input.name ? { name: input.name } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
validateInput: (params: {
|
||||||
|
cfg: OpenClawConfig
|
||||||
|
accountId: string
|
||||||
|
input: ChannelSetupInput
|
||||||
|
}): string | null => {
|
||||||
|
if (!params.input.url && !params.input.useEnv) {
|
||||||
|
return 'Web channel requires relayUrl (--url) or --use-env.'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!params.input.token &&
|
||||||
|
!params.input.useEnv &&
|
||||||
|
!process.env.WEB_RELAY_AUTH_TOKEN
|
||||||
|
) {
|
||||||
|
return 'Web channel requires authToken (--token), --use-env, or WEB_RELAY_AUTH_TOKEN env var.'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
21
src/types.ts
Normal file
21
src/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Web channel account configuration and resolved types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WebAccountConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
relayUrl: string
|
||||||
|
authToken?: string
|
||||||
|
dmPolicy?: 'open' | 'allowlist'
|
||||||
|
allowFrom?: string[]
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWebAccount {
|
||||||
|
accountId: string
|
||||||
|
name?: string
|
||||||
|
enabled: boolean
|
||||||
|
relayUrl: string
|
||||||
|
authToken: string
|
||||||
|
config: WebAccountConfig
|
||||||
|
}
|
||||||
12
tsconfig.build.json
Normal file
12
tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user