feat: import web relay worker from monorepo

This commit is contained in:
imeepos
2026-03-19 18:37:53 +08:00
commit 4aa0a84090
13 changed files with 13385 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars

View File

@@ -0,0 +1 @@
ALTER TABLE "apikey" ADD COLUMN "avatar_id" TEXT REFERENCES "avatars"("id") ON DELETE SET NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "emojis" ADD COLUMN "trigger_type" TEXT NOT NULL DEFAULT 'message';
ALTER TABLE "emojis" ADD COLUMN "trigger_result" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "emojis_avatar_default_unique_idx" ON "emojis" ("avatar_id") WHERE "trigger_type" = 'default';

14
migrate-add-user-file.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "user_file" (
"id" TEXT PRIMARY KEY NOT NULL,
"user_id" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"original_name" TEXT NOT NULL,
"content_type" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"s3_key" TEXT NOT NULL,
"url" TEXT NOT NULL,
"uploaded_at" INTEGER NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "user_file_user_id_idx" ON "user_file" ("user_id");

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@sker-pro/web-relay",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "wrangler dev --ip=0.0.0.0",
"deploy": "wrangler deploy",
"deploy:prod": "wrangler deploy --env prod",
"types": "wrangler types --env-interface CloudflareBindings --env dev",
"typecheck": "tsc --noEmit",
"db:init:local": "wrangler d1 execute clawshow-db --local --file=./schema.sql",
"db:init:remote": "wrangler d1 execute clawshow-db --remote --file=./schema.sql",
"db:migrate:add-avatar-id:local": "wrangler d1 execute clawshow-db --local --file=./migrate-add-avatar-id.sql",
"db:migrate:add-avatar-id:remote": "wrangler d1 execute clawshow-db --remote --file=./migrate-add-avatar-id.sql",
"db:migrate:add-user-file:local": "wrangler d1 execute clawshow-db --local --file=./migrate-add-user-file.sql",
"db:migrate:add-user-file:remote": "wrangler d1 execute clawshow-db --remote --file=./migrate-add-user-file.sql",
"db:migrate:add-emoji-trigger-fields:local": "powershell -ExecutionPolicy Bypass -File ./migrate-add-emoji-trigger-fields.ps1",
"db:migrate:add-emoji-trigger-fields:remote": "powershell -ExecutionPolicy Bypass -File ./migrate-add-emoji-trigger-fields.ps1 -Remote"
},
"dependencies": {
"@better-auth/api-key": "^1.2.0",
"@bowong/auth": "git+https://gitea.bowongai.com/bowong/claushow-auth.git#main",
"better-auth": "^1.2.0",
"drizzle-orm": "^0.44.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250312.0",
"wrangler": "^4.14.0"
}
}

132
schema.sql Normal file
View File

@@ -0,0 +1,132 @@
CREATE TABLE IF NOT EXISTS "user" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"email_verified" INTEGER NOT NULL DEFAULT 0,
"image" TEXT,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "user_email_unique" ON "user" ("email");
CREATE TABLE IF NOT EXISTS "session" (
"id" TEXT PRIMARY KEY NOT NULL,
"expires_at" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL,
"ip_address" TEXT,
"user_agent" TEXT,
"user_id" TEXT NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "user"("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "session_token_unique" ON "session" ("token");
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "session" ("user_id");
CREATE TABLE IF NOT EXISTS "account" (
"id" TEXT PRIMARY KEY NOT NULL,
"account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"access_token" TEXT,
"refresh_token" TEXT,
"id_token" TEXT,
"access_token_expires_at" INTEGER,
"refresh_token_expires_at" INTEGER,
"scope" TEXT,
"password" TEXT,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "user"("id")
);
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "account" ("user_id");
CREATE INDEX IF NOT EXISTS "account_provider_account_idx" ON "account" ("provider_id", "account_id");
CREATE TABLE IF NOT EXISTS "verification" (
"id" TEXT PRIMARY KEY NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expires_at" INTEGER NOT NULL,
"created_at" INTEGER,
"updated_at" INTEGER
);
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");
CREATE TABLE IF NOT EXISTS "apikey" (
"id" TEXT PRIMARY KEY NOT NULL,
"config_id" TEXT,
"name" TEXT,
"start" TEXT,
"prefix" TEXT,
"key" TEXT NOT NULL,
"reference_id" TEXT,
"enabled" INTEGER DEFAULT 1,
"rate_limit_enabled" INTEGER DEFAULT 1,
"rate_limit_time_window" INTEGER,
"rate_limit_max" INTEGER,
"request_count" INTEGER DEFAULT 0,
"remaining" INTEGER,
"refill_interval" INTEGER,
"refill_amount" INTEGER,
"last_refill_at" INTEGER,
"last_request" INTEGER,
"permissions" TEXT,
"metadata" TEXT,
"expires_at" INTEGER,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL,
"avatar_id" TEXT,
FOREIGN KEY ("avatar_id") REFERENCES "avatars"("id") ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS "apikey_reference_id_idx" ON "apikey" ("reference_id");
CREATE INDEX IF NOT EXISTS "apikey_key_idx" ON "apikey" ("key");
-- 形象/表情包集合表
CREATE TABLE IF NOT EXISTS "avatars" (
"id" TEXT PRIMARY KEY NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "avatars_user_id_idx" ON "avatars" ("user_id");
-- 表情表
CREATE TABLE IF NOT EXISTS "emojis" (
"id" TEXT PRIMARY KEY NOT NULL,
"avatar_id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"url" TEXT NOT NULL,
"description" TEXT,
"trigger_type" TEXT NOT NULL DEFAULT 'message',
"trigger_result" TEXT,
"sort_order" INTEGER DEFAULT 0,
"created_at" INTEGER NOT NULL,
FOREIGN KEY ("avatar_id") REFERENCES "avatars"("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "emojis_avatar_id_idx" ON "emojis" ("avatar_id");
CREATE UNIQUE INDEX IF NOT EXISTS "emojis_avatar_default_unique_idx" ON "emojis" ("avatar_id") WHERE "trigger_type" = 'default';
CREATE TABLE IF NOT EXISTS "user_file" (
"id" TEXT PRIMARY KEY NOT NULL,
"user_id" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"original_name" TEXT NOT NULL,
"content_type" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"s3_key" TEXT NOT NULL,
"url" TEXT NOT NULL,
"uploaded_at" INTEGER NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "user_file_user_id_idx" ON "user_file" ("user_id");

58
src/auth.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Authentication utilities for the WebSocket relay.
*/
import type { Auth } from '@bowong/auth'
/**
* Validate a Better Auth bearer token by querying D1 directly.
* Returns user info if valid, null otherwise.
*/
export async function validateBearerToken(
db: D1Database,
token: string,
): Promise<{ userId: string; userName: string | null } | null> {
const result = await db
.prepare(
`SELECT s.user_id, u.name FROM session s JOIN user u ON s.user_id = u.id WHERE s.token = ? AND s.expires_at > ?`,
)
.bind(token, Math.floor(Date.now() / 1000))
.first<{ user_id: string; name: string | null }>()
if (!result) return null
return { userId: result.user_id, userName: result.name }
}
/**
* Verify an API key (lobster identity) via Better Auth api-key plugin.
*/
export async function verifyApiKeyToken(
auth: Auth,
apiKeyValue: string,
): Promise<{
valid: boolean
keyId?: string
keyName?: string
userId?: string
metadata?: Record<string, unknown>
} | null> {
try {
const result = await auth.api.verifyApiKey({
body: { key: apiKeyValue },
})
if (!result?.valid || !result.key) return { valid: false }
return {
valid: true,
keyId: result.key.id,
keyName: result.key.name ?? undefined,
userId: result.key.referenceId ?? undefined,
metadata:
typeof result.key.metadata === 'string'
? JSON.parse(result.key.metadata)
: (result.key.metadata ?? undefined),
}
} catch (err) {
console.error('[auth] API key verification failed:', err)
return null
}
}

1536
src/chat-relay.ts Normal file

File diff suppressed because it is too large Load Diff

216
src/index.ts Normal file
View File

@@ -0,0 +1,216 @@
/**
* Cloudflare Worker entry point.
* - /api/auth/* → Better Auth handler (sign-up, sign-in, session, etc.)
* - /api/auth/avatars/* → Avatar management plugin
* - /api/auth/emojis/* → Emoji management plugin
* - /api/auth/apikeys/* → API key avatar binding plugin
* - /ws, /openclaw, /health → ChatRelay Durable Object
*/
import { createAuth } from '@bowong/auth'
export { ChatRelay } from './chat-relay.js'
interface Env {
CHAT_RELAY: DurableObjectNamespace
AUTH_DB: D1Database
BETTER_AUTH_SECRET: string
RELAY_URL?: string
ALLOWED_ORIGINS?: string
AWS_ACCESS_KEY_ID?: string
AWS_SECRET_ACCESS_KEY?: string
AWS_BUCKET_NAME?: string
AWS_LOCATION?: string
AWS_ENDPOINT?: string
AWS_PUBLIC_BASE_URL?: string
AWS_KEY_PREFIX?: string
}
function createAuthInstance(env: Env, url: URL) {
return createAuth({
db: env.AUTH_DB,
baseURL: env.RELAY_URL ?? url.origin,
secret: env.BETTER_AUTH_SECRET || `It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=`,
trustedOrigins: env.ALLOWED_ORIGINS ? env.ALLOWED_ORIGINS.split(',') : undefined,
s3: env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_BUCKET_NAME && env.AWS_LOCATION
? {
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
bucketName: env.AWS_BUCKET_NAME,
region: env.AWS_LOCATION,
endpoint: env.AWS_ENDPOINT,
publicBaseUrl: env.AWS_PUBLIC_BASE_URL ?? 'https://cdn.bowong.cc',
keyPrefix: env.AWS_KEY_PREFIX ?? 'emoji-files',
},
allowedTypes: ['webm', 'mp4', 'mov', 'png', 'jpg', 'jpeg', 'gif'],
maxFileSize: 25 * 1024 * 1024,
}
: undefined,
})
}
function rewriteRequestPath(request: Request, pathname: string): Request {
const nextUrl = new URL(request.url)
nextUrl.pathname = pathname
return new Request(nextUrl.toString(), request)
}
function getConfiguredAllowedOrigins(env: Env): string[] {
if (!env.ALLOWED_ORIGINS) return []
return env.ALLOWED_ORIGINS.split(',')
.map(o => o.trim())
.filter(Boolean)
}
function getAllowedOrigins(request: Request, env: Env): string[] {
const configured = getConfiguredAllowedOrigins(env)
if (configured.length > 0) {
return configured
}
const url = new URL(request.url)
const origins = new Set<string>([url.origin])
const requestOrigin = request.headers.get('Origin')
if (requestOrigin) {
origins.add(requestOrigin)
}
return [...origins]
}
function corsHeaders(request: Request, env: Env): Record<string, string> {
const origin = request.headers.get('Origin') ?? ''
const allowed = getAllowedOrigins(request, env)
const isAllowed = origin && allowed.includes(origin)
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Better-Auth-Token, x-filename, x-file-metadata',
'Access-Control-Expose-Headers': 'set-auth-token',
'Access-Control-Max-Age': '86400',
}
// When credentials are involved, we MUST return the exact origin, not wildcard
if (origin && isAllowed) {
headers['Access-Control-Allow-Origin'] = origin
headers['Access-Control-Allow-Credentials'] = 'true'
} else if (!origin) {
headers['Access-Control-Allow-Origin'] = '*'
}
return headers
}
function addCorsHeaders(
response: Response,
request: Request,
env: Env,
): Response {
const origin = request.headers.get('Origin') ?? ''
const allowedOrigins = getAllowedOrigins(request, env)
const isAllowedOrigin = origin && allowedOrigins.includes(origin)
const newHeaders = new Headers()
// Copy non-CORS headers from the original response
for (const [key, value] of response.headers.entries()) {
// Skip ALL CORS-related headers from Better Auth
if (key.toLowerCase() !== 'access-control-allow-origin' &&
key.toLowerCase() !== 'access-control-allow-methods' &&
key.toLowerCase() !== 'access-control-allow-headers' &&
key.toLowerCase() !== 'access-control-allow-credentials' &&
key.toLowerCase() !== 'access-control-expose-headers' &&
key.toLowerCase() !== 'access-control-max-age') {
newHeaders.set(key, value)
}
}
// Set our own CORS headers
// When credentials are involved, we MUST return the exact origin, not wildcard
if (origin && isAllowedOrigin) {
newHeaders.set('Access-Control-Allow-Origin', origin)
newHeaders.set('Access-Control-Allow-Credentials', 'true')
} else if (!origin) {
// No Origin header (same-origin or non-browser request) - allow all
newHeaders.set('Access-Control-Allow-Origin', '*')
}
newHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
newHeaders.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Better-Auth-Token, x-filename, x-file-metadata')
newHeaders.set('Access-Control-Expose-Headers', 'set-auth-token')
newHeaders.set('Access-Control-Max-Age', '86400')
newHeaders.append('Vary', 'Origin')
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
})
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: corsHeaders(request, env),
})
}
// Health check
if (url.pathname === '/health') {
return new Response(JSON.stringify({ ok: true }), {
headers: {
'Content-Type': 'application/json',
...corsHeaders(request, env),
},
})
}
if (url.pathname === '/docs') {
const auth = createAuthInstance(env, url)
const response = await auth.handler(
rewriteRequestPath(request, '/api/auth/reference'),
)
return addCorsHeaders(response, request, env)
}
if (url.pathname === '/openapi.json') {
const auth = createAuthInstance(env, url)
const response = await auth.handler(
rewriteRequestPath(request, '/api/auth/open-api/generate-schema'),
)
return addCorsHeaders(response, request, env)
}
// Better Auth API — handles /api/auth/* (sign-up, sign-in, session, etc.)
if (url.pathname.startsWith('/api/auth')) {
const origin = request.headers.get('Origin') ?? ''
const allowedOrigins = getConfiguredAllowedOrigins(env)
// Verify origin is allowed before processing auth request
if (origin && allowedOrigins.length > 0 && !allowedOrigins.includes(origin)) {
return new Response('Origin not allowed', { status: 403 })
}
const auth = createAuthInstance(env, url)
const response = await auth.handler(request)
return addCorsHeaders(response, request, env)
}
// All relay routes go to a single global Durable Object instance.
const id = env.CHAT_RELAY.idFromName('global-relay')
const stub = env.CHAT_RELAY.get(id)
const response = await stub.fetch(request)
// Add CORS headers to non-WebSocket responses
if (response.webSocket) {
return response
}
return addCorsHeaders(response, request, env)
},
} satisfies ExportedHandler<Env>

414
src/protocol.ts Normal file
View File

@@ -0,0 +1,414 @@
/**
* Re-export shared protocol types for the relay worker.
* Duplicated here to avoid cross-package dependency in Cloudflare Worker bundling.
*/
export type WebSocketRole = 'browser' | 'openclaw'
export type AuthMessage = {
type: 'auth'
role: WebSocketRole
token: string
sessionId?: string
}
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
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
}
// 全局 WebSocket 动态订阅/退订会话
export type SessionJoinMessage = {
type: 'session_join'
sessionId: string
}
export type SessionLeaveMessage = {
type: 'session_leave'
sessionId: string
}
export type HistoryClearMessage = {
type: 'history_clear'
sessionId: string
}
export type HistoryClearResultMessage = {
type: 'history_clear_result'
sessionId: string
ok: boolean
error?: string
}
export type RelayMessage =
| AuthMessage
| AuthResultMessage
| ChatMessage
| AckMessage
| TypingMessage
| HistoryRequestMessage
| HistoryResponseMessage
| PingMessage
| PongMessage
| ErrorMessage
| SessionEventMessage
| LobsterStatusMessage
| UserStatusMessage
| SessionJoinMessage
| SessionLeaveMessage
| HistoryClearMessage
| HistoryClearResultMessage
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 'session_join':
return isValidSessionJoinMessage(parsed) ? (parsed as SessionJoinMessage) : null
case 'session_leave':
return isValidSessionLeaveMessage(parsed) ? (parsed as SessionLeaveMessage) : 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 isValidSessionJoinMessage(msg: unknown): msg is SessionJoinMessage {
const m = msg as SessionJoinMessage
return (
typeof msg === 'object' && msg !== null &&
m.type === 'session_join' &&
typeof m.sessionId === 'string'
)
}
function isValidSessionLeaveMessage(msg: unknown): msg is SessionLeaveMessage {
const m = msg as SessionLeaveMessage
return (
typeof msg === 'object' && msg !== null &&
m.type === 'session_leave' &&
typeof m.sessionId === '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 generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export const MAX_HISTORY_PER_SESSION = 200
export const DEFAULT_HISTORY_LIMIT = 50

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"]
}

10875
worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

85
wrangler.jsonc Normal file
View File

@@ -0,0 +1,85 @@
{
"name": "web-relay",
"main": "src/index.ts",
"compatibility_date": "2025-03-10",
"compatibility_flags": [
"nodejs_compat"
],
"migrations": [
{
"tag": "v1",
"new_classes": [
"ChatRelay"
],
},
],
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true
}
},
// Shared bindings & dev defaults — available via `wrangler dev`
"vars": {
"BETTER_AUTH_URL": "http://192.168.0.117:8787/api/auth",
"RELAY_URL": "http://192.168.0.117:8787",
"BETTER_AUTH_SECRET": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
"OPENCLAW_AUTH_TOKEN": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
"AWS_ACCESS_KEY_ID": "AKIAYRH5NGRSWHN2L4M6",
"AWS_SECRET_ACCESS_KEY": "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",
"AWS_BUCKET_NAME": "modal-media-cache",
"AWS_LOCATION": "ap-northeast-2",
"ALLOWED_ORIGINS": "http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://192.168.0.117:3000"
},
"durable_objects": {
"bindings": [
{
"name": "CHAT_RELAY",
"class_name": "ChatRelay",
},
],
},
"d1_databases": [
{
"binding": "AUTH_DB",
"database_name": "clawshow-db",
"database_id": "b2d654ab-20b8-4502-b37f-cbcd1ce80e00",
},
],
"env": {
"prod": {
"vars": {
"BETTER_AUTH_URL": "https://clawshow-api.bowong.cc/api/auth",
"BETTER_AUTH_SECRET": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
"OPENCLAW_AUTH_TOKEN": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
"RELAY_URL": "https://clawshow-api.bowong.cc",
"AWS_ACCESS_KEY_ID": "AKIAYRH5NGRSWHN2L4M6",
"AWS_SECRET_ACCESS_KEY": "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",
"AWS_BUCKET_NAME": "modal-media-cache",
"AWS_LOCATION": "ap-northeast-2",
"ALLOWED_ORIGINS": "http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,https://clawshow.bowong.cc"
},
"durable_objects": {
"bindings": [
{
"name": "CHAT_RELAY",
"class_name": "ChatRelay"
}
]
},
"d1_databases": [
{
"binding": "AUTH_DB",
"database_name": "clawshow-db",
"database_id": "b2d654ab-20b8-4502-b37f-cbcd1ce80e00"
}
],
"routes": [
{
"pattern": "clawshow-api.bowong.cc",
"custom_domain": true
}
]
}
}
}