├── .gitignore ├── LICENSE ├── README.md ├── build.js ├── cors-rules.json ├── deploy.sh ├── package-lock.json ├── package.json ├── setup.sh ├── src ├── .DS_Store ├── CharacterMemoryManager.js ├── CharacterRegistryDO.js ├── EnhancedSQLiteMemoryAdapter.js ├── SQLiteMemoryAdapter.js ├── WorkerCompatibilityLayer.js ├── WorldRegistryDO.js ├── authorTemplate.js ├── characterDirectoryTemplate.js ├── characterTemplate.js ├── client-telegram │ ├── .npmignore │ ├── .turbo │ │ └── turbo-build.log │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ │ ├── config │ │ │ └── default.json5 │ │ ├── constants.ts │ │ ├── environment.ts │ │ ├── getOrCreateRecommenderInBe.ts │ │ ├── index.ts │ │ ├── messageManager.ts │ │ ├── telegramClient.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── discordBotDO.js ├── eliza-core │ ├── index.d.ts │ ├── index.js │ └── index.js.map ├── headerSearchBar.js ├── homeTemplate.js ├── management.js ├── registrationTemplate.js ├── rollKeyTemplate.js ├── searchBar.js ├── searchTemplate.js ├── secureHtmlService.js ├── twitter-client │ └── index.js ├── userAuthDO.js ├── worker.js └── worldTemplate.js ├── webpack.config.js ├── wrangler.toml.copy └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .wrangler 2 | .wrangler/ 3 | wrangler.toml 4 | node_modules/ 5 | .env 6 | dist/ 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # World and Character Management System 2 | 3 | ## Overview 4 | 5 | The World and Character Management System is a comprehensive solution built on Cloudflare Workers and R2 storage, designed to handle both virtual world publishing and AI character management. This system provides endpoints for managing worlds (3D environments), user authentication, and interactive AI characters. 6 | 7 | ## Prerequisites 8 | 9 | Before you begin, ensure you have the following: 10 | 11 | - A Cloudflare account with Workers and R2 enabled 12 | - [Node.js](https://nodejs.org/) (version 12 or later) and npm installed 13 | - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/get-started/) installed and authenticated with your Cloudflare account 14 | 15 | ## Configuration 16 | 17 | The `wrangler.toml` file contains the configuration for your worker and R2 bucket. Key configurations include: 18 | 19 | ### KV Namespaces 20 | - `VISIT_COUNTS`: Tracks world visits 21 | - `DOWNLOAD_RATELIMIT`: Manages rate limiting 22 | - `DOWNLOAD_QUEUE`: Handles download queues 23 | 24 | ### Durable Objects 25 | - `WORLD_REGISTRY`: Class name (`WorldRegistryDO`) 26 | - `USER_AUTH`: Class name (`UserAuthDO`) 27 | - `CHARACTER_REGISTRY`: Class name (`CharacterRegistryDO`) 28 | 29 | ### Environment Variables Required 30 | - `CHARACTER_SALT`: Secret for character encryption 31 | - `USER_KEY_SALT`: Secret for user key generation 32 | - `API_SECRET`: Admin API secret 33 | - `CF_ACCOUNT_ID`: Cloudflare account ID 34 | - `CF_GATEWAY_ID`: Cloudflare gateway ID 35 | - `OPENAI_API_KEY`: OpenAI API key (for character AI) 36 | - `ANTHROPIC_API_KEY`: Anthropic API key (for character AI) 37 | 38 | ## World Management Endpoints 39 | 40 | ### GET Endpoints 41 | - `/`: Homepage with author listings and world directory 42 | - `/world-data`: Retrieve world metadata (cached) 43 | - `/author-data`: Retrieve author data (cached) 44 | - `/authors-list`: Get a list of all authors (cached) 45 | - `/directory/{author}/{slug}`: Get HTML page for a specific world (cached) 46 | - `/author/{author}`: Get HTML page for a specific author (cached) 47 | - `/version-check`: Compare new version against author/slug/metadata.json 48 | - `/download`: Download a world file 49 | - `/download-count`: Get download count for a world 50 | - `/search`: Search worlds with optional tag filtering 51 | - `/directory/search`: Get HTML search results page 52 | - `/visit-count`: Get visit count for a world 53 | 54 | ### POST Endpoints 55 | - `/upload-world`: Upload a world's HTML content and assets 56 | - `/world-metadata`: Update world metadata 57 | - `/update-active-users`: Update active users count for a world 58 | - `/world-upload-assets`: Upload world assets (previews, etc.) 59 | - `/update-author-info`: Update author information 60 | - `/backup-world`: Create backup of currently live files 61 | - `/delete-world`: Remove a specific world 62 | 63 | ## Character Management Endpoints 64 | 65 | ### GET Endpoints 66 | - `/character-data`: Get character metadata and configuration 67 | - `/featured-characters`: Get list of featured characters 68 | - `/author-characters`: Get all characters for an author 69 | - `/characters/{author}/{name}`: Get character profile page 70 | 71 | ### POST Endpoints 72 | - `/create-character`: Create new character 73 | - `/update-character`: Update existing character 74 | - `/update-character-metadata`: Update character metadata only 75 | - `/update-character-images`: Update character profile/banner images 76 | - `/update-character-secrets`: Update character API keys and credentials 77 | - `/delete-character`: Remove character and associated data 78 | - `/api/character/session`: Initialize new character session 79 | - `/api/character/message`: Send message to character 80 | - `/api/character/memory`: Create memory for character 81 | - `/api/character/memories`: Get character memories 82 | - `/api/character/memories/by-rooms`: Get memories across multiple rooms 83 | 84 | ## Authentication System 85 | 86 | ### Public Endpoints 87 | - `/register`: Get registration page HTML 88 | - `/create-user`: Register new user (requires invite code) 89 | - `/roll-api-key`: Get API key roll interface 90 | - `/roll-key-with-token`: Complete key roll with verification token 91 | - `/initiate-key-roll`: Start key recovery process 92 | - `/verify-key-roll`: Complete key recovery with GitHub verification 93 | 94 | ### Authenticated Endpoints 95 | - `/rotate-key`: Standard API key rotation 96 | - `/delete-user`: Remove user and associated data (admin only) 97 | - `/admin-update-user`: Update user details (admin only) 98 | 99 | ## Authentication Requirements 100 | 101 | Most POST endpoints require authentication via API key in the Authorization header: 102 | 103 | ```bash 104 | curl -X POST https://your-worker.dev/endpoint \ 105 | -H "Authorization: Bearer YOUR_API_KEY" \ 106 | -H "Content-Type: application/json" 107 | ``` 108 | 109 | Admin-only endpoints require the main API secret: 110 | ```bash 111 | curl -X POST https://your-worker.dev/admin-update-user \ 112 | -H "Authorization: Bearer YOUR_API_SECRET" \ 113 | -H "Content-Type: 'application/json" 114 | ``` 115 | 116 | ## User Management 117 | 118 | The Plugin Publishing System includes a robust user management system with secure registration, API key management, and GitHub-based verification. 119 | 120 | ### Registration 121 | 122 | New users can register through the `/register` endpoint which provides a web interface for: 123 | - Creating a new author account 124 | - Setting up GitHub integration 125 | - Generating initial API credentials 126 | - Requiring invite codes for controlled access 127 | 128 | Registation workflow: 129 | 1. User visits the registration page 130 | 2. Provides username, email, GitHub username, and invite code 131 | 3. System validates credentials and invite code 132 | 4. Generates initial API key 133 | 5. Downloads configuration file with credentials 134 | 135 | 136 | ## Character System Features 137 | 138 | ### Character Configuration 139 | Characters support rich configuration including: 140 | - Basic info (name, bio, status) 141 | - Model provider selection (OpenAI/Anthropic) 142 | - Communication channels (Discord, Direct) 143 | - Personality traits and topics 144 | - Message examples 145 | - Style settings 146 | - Custom API keys per character 147 | 148 | ### Memory System 149 | Characters maintain: 150 | - Conversation history 151 | - User-specific memories 152 | - Room-based context 153 | - Memory importance scoring 154 | - Cross-room memory retrieval 155 | 156 | ### Session Management 157 | - Secure session initialization 158 | - Nonce-based message validation 159 | - Room-based conversations 160 | - Auto-cleanup of expired sessions 161 | 162 | ## Database Schema 163 | 164 | ### World Registry 165 | ```sql 166 | CREATE TABLE worlds ( 167 | id INTEGER PRIMARY KEY AUTOINCREMENT, 168 | author TEXT NOT NULL, 169 | slug TEXT NOT NULL, 170 | name TEXT NOT NULL, 171 | short_description TEXT, 172 | version TEXT NOT NULL, 173 | visit_count INTEGER DEFAULT 0, 174 | active_users INTEGER DEFAULT 0, 175 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 176 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 177 | UNIQUE(author, slug) 178 | ); 179 | ``` 180 | 181 | ### Character Registry 182 | ```sql 183 | CREATE TABLE characters ( 184 | id INTEGER PRIMARY KEY AUTOINCREMENT, 185 | author TEXT NOT NULL, 186 | name TEXT NOT NULL, 187 | model_provider TEXT NOT NULL, 188 | bio TEXT, 189 | settings TEXT, 190 | vrm_url TEXT, 191 | profile_img TEXT, 192 | banner_img TEXT, 193 | status TEXT DEFAULT 'private', 194 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 195 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 196 | UNIQUE(author, name) 197 | ); 198 | 199 | CREATE TABLE character_secrets ( 200 | id INTEGER PRIMARY KEY AUTOINCREMENT, 201 | character_id INTEGER NOT NULL, 202 | salt TEXT NOT NULL, 203 | model_keys TEXT, 204 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 205 | FOREIGN KEY(character_id) REFERENCES characters(id) 206 | ); 207 | 208 | CREATE TABLE character_sessions ( 209 | id TEXT PRIMARY KEY, 210 | character_id INTEGER, 211 | room_id TEXT, 212 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 213 | last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 214 | FOREIGN KEY(character_id) REFERENCES characters(id) 215 | ); 216 | ``` 217 | 218 | ### User Authentication 219 | ```sql 220 | CREATE TABLE users ( 221 | id INTEGER PRIMARY KEY AUTOINCREMENT, 222 | username TEXT NOT NULL UNIQUE, 223 | email TEXT NOT NULL, 224 | github_username TEXT, 225 | key_id TEXT NOT NULL UNIQUE, 226 | key_hash TEXT NOT NULL, 227 | invite_code_used TEXT NOT NULL, 228 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 229 | last_key_rotation TIMESTAMP DEFAULT CURRENT_TIMESTAMP 230 | ); 231 | ``` 232 | 233 | ### API Key Management 234 | 235 | The system provides several methods for managing API keys: 236 | 237 | #### Standard Key Rotation 238 | - Endpoint: `POST /rotate-key` 239 | - Requires current API key authentication 240 | - Generates new credentials immediately 241 | - Invalidates previous key 242 | 243 | #### GitHub-Based Key Recovery 244 | For users who need to recover access, the system provides a secure GitHub-based verification: 245 | 246 | 1. **Initiate Recovery** 247 | - Endpoint: `POST /initiate-key-roll` 248 | - Required fields: 249 | ```json 250 | { 251 | "username": "string", 252 | "email": "string" 253 | } 254 | ``` 255 | - Returns verification instructions and token 256 | 257 | 2. **Create Verification Gist** 258 | - User creates a public GitHub gist 259 | - Filename must match pattern: `plugin-publisher-verify-{username}.txt` 260 | - Content must include provided verification token 261 | 262 | 3. **Complete Verification** 263 | - Endpoint: `POST /verify-key-roll` 264 | - Required fields: 265 | ```json 266 | { 267 | "gistUrl": "string", 268 | "verificationToken": "string" 269 | } 270 | ``` 271 | - System verifies: 272 | - Gist ownership matches registered GitHub username 273 | - Verification token is valid and not expired 274 | - File content matches expected format 275 | - Returns new API key upon successful verification 276 | 277 | ![Roll Key Gist Example](../docs/assets/roll-key-screenshot.jpg) 278 | 279 | ## Caching 280 | 281 | The system implements caching for GET requests with: 282 | - CDN edge caching (1-hour TTL) 283 | - Version-based cache keys 284 | - Automatic invalidation on content updates 285 | - Auth-based cache bypassing 286 | 287 | Cache is automatically invalidated when: 288 | - A new world/character is published 289 | - Author information is updated 290 | - A GET request contains a valid API secret 291 | 292 | ## Rate Limiting 293 | 294 | - IP-based rate limiting using Cloudflare KV 295 | - 5 downloads per hour per IP/world combination 296 | - Message rate limiting for character interactions 297 | 298 | ## Security Features 299 | 300 | - HMAC-based API key validation 301 | - Nonce-based message authentication 302 | - Salt-based secret encryption 303 | - GitHub-based key recovery verification 304 | - CSP headers for world rendering 305 | - Invite code system for registration 306 | 307 | ## Error Handling 308 | 309 | All endpoints return standardized error responses: 310 | ```json 311 | { 312 | "error": "Error description", 313 | "details": "Detailed error message" 314 | } 315 | ``` 316 | 317 | ## Best Practices 318 | 319 | 1. Always verify API keys before sensitive operations 320 | 2. Use appropriate content-type headers 321 | 3. Implement proper error handling 322 | 4. Clear cache after content updates 323 | 5. Regular key rotation 324 | 6. Monitor rate limits 325 | 7. Backup important worlds before updates 326 | 327 | ## Troubleshooting 328 | 329 | 1. **Wrangler not found**: Ensure Wrangler is installed globally: `npm install -g wrangler` 330 | 2. **Deployment fails**: Verify you're logged in to your Cloudflare account: `npx wrangler login` 331 | 3. **R2 bucket creation fails**: Confirm R2 is enabled for your Cloudflare account 332 | 4. **API requests fail**: Double-check you're using the correct API Secret in your requests 333 | 334 | ## Limitations 335 | 336 | - The setup script assumes you have the necessary permissions to create resources and deploy workers in your Cloudflare account. 337 | - The script does not provide options for cleaning up resources if the setup fails midway. 338 | - Existing resources with the same names may be overwritten without warning. 339 | - Caching is set to a fixed duration (1 hour). Adjust the `max-age` value in the code if you need different caching behavior. 340 | 341 | ## Support 342 | 343 | For issues or assistance: 344 | 1. Check the Troubleshooting section 345 | 2. Review the [Cloudflare Workers documentation](https://developers.cloudflare.com/workers/) 346 | 3. Contact Cloudflare support for platform-specific issues 347 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import { polyfillNode } from 'esbuild-plugin-polyfill-node'; 3 | import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin'; 4 | import dotenv from 'dotenv'; 5 | import fs from 'fs'; 6 | import path from 'path'; 7 | 8 | const __dirname = path.dirname(new URL(import.meta.url).pathname); 9 | const distPath = path.join(__dirname, 'dist'); 10 | 11 | // Clean dist directory 12 | fs.rm(distPath, { recursive: true }, (err) => { 13 | if (err) throw err; 14 | fs.mkdir(distPath, (err) => { 15 | if (err) throw err; 16 | }); 17 | }); 18 | 19 | dotenv.config({ path: path.join(__dirname, '.env') }); 20 | 21 | esbuild.build({ 22 | entryPoints: ['src/worker.js'], 23 | bundle: true, 24 | outfile: 'dist/worker.js', 25 | format: 'esm', 26 | platform: 'browser', // Changed from 'node' to 'browser' 27 | allowOverwrite: true, 28 | target: 'ES2020', 29 | plugins: [ 30 | polyfillNode({ 31 | polyfills: { 32 | fs: true, 33 | path: true, 34 | buffer: true, 35 | // Explicitly disable worker_threads polyfill 36 | worker_threads: false, 37 | crypto: true, 38 | }, 39 | }), 40 | sentryEsbuildPlugin({ 41 | org: process.env.SENTRY_ORG, 42 | project: process.env.SENTRY_PROJECT, 43 | include: './dist', 44 | authToken: process.env.SENTRY_AUTH_TOKEN, 45 | }), 46 | ], 47 | define: { 48 | 'process.env.NODE_ENV': '"production"', 49 | 'global': 'globalThis', 50 | }, 51 | external: [ 52 | '@langchain/core/documents', 53 | '@langchain/core/utils/tiktoken', 54 | '@langchain/textsplitters', 55 | 'fastembed', 56 | '@fal-ai/client', 57 | 'unique-names-generator', 58 | 'tough-cookie', 59 | 'set-cookie-parser', 60 | 'cloudflare:workers', 61 | // Add node-specific modules to external 62 | 'worker_threads', 63 | 'node-domexception' 64 | ] 65 | }).catch(() => process.exit(1)); -------------------------------------------------------------------------------- /cors-rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "cors_rules": [ 3 | { 4 | "allowed_origins": ["*"], 5 | "allowed_methods": ["GET", "HEAD", "POST", "PUT", "DELETE"], 6 | "allowed_headers": ["*"], 7 | "max_age_seconds": 3600 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #bash script to remove the dist directory, make a new dist, then run node build.js 4 | rm -rf dist 5 | mkdir dist 6 | node build.js 7 | npx wrangler deploy 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@ai-sdk/anthropic": "^1.0.2", 5 | "@ai-sdk/google": "^1.0.3", 6 | "@ai-sdk/groq": "^1.0.3", 7 | "@ai-sdk/openai": "^1.0.4", 8 | "@fal-ai/client": "^1.0.0", 9 | "@langchain/cloudflare": "^0.1.0", 10 | "@sentry/esbuild-plugin": "^2.22.7", 11 | "agent-twitter-client": "^0.0.16", 12 | "ai": "^4.0.3", 13 | "discord-interactions": "^4.1.0", 14 | "dotenv": "^16.4.7", 15 | "js-sha1": "^0.7.0", 16 | "js-tiktoken": "^1.0.15", 17 | "langchain": "^0.3.6", 18 | "ollama-ai-provider": "^0.16.1", 19 | "openai": "^4.73.0", 20 | "rollup-plugin-node-polyfills": "^0.2.1", 21 | "telegraf": "^4.16.3", 22 | "tiktoken": "^1.0.17", 23 | "together-ai": "^0.9.0", 24 | "toucan-js": "^4.0.0", 25 | "twitter-api-v2": "^1.18.2", 26 | "unique-names-generator": "^4.7.1", 27 | "uuid": "^11.0.3", 28 | "wrangler": "^3.82.0" 29 | }, 30 | "devDependencies": { 31 | "@cloudflare/workers-types": "^4.20241218.0", 32 | "buffer": "^6.0.3", 33 | "crypto-browserify": "^3.12.1", 34 | "esbuild": "^0.24.0", 35 | "esbuild-plugin-polyfill-node": "^0.3.0", 36 | "path-browserify": "^1.0.1", 37 | "process": "^0.11.10" 38 | }, 39 | "external": [ 40 | "buffer", 41 | "path", 42 | "process", 43 | "@langchain/core/utils/tiktoken" 44 | ], 45 | "scripts": { 46 | "build": "node build.js", 47 | "dev": "wrangler dev", 48 | "deploy": "wrangler deploy" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to generate a random string 4 | generate_random_string() { 5 | chars="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 6 | length=$1 7 | result="" 8 | for i in $(seq 1 $length); do 9 | random_char=${chars:$RANDOM % ${#chars}:1} 10 | result+=$random_char 11 | done 12 | echo $result 13 | } 14 | 15 | # Function to roll API key and redeploy 16 | roll_api_key_and_redeploy() { 17 | echo "Rolling API key and redeploying..." 18 | 19 | # Generate new API secret 20 | new_api_secret=$(generate_random_string 32) 21 | echo "Generated new API secret: $new_api_secret" 22 | 23 | # Set new API secret 24 | echo "Setting new API secret..." 25 | echo "$new_api_secret" | npx wrangler secret put API_SECRET > /dev/null 2>&1 26 | if [ $? -ne 0 ]; then 27 | echo "Error setting new API secret." 28 | exit 1 29 | fi 30 | echo "New API secret set successfully." 31 | 32 | # Update env file with new API key 33 | update_env_file "$new_api_secret" 34 | 35 | # Redeploy worker 36 | echo "Redeploying worker..." 37 | output=$(npx wrangler deploy 2>&1) 38 | if [[ $output == *"Error"* ]]; then 39 | echo "Error redeploying worker: $output" 40 | exit 1 41 | fi 42 | echo "Worker redeployed successfully with new API key." 43 | echo "New API Secret: $new_api_secret" 44 | } 45 | 46 | # Function to update environment file 47 | update_env_file() { 48 | local api_key=$1 49 | local worker_url=${2:-""} # Make worker_url optional with empty default 50 | local env_file="${HOME}/.world-publisher" 51 | 52 | echo "Creating environment file at: $env_file" 53 | 54 | # Create or update the environment file with new configurations 55 | cat > "$env_file" << EOL 56 | # World Publisher Configuration 57 | # Generated on $(date) 58 | 59 | # API Key for authentication 60 | API_KEY=$api_key 61 | 62 | # Worker API URL 63 | WORLD_API_URL=$worker_url 64 | 65 | # R2 Bucket URL (needs to be manually configured) 66 | # Please set this URL after making your R2 bucket public 67 | # Format should be: https://pub-{hash}.r2.dev 68 | BUCKET_URL= 69 | 70 | # AI Configuration 71 | OPENAI_API_KEY= 72 | ANTHROPIC_API_KEY= 73 | 74 | # Cloudflare Configuration 75 | CF_ACCOUNT_ID= 76 | CF_GATEWAY_ID= 77 | 78 | # Sentry Configuration 79 | SENTRY_DSN= 80 | SENTRY_ORG= 81 | SENTRY_PROJECT= 82 | SENTRY_AUTH_TOKEN= 83 | 84 | # Note: Make your R2 bucket public through the Cloudflare dashboard 85 | # and update the BUCKET_URL accordingly 86 | EOL 87 | 88 | if [ -f "$env_file" ]; then 89 | echo "Environment file created successfully at: $env_file" 90 | chmod 600 "$env_file" # Set restrictive permissions 91 | else 92 | echo "Error: Failed to create environment file" 93 | fi 94 | } 95 | 96 | # Function to get worker URL 97 | get_worker_url() { 98 | local project_name=$1 99 | # Get the deployment information 100 | deploy_info=$(npx wrangler deploy --dry-run 2>&1) 101 | 102 | # Try to extract the URL from the deployment info 103 | if [[ $deploy_info =~ https://$project_name\..*\.workers\.dev ]]; then 104 | echo "${BASH_REMATCH[0]}" 105 | else 106 | echo "" 107 | fi 108 | } 109 | 110 | # Check if the script is being run to roll API key and redeploy 111 | if [ "$1" == "--roll-api-key" ]; then 112 | roll_api_key_and_redeploy 113 | exit 0 114 | fi 115 | 116 | # Check if wrangler is installed 117 | if ! command -v npx wrangler &> /dev/null 118 | then 119 | echo "Wrangler is not installed. Please install it first." 120 | echo "You can install it using: npm install -g wrangler" 121 | exit 1 122 | fi 123 | 124 | echo "Welcome to the World Publishing System Setup!" 125 | echo "This script will set up the necessary resources and configure your environment." 126 | 127 | # Get project name 128 | read -p "Enter a name for your project: " project_name 129 | 130 | # Get account ID 131 | echo "Fetching your account information..." 132 | account_info=$(npx wrangler whoami 2>&1) 133 | 134 | # Initialize arrays for account names and IDs 135 | account_names=() 136 | account_ids=() 137 | 138 | # Read the output line by line 139 | while IFS= read -r line; do 140 | # Look for lines containing account info between │ characters 141 | if [[ $line =~ │[[:space:]]*([^│]+)[[:space:]]*│[[:space:]]*([a-f0-9]{32})[[:space:]]*│ ]]; then 142 | name="${BASH_REMATCH[1]}" 143 | id="${BASH_REMATCH[2]}" 144 | # Skip the header line and empty lines 145 | if [[ $name != "Account Name" ]] && [[ -n "${name// }" ]]; then 146 | name=$(echo "$name" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') 147 | id=$(echo "$id" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') 148 | account_names+=("$name") 149 | account_ids+=("$id") 150 | fi 151 | fi 152 | done < <(echo "$account_info") 153 | 154 | # Account selection logic (unchanged) 155 | if [ ${#account_ids[@]} -eq 0 ]; then 156 | echo "No accounts found. Please make sure you're logged in to Wrangler." 157 | exit 1 158 | elif [ ${#account_ids[@]} -eq 1 ]; then 159 | account_id="${account_ids[0]}" 160 | echo "Using account: ${account_names[0]} (${account_id})" 161 | else 162 | echo "Multiple accounts found. Please choose the account you want to use:" 163 | for i in "${!account_names[@]}"; do 164 | echo "$((i+1)). ${account_names[$i]} (${account_ids[$i]})" 165 | done 166 | 167 | while true; do 168 | read -p "Enter the number of the account you want to use: " account_choice 169 | if [[ "$account_choice" =~ ^[0-9]+$ ]] && [ "$account_choice" -ge 1 ] && [ "$account_choice" -le "${#account_ids[@]}" ]; then 170 | account_id="${account_ids[$((account_choice-1))]}" 171 | echo "Using account: ${account_names[$((account_choice-1))]} (${account_id})" 172 | break 173 | else 174 | echo "Invalid choice. Please enter a number between 1 and ${#account_ids[@]}." 175 | fi 176 | done 177 | fi 178 | 179 | # Get current date for compatibility_date 180 | current_date=$(date +%Y-%m-%d) 181 | 182 | # Create temporary wrangler.toml for KV namespace creation 183 | echo "Creating temporary wrangler.toml for namespace creation..." 184 | cat > wrangler.toml << EOL 185 | name = "$project_name" 186 | account_id = "$account_id" 187 | EOL 188 | 189 | # Create KV namespaces 190 | echo "Creating KV namespace..." 191 | echo "Creating VISIT_COUNTS namespace..." 192 | VISIT_COUNTS_output=$(npx wrangler kv:namespace create "VISIT_COUNTS" 2>&1) 193 | if [[ $VISIT_COUNTS_output == *"Error"* ]]; then 194 | echo "Error creating VISIT_COUNTS namespace: $VISIT_COUNTS_output" 195 | exit 1 196 | fi 197 | 198 | VISIT_COUNTS_id=$(echo "$VISIT_COUNTS_output" | grep 'id = "' | sed 's/.*id = "\([^"]*\)".*/\1/') 199 | 200 | if [ -z "$VISIT_COUNTS_id" ]; then 201 | echo "Error: Failed to extract VISIT_COUNTS ID" 202 | echo "Debug output: $VISIT_COUNTS_output" 203 | exit 1 204 | fi 205 | 206 | echo "Successfully created VISIT_COUNTS namespace with ID: $VISIT_COUNTS_id" 207 | 208 | # Collect additional configuration 209 | read -p "Enter your OpenAI API Key (press Enter to skip): " openai_api_key 210 | read -p "Enter your Anthropic API Key (press Enter to skip): " anthropic_api_key 211 | read -p "Enter your Cloudflare Gateway ID (press Enter to skip): " cf_gateway_id 212 | read -p "Enter your Sentry DSN (press Enter to skip): " sentry_dsn 213 | read -p "Enter your Sentry Organization (press Enter to skip): " sentry_org 214 | read -p "Enter your Sentry Project (press Enter to skip): " sentry_project 215 | read -p "Enter your Sentry Auth Token (press Enter to skip): " sentry_auth_token 216 | 217 | # Create wrangler.toml with updated format 218 | echo "Creating/Updating wrangler.toml..." 219 | cat > wrangler.toml << EOL 220 | name = "$project_name" 221 | main = "dist/worker.js" 222 | compatibility_date = "2024-09-23" 223 | compatibility_flags = ["nodejs_compat"] 224 | account_id = "$account_id" 225 | 226 | [[rules]] 227 | type = "ESModule" 228 | globs = ["**/*.js"] 229 | 230 | kv_namespaces = [ 231 | { binding = "VISIT_COUNTS", id = "$VISIT_COUNTS_id" } 232 | ] 233 | 234 | [ai] 235 | binding = "AI" 236 | 237 | [observability] 238 | enabled = true 239 | head_sampling_rate = 1 240 | 241 | [triggers] 242 | crons = ["*/5 * * * *"] 243 | 244 | [[durable_objects.bindings]] 245 | name = "WORLD_REGISTRY" 246 | class_name = "WorldRegistryDO" 247 | 248 | [[durable_objects.bindings]] 249 | name = "USER_AUTH" 250 | class_name = "UserAuthDO" 251 | 252 | [[durable_objects.bindings]] 253 | name = "CHARACTER_REGISTRY" 254 | class_name = "CharacterRegistryDO" 255 | 256 | [[migrations]] 257 | tag = "v1" 258 | new_sqlite_classes = ["WorldRegistryDO"] 259 | 260 | [[migrations]] 261 | tag = "v2" 262 | new_sqlite_classes = ["UserAuthDO"] 263 | 264 | [[migrations]] 265 | tag = "v3" 266 | new_sqlite_classes = ["CharacterRegistryDO"] 267 | 268 | [[migrations]] 269 | tag = "v4" 270 | new_classes = ["DiscordBotDO"] 271 | 272 | [vars] 273 | ENVIRONMENT = "production" 274 | WORLD_BUCKET_URL = "" 275 | OPENAI_API_KEY = "${openai_api_key}" 276 | ANTHROPIC_API_KEY = "${anthropic_api_key}" 277 | CF_ACCOUNT_ID = "${account_id}" 278 | CF_GATEWAY_ID = "${cf_gateway_id}" 279 | SENTRY_DSN = "${sentry_dsn}" 280 | SENTRY_ORG = "${sentry_org}" 281 | SENTRY_PROJECT = "${sentry_project}" 282 | SENTRY_AUTH_TOKEN = "${sentry_auth_token}" 283 | 284 | [[r2_buckets]] 285 | binding = "WORLD_BUCKET" 286 | bucket_name = "${project_name}-bucket" 287 | preview_bucket_name = "${project_name}-bucket-preview" 288 | 289 | [env.production] 290 | vars = { ENVIRONMENT = "production" } 291 | EOL 292 | 293 | echo "Created final wrangler.toml with all configurations" 294 | 295 | # Create R2 bucket and set CORS rules (unchanged) 296 | echo "Creating R2 bucket..." 297 | output=$(npx wrangler r2 bucket create "${project_name}-bucket" 2>&1) 298 | if [[ $output != *"Created bucket"* ]]; then 299 | echo "Error creating R2 bucket: $output" 300 | exit 1 301 | fi 302 | echo "R2 bucket created successfully." 303 | 304 | # Set CORS rules for the R2 bucket 305 | echo "Setting CORS rules for the R2 bucket..." 306 | cat > cors-rules.json << EOL 307 | { 308 | "cors_rules": [ 309 | { 310 | "allowed_origins": ["*"], 311 | "allowed_methods": ["GET", "HEAD", "POST", "PUT", "DELETE"], 312 | "allowed_headers": ["*"], 313 | "max_age_seconds": 3600 314 | } 315 | ] 316 | } 317 | EOL 318 | 319 | output=$(npx wrangler r2 bucket cors put "${project_name}-bucket" --rules ./cors-rules.json 2>&1) 320 | if [[ $output == *"Error"* ]]; then 321 | echo "Error setting CORS rules: $output" 322 | exit 1 323 | fi 324 | echo "CORS rules set successfully." 325 | 326 | # Rest of the setup (API secrets, deployment, etc.) 327 | api_secret=$(generate_random_string 32) 328 | echo "Generated API secret: $api_secret" 329 | echo "Make sure to save this secret securely." 330 | 331 | echo "Deploying worker..." 332 | output=$(npx wrangler deploy 2>&1) 333 | if [[ $output == *"Error"* ]]; then 334 | echo "Error deploying worker: $output" 335 | exit 1 336 | fi 337 | echo "Worker deployed successfully." 338 | 339 | # Set secrets 340 | echo "Setting secrets..." 341 | echo "$api_secret" | npx wrangler secret put API_SECRET > /dev/null 2>&1 342 | user_key_salt=$(openssl rand -hex 32) 343 | echo "$user_key_salt" | npx wrangler secret put USER_KEY_SALT > /dev/null 2>&1 344 | 345 | read -p "Enter a default invite code: " invite_code 346 | echo "$invite_code" | npx wrangler secret put INVITE_CODE > /dev/null 2>&1 347 | 348 | # Final deployment 349 | echo "Redeploying worker to ensure latest changes..." 350 | output=$(npx wrangler deploy 2>&1) 351 | if [[ $output == *"Error"* ]]; then 352 | echo "Error redeploying worker: $output" 353 | exit 1 354 | fi 355 | echo "Worker redeployed successfully." 356 | 357 | worker_url=$(get_worker_url "$project_name") 358 | update_env_file "$api_secret" "$worker_url" 359 | 360 | echo "Setup complete! Your world publishing system is now deployed." 361 | echo "Worker URL: $worker_url" 362 | echo "API Secret: $api_secret" 363 | echo "" 364 | echo "Next steps:" 365 | echo "1. Your worker code in dist/worker.js has been deployed." 366 | echo "2. Environment configuration has been created at ~/.world-publisher" 367 | echo "3. IMPORTANT: Make your R2 bucket public through the Cloudflare dashboard" 368 | echo "4. Update the BUCKET_URL in ~/.world-publisher with your public R2 URL" 369 | echo "5. If you need to make changes, edit your worker code and run 'npx wrangler deploy' to update" 370 | echo "6. To roll the API key and redeploy, run this script with the --roll-api-key argument" 371 | echo "7. Make sure all your API keys and configurations are properly set in the environment file" 372 | echo "8. Start publishing your virtual worlds!" -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antpb/XR-Publisher-API/87824c6cce3354254c2e55f7d3fcff31839cd5f8/src/.DS_Store -------------------------------------------------------------------------------- /src/CharacterMemoryManager.js: -------------------------------------------------------------------------------- 1 | const updateMemory = async (updatedMemory) => { 2 | try { 3 | const response = await fetch(`${API_BASE_URL}/update-memory`, { 4 | method: 'POST', 5 | headers: { 6 | 'Authorization': `Bearer ${localStorage.getItem('xr_publisher_api_key')}`, 7 | 'Content-Type': 'application/json' 8 | }, 9 | body: JSON.stringify({ 10 | sessionId: sessionData.sessionId, 11 | memoryId: updatedMemory.id, 12 | content: { 13 | text: updatedMemory.content.text, 14 | action: updatedMemory.content.action, 15 | model: updatedMemory.content.model || 'gpt-4' 16 | }, 17 | type: updatedMemory.type, 18 | userId: 'antpb', 19 | importance_score: updatedMemory.importance_score, 20 | metadata: updatedMemory.metadata 21 | }) 22 | }); 23 | 24 | if (!response.ok) { 25 | const error = await response.json(); 26 | throw new Error(error.details || 'Failed to update memory'); 27 | } 28 | 29 | fetchMemories(); 30 | setSelectedMemory(null); 31 | } catch (error) { 32 | console.error('Error updating memory:', error); 33 | alert(error.message); 34 | } 35 | }; -------------------------------------------------------------------------------- /src/EnhancedSQLiteMemoryAdapter.js: -------------------------------------------------------------------------------- 1 | import { SQLiteMemoryAdapter } from './SQLiteMemoryAdapter.js'; 2 | 3 | export class EnhancedSQLiteMemoryAdapter extends SQLiteMemoryAdapter { 4 | constructor(sql) { 5 | super(sql); 6 | this.stats = { 7 | totalMemories: 0, 8 | messagesProcessed: 0, 9 | lastCleanup: Date.now() 10 | }; 11 | 12 | this.MEMORY_WARN_THRESHOLD = 10000; 13 | this.MEMORY_CRITICAL_THRESHOLD = 50000; 14 | } 15 | 16 | async initializeSchema() { 17 | // First call parent's initializeSchema to ensure all base tables exist 18 | await super.initializeSchema(); 19 | 20 | try { 21 | // Get current columns 22 | const tableInfo = await this.sql.exec('PRAGMA table_info(memories)').toArray(); 23 | const columns = tableInfo.map(col => col.name); 24 | 25 | // Add columns one at a time if they don't exist 26 | if (!columns.includes('importance_score')) { 27 | await this.sql.exec('ALTER TABLE memories ADD COLUMN importance_score FLOAT DEFAULT 0'); 28 | } 29 | if (!columns.includes('access_count')) { 30 | await this.sql.exec('ALTER TABLE memories ADD COLUMN access_count INTEGER DEFAULT 0'); 31 | } 32 | if (!columns.includes('last_accessed')) { 33 | await this.sql.exec('ALTER TABLE memories ADD COLUMN last_accessed TIMESTAMP'); 34 | } 35 | if (!columns.includes('metadata')) { 36 | await this.sql.exec('ALTER TABLE memories ADD COLUMN metadata TEXT'); 37 | } 38 | if (!columns.includes('userName')) { 39 | await this.sql.exec('ALTER TABLE memories ADD COLUMN userName TEXT'); 40 | } 41 | 42 | // Create indexes - SQLite will ignore if they already exist 43 | await this.sql.exec(` 44 | CREATE INDEX IF NOT EXISTS idx_memories_importance 45 | ON memories(importance_score); 46 | 47 | CREATE INDEX IF NOT EXISTS idx_memories_access 48 | ON memories(access_count, last_accessed); 49 | 50 | CREATE TABLE IF NOT EXISTS memory_stats ( 51 | id INTEGER PRIMARY KEY AUTOINCREMENT, 52 | total_memories INTEGER NOT NULL, 53 | messages_processed INTEGER NOT NULL, 54 | last_cleanup TIMESTAMP NOT NULL, 55 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 56 | ); 57 | `); 58 | await this.sql.exec(` 59 | CREATE INDEX IF NOT EXISTS idx_memories_username 60 | ON memories(userName); 61 | 62 | CREATE INDEX IF NOT EXISTS idx_memories_user 63 | ON memories(userId, userName); 64 | `); 65 | } catch (error) { 66 | console.error('Error adding enhanced columns:', error); 67 | // Continue even if enhancement fails - base functionality will still work 68 | } 69 | } 70 | 71 | async ensureRoomExists(roomId, agentId) { 72 | try { 73 | const room = await this.sql.exec(` 74 | SELECT id FROM rooms WHERE id = ? LIMIT 1 75 | `, roomId).toArray(); 76 | 77 | if (!room.length) { 78 | await this.sql.exec(` 79 | INSERT INTO rooms ( 80 | id, 81 | character_id, 82 | created_at, 83 | last_active 84 | ) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) 85 | `, roomId, agentId); 86 | return true; 87 | } 88 | return true; 89 | } catch (error) { 90 | console.error('Error ensuring room exists:', error); 91 | return false; 92 | } 93 | } 94 | 95 | // Add this method to help with room-related queries 96 | async getMemoriesByRoomId(roomId, options = {}) { 97 | try { 98 | const { limit = 10, type = null } = options; 99 | 100 | const query = ` 101 | SELECT * FROM memories 102 | WHERE roomId = ? 103 | ${type ? 'AND type = ?' : ''} 104 | ORDER BY createdAt DESC 105 | LIMIT ? 106 | `; 107 | 108 | const params = type ? [roomId, type, limit] : [roomId, limit]; 109 | 110 | const memories = await this.sql.exec(query, ...params).toArray(); 111 | 112 | return memories.map(m => ({ 113 | id: m.id, 114 | type: m.type, 115 | content: JSON.parse(m.content), 116 | userId: m.userId, 117 | roomId: m.roomId, 118 | agentId: m.agentId, 119 | createdAt: parseInt(m.createdAt), 120 | isUnique: Boolean(m.isUnique), 121 | importance_score: m.importance_score, 122 | metadata: m.metadata ? JSON.parse(m.metadata) : null 123 | })); 124 | } catch (error) { 125 | console.error('Error getting room memories:', error); 126 | return []; 127 | } 128 | } 129 | 130 | async getMemoriesByRoomIds({ agentId, roomIds, count = 5 }) { 131 | try { 132 | const memories = await this.sql.exec(` 133 | SELECT * FROM memories 134 | WHERE agentId = ? 135 | AND roomId IN (${roomIds.map(() => '?').join(',')}) 136 | ORDER BY createdAt DESC 137 | LIMIT ? 138 | `, agentId, ...roomIds, count).toArray(); 139 | 140 | const memoryArray = Array.isArray(memories[0]) ? memories[0] : memories; 141 | return memoryArray.map(m => ({ 142 | id: m.id, 143 | type: m.type || 'message', 144 | content: typeof m.content === 'string' ? JSON.parse(m.content) : m.content, 145 | userId: m.userId, 146 | roomId: m.roomId, 147 | agentId: m.agentId, 148 | createdAt: parseInt(m.createdAt), 149 | isUnique: Boolean(m.isUnique) 150 | })); 151 | } catch (error) { 152 | console.error('Error getting memories by room IDs:', error); 153 | return []; 154 | } 155 | } 156 | 157 | // Override createMemory to add importance scoring 158 | async createMemory({ id, type = 'message', content, userId, userName, roomId, agentId, isUnique = false, messageAction = null }) { 159 | try { 160 | if (!roomId) { 161 | return false; 162 | } 163 | if(roomId && roomId === 'post-tweet') { 164 | type = 'post-tweet'; 165 | } 166 | 167 | if(roomId && roomId === 'reply-tweet') { 168 | type = 'reply-tweet'; 169 | } 170 | 171 | if(roomId && roomId === 'tweet-prompt') { 172 | type = 'tweet-prompt'; 173 | } 174 | 175 | const importanceScore = await this.calculateImportanceScore(content); 176 | 177 | 178 | const enhancedMemory = { 179 | id: id || crypto.randomUUID(), 180 | type, 181 | content: typeof content === 'string' ? content : JSON.stringify(content), 182 | userId, // Can be null for unauthenticated users 183 | userName: userName || 'guest', // Always required 184 | roomId, 185 | agentId, 186 | createdAt: Date.now(), 187 | isUnique: isUnique ? 1 : 0, 188 | importance_score: importanceScore, 189 | metadata: JSON.stringify({ 190 | contentType: typeof content, 191 | size: JSON.stringify(content).length, 192 | context: await this.extractContext(content) 193 | }) 194 | }; 195 | 196 | await this.sql.exec(` 197 | INSERT INTO memories ( 198 | id, 199 | type, 200 | content, 201 | userId, 202 | userName, 203 | roomId, 204 | agentId, 205 | createdAt, 206 | isUnique, 207 | importance_score, 208 | metadata 209 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 210 | `, 211 | enhancedMemory.id, 212 | enhancedMemory.type, 213 | enhancedMemory.content, 214 | enhancedMemory.userId, 215 | enhancedMemory.userName, 216 | enhancedMemory.roomId, 217 | enhancedMemory.agentId, 218 | enhancedMemory.createdAt, 219 | enhancedMemory.isUnique, 220 | enhancedMemory.importance_score, 221 | enhancedMemory.metadata 222 | ); 223 | 224 | this.stats.totalMemories++; 225 | this.stats.messagesProcessed++; 226 | await this.checkMemoryThresholds(); 227 | 228 | return true; 229 | } catch (error) { 230 | return false; 231 | } 232 | } 233 | 234 | 235 | // Add new helper methods 236 | async calculateImportanceScore(content) { 237 | const contentStr = typeof content === 'string' ? content : JSON.stringify(content); 238 | const length = contentStr.length; 239 | const uniqueWords = new Set(contentStr.toLowerCase().split(/\s+/)).size; 240 | return Math.min((length * 0.01 + uniqueWords * 0.1) / 100, 1.0); 241 | } 242 | 243 | async extractContext(content) { 244 | const contentStr = typeof content === 'string' ? content : JSON.stringify(content); 245 | return { 246 | length: contentStr.length, 247 | timestamp: Date.now(), 248 | summary: contentStr.slice(0, 100) + '...' 249 | }; 250 | } 251 | 252 | async checkMemoryThresholds() { 253 | const totalMemories = (await this.sql.exec('SELECT COUNT(*) as count FROM memories').toArray())[0].count; 254 | 255 | if (totalMemories >= this.MEMORY_CRITICAL_THRESHOLD) { 256 | console.error(`CRITICAL: Memory count (${totalMemories}) has exceeded critical threshold`); 257 | } else if (totalMemories >= this.MEMORY_WARN_THRESHOLD) { 258 | console.warn(`WARNING: Memory count (${totalMemories}) has exceeded warning threshold`); 259 | } 260 | } 261 | 262 | async deleteMemory(memoryId) { 263 | try { 264 | console.log('[EnhancedSQLiteMemoryAdapter.deleteMemory] Deleting memory:', { memoryId }); 265 | 266 | await this.sql.exec(` 267 | DELETE FROM memories 268 | WHERE id = ? 269 | `, memoryId); 270 | 271 | // Check if any rows were affected 272 | const changes = await this.sql.exec('SELECT changes() as count').toArray(); 273 | const success = changes[0].count > 0; 274 | 275 | console.log('[EnhancedSQLiteMemoryAdapter.deleteMemory] Delete result:', { 276 | success, 277 | changes: changes[0].count 278 | }); 279 | 280 | if (success) { 281 | // Remove from cache only if delete was successful 282 | this.memories.delete(memoryId); 283 | } 284 | 285 | return success; 286 | } catch (error) { 287 | console.error('[EnhancedSQLiteMemoryAdapter.deleteMemory] Error:', { 288 | message: error.message, 289 | stack: error.stack 290 | }); 291 | return false; 292 | } 293 | } 294 | 295 | async findMemories(query, options = {}) { 296 | try { 297 | const { limit = 10, type = null, agentId, userId } = options; 298 | console.log('[findMemories] Starting search with:', { query, agentId, userId, type, limit }); 299 | 300 | if (!agentId) { 301 | console.error('[findMemories] Missing required agentId parameter'); 302 | return []; 303 | } 304 | 305 | // First get the character ID from the slug 306 | const characters = await this.sql.exec('SELECT id FROM characters WHERE slug = ? LIMIT 1', agentId).toArray(); 307 | if (!characters.length) { 308 | console.error('[findMemories] No character found with slug:', agentId); 309 | return []; 310 | } 311 | const characterId = characters[0].id + '.0'; // Add .0 to match database format 312 | 313 | // Debug: Get all memories for this character first 314 | const allMemories = await this.sql.exec(` 315 | SELECT * FROM memories 316 | WHERE agentId = ? 317 | ORDER BY createdAt DESC 318 | `, characterId).toArray(); 319 | 320 | console.log('[findMemories] All memories for character:', { 321 | agentId: characterId, 322 | count: allMemories.length, 323 | memories: allMemories.map(m => ({ 324 | id: m.id, 325 | type: m.type, 326 | agentId: m.agentId, 327 | content: m.content.substring(0, 100) 328 | })) 329 | }); 330 | 331 | let conditions = [ 332 | 'agentId = ?', 333 | "json_extract(content, '$.text') LIKE ?" 334 | ]; 335 | let sqlParams = [characterId, `%${query}%`]; 336 | 337 | if (type) { 338 | conditions.push('type = ?'); 339 | sqlParams.push(type); 340 | } 341 | 342 | if (userId) { 343 | conditions.push('(userId = ? OR userId IS NULL)'); // Make userId optional 344 | sqlParams.push(userId); 345 | } 346 | 347 | sqlParams.push(limit); 348 | 349 | const sqlQuery = ` 350 | SELECT * FROM memories 351 | WHERE ${conditions.join(' AND ')} 352 | ORDER BY createdAt DESC 353 | LIMIT ?`; 354 | 355 | console.log('[findMemories] Executing query:', { 356 | sql: sqlQuery, 357 | params: sqlParams, 358 | conditions 359 | }); 360 | 361 | let memories = await this.sql.exec(sqlQuery, ...sqlParams).toArray(); 362 | console.log('[findMemories] Raw query results:', { 363 | count: memories.length, 364 | firstResult: memories[0] ? { 365 | id: memories[0].id, 366 | type: memories[0].type, 367 | agentId: memories[0].agentId, 368 | contentPreview: memories[0].content.substring(0, 100) 369 | } : null 370 | }); 371 | 372 | // Only attempt semantic search if the method exists and we have capacity for more results 373 | if (memories.length < limit && 374 | typeof this.searchMemoriesByEmbedding === 'function' && 375 | typeof this.createEmbedding === 'function') { 376 | try { 377 | const embedding = await this.createEmbedding(query); 378 | if (embedding) { 379 | const semanticResults = await this.searchMemoriesByEmbedding(embedding, { 380 | limit: limit - memories.length, 381 | type, 382 | agentId 383 | }); 384 | memories = [...memories, ...semanticResults]; 385 | } 386 | } catch (semanticError) { 387 | console.warn('[findMemories] Semantic search failed:', semanticError.message); 388 | } 389 | } 390 | 391 | const processedMemories = memories.map(m => ({ 392 | id: m.id, 393 | type: m.type, 394 | content: JSON.parse(m.content), 395 | userId: m.userId, 396 | roomId: m.roomId, 397 | agentId: m.agentId, 398 | createdAt: parseInt(m.createdAt), 399 | isUnique: Boolean(m.isUnique), 400 | importance_score: m.importance_score, 401 | metadata: m.metadata ? JSON.parse(m.metadata) : null 402 | })); 403 | 404 | console.log('[findMemories] Final processed results:', { 405 | count: processedMemories.length, 406 | firstResult: processedMemories[0] ? { 407 | id: processedMemories[0].id, 408 | type: processedMemories[0].type, 409 | agentId: processedMemories[0].agentId 410 | } : null 411 | }); 412 | 413 | return processedMemories; 414 | } catch (error) { 415 | console.error('[findMemories] Error:', { 416 | message: error.message, 417 | stack: error.stack 418 | }); 419 | return []; 420 | } 421 | } 422 | 423 | async updateMemory(memory) { 424 | try { 425 | const { id } = memory; 426 | 427 | console.log('[EnhancedSQLiteMemoryAdapter.updateMemory] Starting update:', { 428 | id, 429 | updates: Object.keys(memory).filter(k => k !== 'id') 430 | }); 431 | 432 | // First verify the memory exists and get current values 433 | const existing = await this.sql.exec('SELECT * FROM memories WHERE id = ? LIMIT 1', id).toArray(); 434 | console.log('[EnhancedSQLiteMemoryAdapter.updateMemory] Memory exists check:', { 435 | id, 436 | exists: existing.length > 0 437 | }); 438 | 439 | if (!existing.length) { 440 | console.error('[EnhancedSQLiteMemoryAdapter.updateMemory] Memory not found:', id); 441 | return false; 442 | } 443 | 444 | // Build update query dynamically based on provided fields 445 | const updates = []; 446 | const params = []; 447 | 448 | if ('type' in memory) { 449 | updates.push('type = ?'); 450 | params.push(memory.type); 451 | } 452 | if ('content' in memory) { 453 | updates.push('content = ?'); 454 | // Ensure content is stringified if it's an object 455 | params.push(typeof memory.content === 'string' ? memory.content : JSON.stringify(memory.content)); 456 | } 457 | if ('userId' in memory) { 458 | updates.push('userId = ?'); 459 | params.push(memory.userId); 460 | } 461 | if ('userName' in memory) { 462 | updates.push('userName = ?'); 463 | params.push(memory.userName); 464 | } 465 | // Update importance_score if it's provided in the payload 466 | if ('importance_score' in memory) { 467 | updates.push('importance_score = ?'); 468 | params.push(memory.importance_score); 469 | console.log('[EnhancedSQLiteMemoryAdapter.updateMemory] Updating importance score:', memory.importance_score); 470 | } 471 | 472 | // Only proceed if we have updates to make 473 | if (updates.length === 0) { 474 | console.log('[EnhancedSQLiteMemoryAdapter.updateMemory] No fields to update'); 475 | return true; 476 | } 477 | 478 | // Add the id as the last parameter 479 | params.push(id); 480 | 481 | const query = ` 482 | UPDATE memories 483 | SET ${updates.join(', ')} 484 | WHERE id = ? 485 | `; 486 | 487 | console.log('[EnhancedSQLiteMemoryAdapter.updateMemory] Executing update:', { 488 | query, 489 | params: params.map((p, i) => i === params.length - 1 ? `id: ${p}` : `param${i}: ${p}`) 490 | }); 491 | 492 | await this.sql.exec(query, ...params); 493 | 494 | const changes = await this.sql.exec('SELECT changes() as count').toArray(); 495 | const success = changes[0].count > 0; 496 | 497 | console.log('[EnhancedSQLiteMemoryAdapter.updateMemory] Update result:', { 498 | success, 499 | changes: changes[0].count 500 | }); 501 | 502 | return success; 503 | } catch (error) { 504 | console.error('[EnhancedSQLiteMemoryAdapter.updateMemory] Error:', { 505 | message: error.message, 506 | stack: error.stack, 507 | type: error.constructor.name 508 | }); 509 | return false; 510 | } 511 | } 512 | 513 | async getAllMemoriesByCharacter(characterId, options = {}) { 514 | try { 515 | const { limit = 100, type = null } = options; 516 | console.log('[getAllMemoriesByCharacter] Input:', { characterId, options }); 517 | 518 | // First get all unique rooms for this character 519 | const roomsQuery = 'SELECT DISTINCT roomId FROM memories WHERE agentId = ?'; 520 | const rooms = await this.sql.exec(roomsQuery, characterId).toArray(); 521 | console.log('[getAllMemoriesByCharacter] Found rooms:', rooms.map(r => r.roomId)); 522 | 523 | const query = ` 524 | SELECT * FROM memories 525 | WHERE agentId = ? 526 | ${type ? 'AND type = ?' : ''} 527 | ORDER BY createdAt DESC 528 | LIMIT ? 529 | `; 530 | 531 | const params = [characterId]; 532 | if (type) params.push(type); 533 | params.push(limit); 534 | 535 | console.log('[getAllMemoriesByCharacter] Query:', { 536 | sql: query, 537 | params, 538 | paramTypes: params.map(p => typeof p) 539 | }); 540 | 541 | const memories = await this.sql.exec(query, ...params).toArray(); 542 | console.log('[getAllMemoriesByCharacter] Found memories:', { 543 | count: memories.length, 544 | firstFew: memories.slice(0, 3).map(m => ({ 545 | id: m.id, 546 | type: m.type, 547 | roomId: m.roomId, 548 | createdAt: m.createdAt 549 | })) 550 | }); 551 | 552 | const result = memories.map(m => { 553 | try { 554 | let parsedContent; 555 | try { 556 | parsedContent = JSON.parse(m.content); 557 | } catch (parseError) { 558 | console.warn('[getAllMemoriesByCharacter] Failed to parse content:', { 559 | id: m.id, 560 | error: parseError.message, 561 | content: m.content?.substring(0, 100) 562 | }); 563 | // If JSON parsing fails, return the raw content 564 | parsedContent = { text: m.content }; 565 | } 566 | 567 | let parsedMetadata = null; 568 | try { 569 | if (m.metadata) { 570 | parsedMetadata = JSON.parse(m.metadata); 571 | } 572 | } catch (metadataError) { 573 | console.warn('[getAllMemoriesByCharacter] Failed to parse metadata:', { 574 | id: m.id, 575 | error: metadataError.message 576 | }); 577 | } 578 | 579 | return { 580 | id: m.id, 581 | type: m.type, 582 | content: parsedContent, 583 | userId: m.userId, 584 | roomId: m.roomId, 585 | agentId: m.agentId, 586 | createdAt: parseInt(m.createdAt), 587 | isUnique: Boolean(m.isUnique), 588 | importance_score: m.importance_score, 589 | metadata: parsedMetadata 590 | }; 591 | } catch (memoryError) { 592 | console.error('[getAllMemoriesByCharacter] Failed to process memory:', { 593 | id: m.id, 594 | error: memoryError.message 595 | }); 596 | return null; 597 | } 598 | }).filter(Boolean); // Remove any null entries from failed processing 599 | 600 | console.log('[getAllMemoriesByCharacter] Processed results:', { 601 | totalCount: result.length, 602 | uniqueRooms: [...new Set(result.map(m => m.roomId))], 603 | uniqueTypes: [...new Set(result.map(m => m.type))] 604 | }); 605 | 606 | return result; 607 | } catch (error) { 608 | console.error('[getAllMemoriesByCharacter] Error:', { 609 | message: error.message, 610 | stack: error.stack, 611 | type: error.constructor.name 612 | }); 613 | return []; 614 | } 615 | } 616 | 617 | // Keep this for backward compatibility but make it use the new method 618 | async getAllMemoriesBySessionId(sessionId, options = {}) { 619 | try { 620 | const { type = null, getAllMemories = false } = options; 621 | 622 | // If getAllMemories is true, get the character ID from any memory with this session ID 623 | if (getAllMemories) { 624 | const sessionQuery = 'SELECT DISTINCT agentId FROM memories WHERE userId = ? LIMIT 1'; 625 | const agents = await this.sql.exec(sessionQuery, sessionId).toArray(); 626 | 627 | if (agents.length > 0) { 628 | return this.getAllMemoriesByCharacter(agents[0].agentId, { type }); 629 | } 630 | } 631 | 632 | // Fall back to session-based retrieval if getAllMemories is false or no agent found 633 | const query = ` 634 | SELECT * FROM memories 635 | WHERE userId = ? 636 | ${type ? 'AND type = ?' : ''} 637 | ORDER BY createdAt DESC 638 | LIMIT 100 639 | `; 640 | 641 | const params = [sessionId]; 642 | if (type) params.push(type); 643 | 644 | const memories = await this.sql.exec(query, ...params).toArray(); 645 | return memories.map(m => ({ 646 | id: m.id, 647 | type: m.type, 648 | content: JSON.parse(m.content), 649 | userId: m.userId, 650 | roomId: m.roomId, 651 | agentId: m.agentId, 652 | createdAt: parseInt(m.createdAt), 653 | isUnique: Boolean(m.isUnique), 654 | importance_score: m.importance_score, 655 | metadata: m.metadata ? JSON.parse(m.metadata) : null 656 | })); 657 | } catch (error) { 658 | console.error('[getAllMemoriesBySessionId] Error:', error); 659 | return []; 660 | } 661 | } 662 | } 663 | -------------------------------------------------------------------------------- /src/WorkerCompatibilityLayer.js: -------------------------------------------------------------------------------- 1 | // Cloudflare Worker Compatibility Layer 2 | class WorkerCompatibilityLayer { 3 | static init() { 4 | // Stub missing request properties 5 | if (!Request.prototype.hasOwnProperty('referrerPolicy')) { 6 | Object.defineProperty(Request.prototype, 'referrerPolicy', { 7 | get() { 8 | return 'strict-origin-when-cross-origin'; // Default value 9 | }, 10 | set() { /* No-op */ }, 11 | configurable: true 12 | }); 13 | } 14 | 15 | // Add missing RequestInit properties 16 | const originalRequest = global.Request; 17 | global.Request = function(...args) { 18 | // Remove referrerPolicy from options if present 19 | if (args[1] && args[1].referrerPolicy) { 20 | const [input, init = {}] = args; 21 | const { referrerPolicy, ...cleanInit } = init; 22 | args[1] = cleanInit; 23 | } 24 | return new originalRequest(...args); 25 | }; 26 | global.Request.prototype = originalRequest.prototype; 27 | 28 | // Patch fetch to clean up unsupported options 29 | const originalFetch = global.fetch; 30 | global.fetch = function(...args) { 31 | if (args[1] && args[1].referrerPolicy) { 32 | const [input, init = {}] = args; 33 | const { referrerPolicy, ...cleanInit } = init; 34 | return originalFetch(input, cleanInit); 35 | } 36 | return originalFetch(...args); 37 | }; 38 | } 39 | 40 | static cleanRequestInit(init) { 41 | if (!init) return init; 42 | 43 | const { 44 | referrerPolicy, 45 | ...cleanInit 46 | } = init; 47 | 48 | return cleanInit; 49 | } 50 | } 51 | 52 | // Export a helper function to wrap initialization 53 | export function initializeWorkerCompat() { 54 | WorkerCompatibilityLayer.init(); 55 | } -------------------------------------------------------------------------------- /src/WorldRegistryDO.js: -------------------------------------------------------------------------------- 1 | export class WorldRegistryDO { 2 | constructor(state, env) { 3 | this.state = state; 4 | this.env = env; 5 | this.sql = state.storage.sql; 6 | this.initializeSchema(); 7 | } 8 | 9 | async initializeSchema() { 10 | try { 11 | this.sql.exec(` 12 | -- World metadata table 13 | CREATE TABLE IF NOT EXISTS worlds ( 14 | id INTEGER PRIMARY KEY AUTOINCREMENT, 15 | author TEXT NOT NULL, 16 | slug TEXT NOT NULL, 17 | name TEXT NOT NULL, 18 | short_description TEXT, 19 | long_description TEXT, 20 | version TEXT NOT NULL, 21 | preview_image TEXT, 22 | html_url TEXT NOT NULL, 23 | entry_point TEXT DEFAULT '0,0,0', 24 | visibility TEXT DEFAULT 'public', 25 | capacity INTEGER DEFAULT 100, 26 | visit_count INTEGER DEFAULT 0, 27 | active_users INTEGER DEFAULT 0, 28 | content_rating TEXT DEFAULT 'everyone', 29 | properties TEXT, 30 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 31 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 32 | UNIQUE(author, slug) 33 | ); 34 | 35 | -- World tags for search 36 | CREATE TABLE IF NOT EXISTS world_tags ( 37 | world_id INTEGER, 38 | tag TEXT NOT NULL, 39 | FOREIGN KEY(world_id) REFERENCES worlds(id), 40 | PRIMARY KEY(world_id, tag) 41 | ); 42 | 43 | -- World versions for version history 44 | CREATE TABLE IF NOT EXISTS world_versions ( 45 | world_id INTEGER, 46 | version TEXT NOT NULL, 47 | html_url TEXT NOT NULL, 48 | changelog TEXT, 49 | published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 50 | FOREIGN KEY(world_id) REFERENCES worlds(id), 51 | PRIMARY KEY(world_id, version) 52 | ); 53 | 54 | CREATE INDEX IF NOT EXISTS idx_worlds_search 55 | ON worlds(name, short_description); 56 | 57 | CREATE INDEX IF NOT EXISTS idx_worlds_visits 58 | ON worlds(visit_count DESC); 59 | 60 | CREATE INDEX IF NOT EXISTS idx_worlds_author 61 | ON worlds(author); 62 | 63 | CREATE TABLE IF NOT EXISTS authors ( 64 | id INTEGER PRIMARY KEY AUTOINCREMENT, 65 | username TEXT NOT NULL UNIQUE, 66 | email TEXT, 67 | avatar_url TEXT, 68 | bio TEXT, 69 | member_since TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 70 | website TEXT, 71 | twitter TEXT, 72 | github TEXT, 73 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 74 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 75 | ); 76 | 77 | CREATE INDEX IF NOT EXISTS idx_authors_username 78 | ON authors(username); 79 | `); 80 | } catch (error) { 81 | console.error("Error initializing schema:", error); 82 | throw error; 83 | } 84 | } 85 | 86 | async syncAuthorData(authorData) { 87 | try { 88 | if (typeof authorData !== 'object' || authorData === null) { 89 | throw new Error(`Invalid author data type: ${typeof authorData}`); 90 | } 91 | 92 | const result = await this.sql.exec(` 93 | INSERT INTO authors ( 94 | username, email, avatar_url, bio, 95 | website, twitter, github 96 | ) 97 | VALUES (?, ?, ?, ?, ?, ?, ?) 98 | ON CONFLICT(username) DO UPDATE SET 99 | email = EXCLUDED.email, 100 | avatar_url = EXCLUDED.avatar_url, 101 | bio = EXCLUDED.bio, 102 | website = EXCLUDED.website, 103 | twitter = EXCLUDED.twitter, 104 | github = EXCLUDED.github, 105 | updated_at = CURRENT_TIMESTAMP 106 | RETURNING id 107 | `, 108 | authorData.username, 109 | authorData.email, 110 | authorData.avatar_url, 111 | authorData.bio, 112 | authorData.website, 113 | authorData.twitter, 114 | authorData.github 115 | ).one(); 116 | 117 | return result.id; 118 | } catch (error) { 119 | console.error("Error syncing author data:", error); 120 | throw error; 121 | } 122 | } 123 | 124 | async handleSearch(query = '', tags = [], limit = 20, offset = 0) { 125 | try { 126 | const whereClause = query ? 127 | `WHERE (w.name LIKE ? OR w.short_description LIKE ? OR w.author LIKE ?)` : 128 | 'WHERE 1=1'; 129 | 130 | const tagFilters = tags.length > 0 ? 131 | `AND w.id IN ( 132 | SELECT world_id FROM world_tags 133 | WHERE tag IN (${tags.map(() => '?').join(',')}) 134 | GROUP BY world_id 135 | HAVING COUNT(DISTINCT tag) = ${tags.length} 136 | )` : ''; 137 | 138 | const params = query ? 139 | [...query.split(' ').flatMap(term => [`%${term}%`, `%${term}%`, `%${term}%`]), ...tags, limit, offset] : 140 | [...tags, limit, offset]; 141 | 142 | const results = this.sql.exec(` 143 | SELECT w.*, 144 | a.username as author_username, 145 | a.avatar_url as author_avatar, 146 | ( 147 | SELECT GROUP_CONCAT(tag) 148 | FROM world_tags 149 | WHERE world_id = w.id 150 | ) as tags 151 | FROM worlds w 152 | LEFT JOIN authors a ON w.author = a.username 153 | ${whereClause} 154 | ${tagFilters} 155 | ORDER BY w.visit_count DESC, w.updated_at DESC 156 | LIMIT ? OFFSET ? 157 | `, ...params).toArray(); 158 | 159 | return results.map(row => ({ 160 | ...row, 161 | tags: row.tags ? row.tags.split(',') : [] 162 | })); 163 | } catch (error) { 164 | console.error('Search error:', error); 165 | return []; 166 | } 167 | } 168 | 169 | async recordVisit(author, slug) { 170 | const world = this.sql.exec( 171 | "SELECT id FROM worlds WHERE author = ? AND slug = ?", 172 | author, slug 173 | ).one(); 174 | 175 | if (!world) return false; 176 | 177 | await this.sql.exec(` 178 | UPDATE worlds 179 | SET visit_count = visit_count + 1, 180 | updated_at = CURRENT_TIMESTAMP 181 | WHERE id = ? 182 | `, world.id); 183 | 184 | return true; 185 | } 186 | 187 | async updateActiveUsers(author, slug, count) { 188 | await this.sql.exec(` 189 | UPDATE worlds 190 | SET active_users = ?, 191 | updated_at = CURRENT_TIMESTAMP 192 | WHERE author = ? AND slug = ? 193 | `, count, author, slug); 194 | } 195 | 196 | async createOrUpdateWorld(worldData) { 197 | try { 198 | return await this.state.storage.transaction(async (txn) => { 199 | // Ensure all required fields have values or defaults 200 | const data = { 201 | author: worldData.author, 202 | slug: worldData.slug, 203 | name: worldData.name, 204 | short_description: worldData.short_description || null, 205 | long_description: worldData.long_description || null, 206 | version: worldData.version || '1.0.0', 207 | preview_image: worldData.preview_image || null, 208 | html_url: worldData.html_url, 209 | entry_point: worldData.entry_point || '0,0,0', 210 | visibility: worldData.visibility || 'public', 211 | capacity: worldData.capacity || 100, 212 | content_rating: worldData.content_rating || 'everyone', 213 | properties: worldData.properties ? JSON.stringify(worldData.properties) : null 214 | }; 215 | 216 | const result = await this.sql.exec(` 217 | INSERT INTO worlds ( 218 | author, 219 | slug, 220 | name, 221 | short_description, 222 | long_description, 223 | version, 224 | preview_image, 225 | html_url, 226 | entry_point, 227 | visibility, 228 | capacity, 229 | content_rating, 230 | properties 231 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 232 | ON CONFLICT(author, slug) DO UPDATE SET 233 | name = EXCLUDED.name, 234 | short_description = EXCLUDED.short_description, 235 | long_description = EXCLUDED.long_description, 236 | version = EXCLUDED.version, 237 | preview_image = EXCLUDED.preview_image, 238 | html_url = EXCLUDED.html_url, 239 | entry_point = EXCLUDED.entry_point, 240 | visibility = EXCLUDED.visibility, 241 | capacity = EXCLUDED.capacity, 242 | content_rating = EXCLUDED.content_rating, 243 | properties = EXCLUDED.properties, 244 | updated_at = CURRENT_TIMESTAMP 245 | RETURNING id 246 | `, 247 | data.author, 248 | data.slug, 249 | data.name, 250 | data.short_description, 251 | data.long_description, 252 | data.version, 253 | data.preview_image, 254 | data.html_url, 255 | data.entry_point, 256 | data.visibility, 257 | data.capacity, 258 | data.content_rating, 259 | data.properties 260 | ).one(); 261 | 262 | if (!result?.id) { 263 | throw new Error('Failed to create/update world record'); 264 | } 265 | 266 | // Handle tags if present 267 | if (worldData.tags && Array.isArray(worldData.tags)) { 268 | await this.sql.exec("DELETE FROM world_tags WHERE world_id = ?", result.id); 269 | for (const tag of worldData.tags) { 270 | await this.sql.exec( 271 | "INSERT INTO world_tags (world_id, tag) VALUES (?, ?)", 272 | result.id, 273 | tag 274 | ); 275 | } 276 | } 277 | 278 | // Add version history 279 | await this.sql.exec(` 280 | INSERT INTO world_versions ( 281 | world_id, 282 | version, 283 | html_url, 284 | changelog 285 | ) VALUES (?, ?, ?, ?) 286 | `, result.id, data.version, data.html_url, worldData.changelog || ''); 287 | 288 | return result.id; 289 | }); 290 | } catch (error) { 291 | console.error("Error creating/updating world:", error); 292 | throw error; 293 | } 294 | } 295 | 296 | async fetch(request) { 297 | if (request.method === "GET") { 298 | return new Response("Method not allowed", { status: 405 }); 299 | } 300 | 301 | const url = new URL(request.url); 302 | 303 | switch (url.pathname) { 304 | case '/search': { 305 | const body = await request.json(); 306 | const results = await this.handleSearch( 307 | body.query || '', 308 | body.tags || [], 309 | parseInt(body.limit || 20), 310 | parseInt(body.offset || 0) 311 | ); 312 | return new Response(JSON.stringify(results), { 313 | headers: { 'Content-Type': 'application/json' } 314 | }); 315 | } 316 | 317 | case '/create-world': 318 | case '/update-world': { 319 | const worldData = await request.json(); 320 | const worldId = await this.createOrUpdateWorld(worldData); 321 | return new Response(JSON.stringify({ success: true, worldId }), { 322 | headers: { 'Content-Type': 'application/json' } 323 | }); 324 | } 325 | 326 | case '/record-visit': { 327 | const { author, slug } = await request.json(); 328 | const success = await this.recordVisit(author, slug); 329 | return new Response(JSON.stringify({ success }), { 330 | headers: { 'Content-Type': 'application/json' } 331 | }); 332 | } 333 | 334 | case '/update-active-users': { 335 | const { author, slug, count } = await request.json(); 336 | await this.updateActiveUsers(author, slug, count); 337 | return new Response(JSON.stringify({ success: true }), { 338 | headers: { 'Content-Type': 'application/json' } 339 | }); 340 | } 341 | 342 | case '/sync-author': { 343 | const authorData = await request.json(); 344 | const authorId = await this.syncAuthorData(authorData); 345 | return new Response(JSON.stringify({ success: true, authorId }), { 346 | headers: { 'Content-Type': 'application/json' } 347 | }); 348 | } 349 | 350 | case '/list-authors': { 351 | try { 352 | const authors = await this.sql.exec(` 353 | SELECT 354 | a.*, 355 | COUNT(DISTINCT w.id) as world_count, 356 | SUM(w.visit_count) as total_visits 357 | FROM authors a 358 | LEFT JOIN worlds w ON w.author = a.username 359 | GROUP BY a.id 360 | ORDER BY total_visits DESC NULLS LAST, a.updated_at DESC 361 | `).toArray(); 362 | 363 | return new Response(JSON.stringify(authors), { 364 | headers: { 'Content-Type': 'application/json' } 365 | }); 366 | } catch (error) { 367 | console.error('Error listing authors:', error); 368 | return new Response(JSON.stringify({ error: 'Internal server error' }), { 369 | status: 500, 370 | headers: { 'Content-Type': 'application/json' } 371 | }); 372 | } 373 | } 374 | 375 | default: 376 | return new Response("Not found", { status: 404 }); 377 | } 378 | } 379 | } -------------------------------------------------------------------------------- /src/authorTemplate.js: -------------------------------------------------------------------------------- 1 | import { createSecureHtmlService } from './secureHtmlService'; 2 | import { createHeaderSearchBar } from './headerSearchBar'; 3 | 4 | export default function generateAuthorHTML(authorData) { 5 | const secureHtmlService = createSecureHtmlService(); 6 | const safeAuthor = secureHtmlService.sanitizeAuthorData(authorData); 7 | 8 | if (!safeAuthor) { 9 | return new Response('Invalid author data', { status: 400 }); 10 | } 11 | 12 | // Ensure worlds array exists 13 | safeAuthor.worlds = safeAuthor.worlds || []; 14 | 15 | // Sort worlds by visit count 16 | const sortedWorlds = [...safeAuthor.worlds].sort((a, b) => 17 | (b.visit_count || 0) - (a.visit_count || 0) 18 | ); 19 | 20 | const html = ` 21 | 22 | 23 | 24 | ${safeAuthor.username} - Creator Profile 25 | 30 | 34 | 88 | 89 | 90 |
91 | ${createHeaderSearchBar()} 92 |
93 |
94 |
95 |
96 | ${safeAuthor.username} 101 |
102 |

${safeAuthor.username}

103 | ${safeAuthor.website ? ` 104 | 111 | Visit Website 112 | 113 | ` : ''} 114 |

${safeAuthor.bio || ''}

115 |
116 | ${safeAuthor.twitter ? ` 117 | 118 | 119 | 120 | ` : ''} 121 | ${safeAuthor.github ? ` 122 | 123 | 124 | 125 | ` : ''} 126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | 134 |
135 | 136 | 137 |
138 | 139 | 140 |
141 |
142 |

143 | Worlds by ${safeAuthor.username} 144 |

145 |
146 | ${sortedWorlds.length > 0 ? ` 147 |
148 | ${sortedWorlds.map(world => ` 149 |
150 |
151 |
152 | ${world.name} 157 | 158 |

${world.name}

159 |
160 |
161 |

162 | ${world.short_description || 'No description available.'} 163 |

164 |
165 |
166 | v${world.version || 'N/A'} 167 |
168 |
169 | 170 | 171 | ${world.active_users || 0} 172 | 173 | 174 | 175 | ${world.visit_count || 0} 176 | 177 |
178 |
179 | 185 |
186 |
187 | `).join('')} 188 |
189 | ` : ` 190 |
191 |

No worlds published yet.

192 |
193 | `} 194 |
195 |
196 |
197 | 198 | 199 |
200 |
201 |

202 | Characters by ${safeAuthor.username} 203 |

204 |
205 |
206 | Loading characters... 207 |
208 |
209 |
210 |
211 | 212 |
213 |
214 |

© ${new Date().getFullYear()} World Publisher

215 |

216 | Terms | 217 | Privacy | 218 | Source 219 |

220 |
221 |
222 |
223 |
224 | 280 | 281 | 282 | `; 283 | 284 | return secureHtmlService.transformHTML(html); 285 | } -------------------------------------------------------------------------------- /src/characterDirectoryTemplate.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antpb/XR-Publisher-API/87824c6cce3354254c2e55f7d3fcff31839cd5f8/src/characterDirectoryTemplate.js -------------------------------------------------------------------------------- /src/characterTemplate.js: -------------------------------------------------------------------------------- 1 | import { createSecureHtmlService } from './secureHtmlService'; 2 | import { createHeaderSearchBar } from './headerSearchBar'; 3 | 4 | export default async function generateCharacterHTML(characterData, env) { 5 | const secureHtmlService = createSecureHtmlService(); 6 | const safeCharacter = secureHtmlService.sanitizeCharacterData(characterData); 7 | 8 | // Create a personality description that includes all the character data 9 | const personalityData = { 10 | name: safeCharacter.name, 11 | bio: safeCharacter.bio, 12 | lore: safeCharacter.lore, 13 | topics: safeCharacter.topics, 14 | style: safeCharacter.style, 15 | adjectives: safeCharacter.adjectives, 16 | messageExamples: safeCharacter.messageExamples, 17 | modelProvider: safeCharacter.modelProvider, 18 | settings: safeCharacter.settings 19 | }; 20 | 21 | // Create a natural language description for the personality attribute 22 | const personalityText = `${safeCharacter.name} is ${safeCharacter.adjectives.join(', ')}. 23 | ${safeCharacter.bio} 24 | Background: ${safeCharacter.lore.join('. ')} 25 | They are knowledgeable about: ${safeCharacter.topics.join(', ')}. 26 | Communication style: ${safeCharacter.style.all.join(', ')}. 27 | When chatting they: ${safeCharacter.style.chat.join(', ')}. 28 | When posting they: ${safeCharacter.style.post.join(', ')}.`; 29 | const html = ` 30 | 31 | 32 | 33 | ${safeCharacter.name} by ${safeCharacter.author} 34 | 39 | 53 | 54 | 55 |
56 |
57 |
58 | 59 | Logo 60 | 61 |
62 | 63 | ${safeCharacter.author} 66 | ${safeCharacter.author} 67 | 68 | presents 69 |

${safeCharacter.name}

70 | ${createHeaderSearchBar()} 71 |
72 |
73 |
74 |
75 | 76 |
77 |
78 | 86 | 87 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

About ${safeCharacter.name}

109 | 110 |
111 |
112 |

Background

113 |
    114 | ${safeCharacter.lore.map(item => 115 | `
  • • ${item}
  • ` 116 | ).join('')} 117 |
118 |
119 | 120 |
121 |

Topics & Expertise

122 |
123 | ${safeCharacter.topics.map(topic => 124 | ` 125 | ${topic} 126 | ` 127 | ).join('')} 128 |
129 |
130 |
131 | 132 |
133 |

Personality Traits

134 |
135 | ${safeCharacter.adjectives.map(adj => 136 | ` 137 | ${adj} 138 | ` 139 | ).join('')} 140 |
141 |
142 |
143 |
144 | 145 |
146 |
147 |

© ${new Date().getFullYear()} World Publisher

148 |
149 | Terms 150 | Privacy 151 | GitHub 152 |
153 |
154 |
155 | 156 | 157 | 174 | 175 | 176 | `; 177 | 178 | return secureHtmlService.transformHTML(html); 179 | } -------------------------------------------------------------------------------- /src/client-telegram/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !dist/** 4 | !package.json 5 | !readme.md 6 | !tsup.config.ts -------------------------------------------------------------------------------- /src/client-telegram/.turbo/turbo-build.log: -------------------------------------------------------------------------------- 1 | 2 |  3 | > @elizaos/client-telegram@0.1.7 build /Users/anthonyburchell/scratch/eliza/eliza/packages/client-telegram 4 | > tsup --format esm --dts 5 | 6 | CLI Building entry: src/index.ts 7 | CLI Using tsconfig: tsconfig.json 8 | CLI tsup v8.3.5 9 | CLI Using tsup config: /Users/anthonyburchell/scratch/eliza/eliza/packages/client-telegram/tsup.config.ts 10 | CLI Target: esnext 11 | CLI Cleaning output folder 12 | ESM Build start 13 | ESM dist/index.js 37.40 KB 14 | ESM dist/index.js.map 77.41 KB 15 | ESM ⚡️ Build success in 14ms 16 | DTS Build start 17 | DTS ⚡️ Build success in 4082ms 18 | DTS dist/index.d.ts 161.00 B 19 | -------------------------------------------------------------------------------- /src/client-telegram/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslintGlobalConfig from "../../eslint.config.mjs"; 2 | 3 | export default [...eslintGlobalConfig]; 4 | -------------------------------------------------------------------------------- /src/client-telegram/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elizaos/client-telegram", 3 | "version": "0.1.7", 4 | "type": "module", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": { 11 | "import": { 12 | "@elizaos/source": "./src/index.ts", 13 | "types": "./dist/index.d.ts", 14 | "default": "./dist/index.js" 15 | } 16 | } 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "dependencies": { 22 | "../../eliza-core": "workspace:*", 23 | "@telegraf/types": "7.1.0", 24 | "telegraf": "4.16.3", 25 | "zod": "3.23.8" 26 | }, 27 | "devDependencies": { 28 | "tsup": "8.3.5" 29 | }, 30 | "scripts": { 31 | "build": "tsup --format esm --dts", 32 | "dev": "tsup --format esm --dts --watch", 33 | "lint": "eslint --fix --cache ." 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client-telegram/src/config/default.json5: -------------------------------------------------------------------------------- 1 | { 2 | bot: { 3 | testEnv: false, 4 | }, 5 | server: { 6 | https: false, 7 | port: 3000, 8 | static: false, 9 | }, 10 | gameServer: { 11 | validateInitData: true, 12 | inactivityTimeout: 300, 13 | disconnectTimeout: 180, 14 | fakeRoom: { 15 | create: false, 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/client-telegram/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGE_CONSTANTS = { 2 | MAX_MESSAGES: 50, 3 | RECENT_MESSAGE_COUNT: 5, 4 | CHAT_HISTORY_COUNT: 10, 5 | DEFAULT_SIMILARITY_THRESHOLD: 0.6, 6 | DEFAULT_SIMILARITY_THRESHOLD_FOLLOW_UPS: 0.4, 7 | INTEREST_DECAY_TIME: 5 * 60 * 1000, // 5 minutes 8 | PARTIAL_INTEREST_DECAY: 3 * 60 * 1000, // 3 minutes 9 | } as const; 10 | 11 | export const TIMING_CONSTANTS = { 12 | TEAM_MEMBER_DELAY: 1500, // 1.5 seconds 13 | TEAM_MEMBER_DELAY_MIN: 1000, // 1 second 14 | TEAM_MEMBER_DELAY_MAX: 3000, // 3 seconds 15 | LEADER_DELAY_MIN: 2000, // 2 seconds 16 | LEADER_DELAY_MAX: 4000 // 4 seconds 17 | } as const; 18 | 19 | export const RESPONSE_CHANCES = { 20 | AFTER_LEADER: 0.5, // 50% chance to respond after leader 21 | } as const; 22 | 23 | export const TEAM_COORDINATION = { 24 | KEYWORDS: [ 25 | 'team', 26 | 'everyone', 27 | 'all agents', 28 | 'team update', 29 | 'gm team', 30 | 'hello team', 31 | 'hey team', 32 | 'hi team', 33 | 'morning team', 34 | 'evening team', 35 | 'night team', 36 | 'update team', 37 | ] 38 | } as const; -------------------------------------------------------------------------------- /src/client-telegram/src/environment.ts: -------------------------------------------------------------------------------- 1 | import { IAgentRuntime } from "../../eliza-core"; 2 | import { z } from "zod"; 3 | 4 | export const telegramEnvSchema = z.object({ 5 | TELEGRAM_BOT_TOKEN: z.string().min(1, "Telegram bot token is required"), 6 | }); 7 | 8 | export type TelegramConfig = z.infer; 9 | 10 | export async function validateTelegramConfig( 11 | runtime: IAgentRuntime 12 | ): Promise { 13 | try { 14 | const config = { 15 | TELEGRAM_BOT_TOKEN: 16 | runtime.getSetting("TELEGRAM_BOT_TOKEN") || 17 | process.env.TELEGRAM_BOT_TOKEN, 18 | }; 19 | 20 | return telegramEnvSchema.parse(config); 21 | } catch (error) { 22 | if (error instanceof z.ZodError) { 23 | const errorMessages = error.errors 24 | .map((err) => `${err.path.join(".")}: ${err.message}`) 25 | .join("\n"); 26 | throw new Error( 27 | `Telegram configuration validation failed:\n${errorMessages}` 28 | ); 29 | } 30 | throw error; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/client-telegram/src/getOrCreateRecommenderInBe.ts: -------------------------------------------------------------------------------- 1 | export async function getOrCreateRecommenderInBe( 2 | recommenderId: string, 3 | username: string, 4 | backendToken: string, 5 | backend: string, 6 | retries = 3, 7 | delayMs = 2000 8 | ) { 9 | for (let attempt = 1; attempt <= retries; attempt++) { 10 | try { 11 | const response = await fetch( 12 | `${backend}/api/updaters/getOrCreateRecommender`, 13 | { 14 | method: "POST", 15 | headers: { 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${backendToken}`, 18 | }, 19 | body: JSON.stringify({ 20 | recommenderId: recommenderId, 21 | username: username, 22 | }), 23 | } 24 | ); 25 | const data = await response.json(); 26 | return data; 27 | } catch (error) { 28 | console.error( 29 | `Attempt ${attempt} failed: Error getting or creating recommender in backend`, 30 | error 31 | ); 32 | if (attempt < retries) { 33 | console.log(`Retrying in ${delayMs} ms...`); 34 | await new Promise((resolve) => setTimeout(resolve, delayMs)); 35 | } else { 36 | console.error("All attempts failed."); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/client-telegram/src/index.ts: -------------------------------------------------------------------------------- 1 | import { elizaLogger } from "../../eliza-core"; 2 | import { Client, IAgentRuntime } from "../../eliza-core"; 3 | import { TelegramClient } from "./telegramClient"; 4 | import { validateTelegramConfig } from "./environment"; 5 | 6 | export const TelegramClientInterface: Client = { 7 | start: async (runtime: IAgentRuntime) => { 8 | await validateTelegramConfig(runtime); 9 | 10 | const tg = new TelegramClient( 11 | runtime, 12 | runtime.getSetting("TELEGRAM_BOT_TOKEN") 13 | ); 14 | 15 | await tg.start(); 16 | 17 | elizaLogger.success( 18 | `✅ Telegram client successfully started for character ${runtime.character.name}` 19 | ); 20 | return tg; 21 | }, 22 | stop: async (_runtime: IAgentRuntime) => { 23 | elizaLogger.warn("Telegram client does not support stopping yet"); 24 | }, 25 | }; 26 | 27 | export default TelegramClientInterface; 28 | -------------------------------------------------------------------------------- /src/client-telegram/src/telegramClient.ts: -------------------------------------------------------------------------------- 1 | import { Context, Telegraf } from "telegraf"; 2 | import { message } from "telegraf/filters"; 3 | import { IAgentRuntime, elizaLogger } from "../../eliza-core"; 4 | import { MessageManager } from "./messageManager.ts"; 5 | import { getOrCreateRecommenderInBe } from "./getOrCreateRecommenderInBe.ts"; 6 | 7 | export class TelegramClient { 8 | private bot: Telegraf; 9 | private runtime: IAgentRuntime; 10 | private messageManager: MessageManager; 11 | private backend; 12 | private backendToken; 13 | private tgTrader; 14 | 15 | constructor(runtime: IAgentRuntime, botToken: string) { 16 | elizaLogger.log("📱 Constructing new TelegramClient..."); 17 | this.runtime = runtime; 18 | this.bot = new Telegraf(botToken); 19 | this.messageManager = new MessageManager(this.bot, this.runtime); 20 | this.backend = runtime.getSetting("BACKEND_URL"); 21 | this.backendToken = runtime.getSetting("BACKEND_TOKEN"); 22 | this.tgTrader = runtime.getSetting("TG_TRADER"); // boolean To Be added to the settings 23 | elizaLogger.log("✅ TelegramClient constructor completed"); 24 | } 25 | 26 | public async start(): Promise { 27 | elizaLogger.log("🚀 Starting Telegram bot..."); 28 | try { 29 | await this.initializeBot(); 30 | this.setupMessageHandlers(); 31 | this.setupShutdownHandlers(); 32 | } catch (error) { 33 | elizaLogger.error("❌ Failed to launch Telegram bot:", error); 34 | throw error; 35 | } 36 | } 37 | 38 | private async initializeBot(): Promise { 39 | this.bot.launch({ dropPendingUpdates: true }); 40 | elizaLogger.log( 41 | "✨ Telegram bot successfully launched and is running!" 42 | ); 43 | 44 | const botInfo = await this.bot.telegram.getMe(); 45 | this.bot.botInfo = botInfo; 46 | elizaLogger.success(`Bot username: @${botInfo.username}`); 47 | 48 | this.messageManager.bot = this.bot; 49 | } 50 | 51 | private async isGroupAuthorized(ctx: Context): Promise { 52 | const config = this.runtime.character.clientConfig?.telegram; 53 | if (ctx.from?.id === ctx.botInfo?.id) { 54 | return false; 55 | } 56 | 57 | if (!config?.shouldOnlyJoinInAllowedGroups) { 58 | return true; 59 | } 60 | 61 | const allowedGroups = config.allowedGroupIds || []; 62 | const currentGroupId = ctx.chat.id.toString(); 63 | 64 | if (!allowedGroups.includes(currentGroupId)) { 65 | elizaLogger.info(`Unauthorized group detected: ${currentGroupId}`); 66 | try { 67 | await ctx.reply("Not authorized. Leaving."); 68 | await ctx.leaveChat(); 69 | } catch (error) { 70 | elizaLogger.error( 71 | `Error leaving unauthorized group ${currentGroupId}:`, 72 | error 73 | ); 74 | } 75 | return false; 76 | } 77 | 78 | return true; 79 | } 80 | 81 | private setupMessageHandlers(): void { 82 | elizaLogger.log("Setting up message handler..."); 83 | 84 | this.bot.on(message("new_chat_members"), async (ctx) => { 85 | try { 86 | const newMembers = ctx.message.new_chat_members; 87 | const isBotAdded = newMembers.some( 88 | (member) => member.id === ctx.botInfo.id 89 | ); 90 | 91 | if (isBotAdded && !(await this.isGroupAuthorized(ctx))) { 92 | return; 93 | } 94 | } catch (error) { 95 | elizaLogger.error("Error handling new chat members:", error); 96 | } 97 | }); 98 | 99 | this.bot.on("message", async (ctx) => { 100 | try { 101 | // Check group authorization first 102 | if (!(await this.isGroupAuthorized(ctx))) { 103 | return; 104 | } 105 | 106 | if (this.tgTrader) { 107 | const userId = ctx.from?.id.toString(); 108 | const username = 109 | ctx.from?.username || ctx.from?.first_name || "Unknown"; 110 | if (!userId) { 111 | elizaLogger.warn( 112 | "Received message from a user without an ID." 113 | ); 114 | return; 115 | } 116 | try { 117 | await getOrCreateRecommenderInBe( 118 | userId, 119 | username, 120 | this.backendToken, 121 | this.backend 122 | ); 123 | } catch (error) { 124 | elizaLogger.error( 125 | "Error getting or creating recommender in backend", 126 | error 127 | ); 128 | } 129 | } 130 | 131 | await this.messageManager.handleMessage(ctx); 132 | } catch (error) { 133 | elizaLogger.error("❌ Error handling message:", error); 134 | // Don't try to reply if we've left the group or been kicked 135 | if (error?.response?.error_code !== 403) { 136 | try { 137 | await ctx.reply( 138 | "An error occurred while processing your message." 139 | ); 140 | } catch (replyError) { 141 | elizaLogger.error( 142 | "Failed to send error message:", 143 | replyError 144 | ); 145 | } 146 | } 147 | } 148 | }); 149 | 150 | this.bot.on("photo", (ctx) => { 151 | elizaLogger.log( 152 | "📸 Received photo message with caption:", 153 | ctx.message.caption 154 | ); 155 | }); 156 | 157 | this.bot.on("document", (ctx) => { 158 | elizaLogger.log( 159 | "📎 Received document message:", 160 | ctx.message.document.file_name 161 | ); 162 | }); 163 | 164 | this.bot.catch((err, ctx) => { 165 | elizaLogger.error(`❌ Telegram Error for ${ctx.updateType}:`, err); 166 | ctx.reply("An unexpected error occurred. Please try again later."); 167 | }); 168 | } 169 | 170 | private setupShutdownHandlers(): void { 171 | const shutdownHandler = async (signal: string) => { 172 | elizaLogger.log( 173 | `⚠️ Received ${signal}. Shutting down Telegram bot gracefully...` 174 | ); 175 | try { 176 | await this.stop(); 177 | elizaLogger.log("🛑 Telegram bot stopped gracefully"); 178 | } catch (error) { 179 | elizaLogger.error( 180 | "❌ Error during Telegram bot shutdown:", 181 | error 182 | ); 183 | throw error; 184 | } 185 | }; 186 | 187 | process.once("SIGINT", () => shutdownHandler("SIGINT")); 188 | process.once("SIGTERM", () => shutdownHandler("SIGTERM")); 189 | process.once("SIGHUP", () => shutdownHandler("SIGHUP")); 190 | } 191 | 192 | public async stop(): Promise { 193 | elizaLogger.log("Stopping Telegram bot..."); 194 | await this.bot.stop(); 195 | elizaLogger.log("Telegram bot stopped"); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/client-telegram/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function cosineSimilarity(text1: string, text2: string, text3?: string): number { 2 | const preprocessText = (text: string) => text 3 | .toLowerCase() 4 | .replace(/[^\w\s'_-]/g, ' ') 5 | .replace(/\s+/g, ' ') 6 | .trim(); 7 | 8 | const getWords = (text: string) => { 9 | return text.split(' ').filter(word => word.length > 1); 10 | }; 11 | 12 | const words1 = getWords(preprocessText(text1)); 13 | const words2 = getWords(preprocessText(text2)); 14 | const words3 = text3 ? getWords(preprocessText(text3)) : []; 15 | 16 | const freq1: { [key: string]: number } = {}; 17 | const freq2: { [key: string]: number } = {}; 18 | const freq3: { [key: string]: number } = {}; 19 | 20 | words1.forEach(word => freq1[word] = (freq1[word] || 0) + 1); 21 | words2.forEach(word => freq2[word] = (freq2[word] || 0) + 1); 22 | if (words3.length) { 23 | words3.forEach(word => freq3[word] = (freq3[word] || 0) + 1); 24 | } 25 | 26 | const uniqueWords = new Set([...Object.keys(freq1), ...Object.keys(freq2), ...(words3.length ? Object.keys(freq3) : [])]); 27 | 28 | let dotProduct = 0; 29 | let magnitude1 = 0; 30 | let magnitude2 = 0; 31 | let magnitude3 = 0; 32 | 33 | uniqueWords.forEach(word => { 34 | const val1 = freq1[word] || 0; 35 | const val2 = freq2[word] || 0; 36 | const val3 = freq3[word] || 0; 37 | 38 | if (words3.length) { 39 | // For three-way, calculate pairwise similarities 40 | const sim12 = val1 * val2; 41 | const sim23 = val2 * val3; 42 | const sim13 = val1 * val3; 43 | 44 | // Take maximum similarity between any pair 45 | dotProduct += Math.max(sim12, sim23, sim13); 46 | } else { 47 | dotProduct += val1 * val2; 48 | } 49 | 50 | magnitude1 += val1 * val1; 51 | magnitude2 += val2 * val2; 52 | if (words3.length) { 53 | magnitude3 += val3 * val3; 54 | } 55 | }); 56 | 57 | magnitude1 = Math.sqrt(magnitude1); 58 | magnitude2 = Math.sqrt(magnitude2); 59 | magnitude3 = words3.length ? Math.sqrt(magnitude3) : 1; 60 | 61 | if (magnitude1 === 0 || magnitude2 === 0 || (words3.length && magnitude3 === 0)) return 0; 62 | 63 | // For two texts, use original calculation 64 | if (!words3.length) { 65 | return dotProduct / (magnitude1 * magnitude2); 66 | } 67 | 68 | // For three texts, use max magnitude pair to maintain scale 69 | const maxMagnitude = Math.max( 70 | magnitude1 * magnitude2, 71 | magnitude2 * magnitude3, 72 | magnitude1 * magnitude3 73 | ); 74 | 75 | return dotProduct / maxMagnitude; 76 | } 77 | 78 | export function escapeMarkdown(text: string): string { 79 | // Don't escape if it's a code block 80 | if (text.startsWith('```') && text.endsWith('```')) { 81 | return text; 82 | } 83 | 84 | // Split the text by code blocks 85 | const parts = text.split(/(```[\s\S]*?```)/g); 86 | 87 | return parts.map((part, index) => { 88 | // If it's a code block (odd indices in the split result will be code blocks) 89 | if (index % 2 === 1) { 90 | return part; 91 | } 92 | // For regular text, only escape characters that need escaping in Markdown 93 | return part 94 | // First preserve any intended inline code spans 95 | .replace(/`.*?`/g, match => match) 96 | // Then only escape the minimal set of special characters that need escaping in Markdown mode 97 | .replace(/([*_`\\])/g, '\\$1'); 98 | }).join(''); 99 | } 100 | 101 | /** 102 | * Splits a message into chunks that fit within Telegram's message length limit 103 | */ 104 | export function splitMessage(text: string, maxLength: number = 4096): string[] { 105 | const chunks: string[] = []; 106 | let currentChunk = ""; 107 | 108 | const lines = text.split("\n"); 109 | for (const line of lines) { 110 | if (currentChunk.length + line.length + 1 <= maxLength) { 111 | currentChunk += (currentChunk ? "\n" : "") + line; 112 | } else { 113 | if (currentChunk) chunks.push(currentChunk); 114 | currentChunk = line; 115 | } 116 | } 117 | 118 | if (currentChunk) chunks.push(currentChunk); 119 | return chunks; 120 | } -------------------------------------------------------------------------------- /src/client-telegram/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../core/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": [ 8 | "src/**/*.ts" 9 | ] 10 | } -------------------------------------------------------------------------------- /src/client-telegram/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | outDir: "dist", 6 | sourcemap: true, 7 | clean: true, 8 | format: ["esm"], // Ensure you're targeting CommonJS 9 | external: [ 10 | "dotenv", // Externalize dotenv to prevent bundling 11 | "fs", // Externalize fs to use Node.js built-in module 12 | "path", // Externalize other built-ins if necessary 13 | "@reflink/reflink", 14 | "@node-llama-cpp", 15 | "https", 16 | "http", 17 | "agentkeepalive", 18 | // Add other modules you want to externalize 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /src/discordBotDO.js: -------------------------------------------------------------------------------- 1 | import { verifyKey } from 'discord-interactions'; 2 | import { DurableObject } from "cloudflare:workers"; 3 | 4 | function corsHeaders() { 5 | return { 6 | 'Access-Control-Allow-Origin': '*', 7 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 8 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Discord-Application-Id, cf-discord-token', 9 | }; 10 | } 11 | 12 | export class DiscordBotDO extends DurableObject { 13 | constructor(state, env) { 14 | super(state, env); 15 | this.state = state; // Store the state object 16 | this.env = env; 17 | this.clients = new Map(); 18 | this.publicKey = null; 19 | this.token = null; 20 | this.applicationId = null; 21 | } 22 | async initializeState() { 23 | // Add debug logging 24 | 25 | const keys = await this.state.storage.list(); 26 | 27 | if (!this.publicKey) { 28 | this.publicKey = await this.state.storage.get('publicKey'); 29 | } 30 | if (!this.token) { 31 | this.token = await this.state.storage.get('token'); 32 | } 33 | if (!this.applicationId) { 34 | this.applicationId = await this.state.storage.get('applicationId'); 35 | } 36 | } 37 | 38 | async fetch(request) { 39 | await this.initializeState(); // This should be enough to get publicKey 40 | 41 | const url = new URL(request.url); 42 | const path = url.pathname; 43 | 44 | 45 | // Handle WebSocket connections 46 | const upgradeHeader = request.headers.get("Upgrade"); 47 | if (upgradeHeader === "websocket") { 48 | return await this.handleWebSocket(request.clone()); 49 | } 50 | 51 | try { 52 | if (path === '/check') { 53 | return await this.handleCheck(); 54 | } 55 | 56 | if (path === '/init') { 57 | return await this.handleInit(request.clone()); 58 | } 59 | 60 | if (path === '/interactions') { 61 | return await this.handleInteractions(request.clone()); 62 | } 63 | 64 | return this.handleHttpRequest(request.clone()); 65 | } catch (err) { 66 | console.error('DO fetch error:', err); 67 | return new Response(err.message, { status: 500 }); 68 | } 69 | } 70 | 71 | async handleCheck() { 72 | // Make sure we're initialized 73 | await this.initializeState(); 74 | 75 | try { 76 | if (!this.token) { 77 | return new Response(JSON.stringify({ 78 | success: false, 79 | message: 'No Discord token configured' 80 | }), { 81 | status: 200, 82 | headers: { 83 | 'Access-Control-Allow-Origin': '*', 84 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 85 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 86 | 'Content-Type': 'application/json' 87 | } 88 | }); 89 | } 90 | 91 | const response = await fetch('https://discord.com/api/v10/applications/@me', { 92 | headers: { 93 | 'Authorization': `Bot ${this.token}`, 94 | 'Content-Type': 'application/json' 95 | } 96 | }); 97 | 98 | return new Response(JSON.stringify({ 99 | success: response.ok, 100 | message: response.ok ? 'Discord configuration valid' : 'Discord configuration invalid' 101 | }), { 102 | status: 200, 103 | headers: { 104 | 'Access-Control-Allow-Origin': '*', 105 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 106 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 107 | 'Content-Type': 'application/json' 108 | } 109 | }); 110 | } catch (err) { 111 | console.error('Discord check error:', err); 112 | return new Response(JSON.stringify({ 113 | success: false, 114 | message: 'Failed to validate Discord configuration', 115 | error: err.message 116 | }), { 117 | status: 200, 118 | headers: { 119 | 'Access-Control-Allow-Origin': '*', 120 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 121 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 122 | 'Content-Type': 'application/json' 123 | } 124 | }); 125 | } 126 | } 127 | 128 | async handleInit(request) { 129 | try { 130 | const data = await request.json(); 131 | 132 | // Store in Durable Object storage 133 | await Promise.all([ 134 | this.state.storage.put('publicKey', data.publicKey), 135 | this.state.storage.put('token', data.token), 136 | this.state.storage.put('applicationId', data.applicationId) 137 | ]); 138 | 139 | // Update instance variables 140 | this.publicKey = data.publicKey; 141 | this.token = data.token; 142 | this.applicationId = data.applicationId; 143 | 144 | // Register slash command 145 | await this.createCommand(data.token, data.applicationId); 146 | 147 | return new Response(JSON.stringify({ 148 | success: true, 149 | message: `Please set your Interactions Endpoint URL to: https://discord-handler.sxp.digital/interactions` 150 | }), { 151 | status: 200, 152 | headers: { 153 | 'Access-Control-Allow-Origin': '*', 154 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 155 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 156 | 'Content-Type': 'application/json' 157 | } 158 | }); 159 | } catch (error) { 160 | console.error('Init error:', error); 161 | return new Response(JSON.stringify({ 162 | success: false, 163 | error: error.message 164 | }), { 165 | status: 500, 166 | headers: { 167 | 'Access-Control-Allow-Origin': '*', 168 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 169 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 170 | 'Content-Type': 'application/json' 171 | } 172 | }); 173 | } 174 | } 175 | 176 | async createCommand(token, applicationId) { 177 | try { 178 | const response = await fetch(`https://discord.com/api/v10/applications/${applicationId}/commands`, { 179 | method: 'POST', 180 | headers: { 181 | 'Authorization': `Bot ${token}`, 182 | 'Content-Type': 'application/json', 183 | }, 184 | body: JSON.stringify({ 185 | name: 'hey', 186 | description: 'Chat with the AI character', 187 | options: [{ 188 | name: 'message', 189 | description: 'Your message to the character', 190 | type: 3, // STRING type 191 | required: true 192 | }] 193 | }), 194 | }); 195 | 196 | if (!response.ok) { 197 | throw new Error(`Error creating command: ${response.statusText}`); 198 | } 199 | return response.json(); 200 | } catch (err) { 201 | console.error('Failed to register slash command:', err); 202 | } 203 | } 204 | 205 | // Then, modify the handleApplicationCommand method to handle the hey command 206 | async handleApplicationCommand(body) { 207 | if (body.data.name === 'hey') { 208 | try { 209 | const message = body.data.options[0].value; 210 | const channelId = body.channel_id; 211 | const userId = body.member.user.id; 212 | const userName = body.member.user.username; 213 | 214 | // Get the character registry DO 215 | const id = this.env.CHARACTER_REGISTRY.idFromName("global"); 216 | const registry = this.env.CHARACTER_REGISTRY.get(id); 217 | 218 | // Create a unique room ID for this Discord channel 219 | const roomId = `discord-${channelId}`; 220 | 221 | // Initialize or get existing session 222 | let session; 223 | try { 224 | // Try to find existing session for this channel 225 | const sessionResponse = await registry.fetch(new Request('http://internal/initialize-session', { 226 | method: 'POST', 227 | headers: { 'Content-Type': 'application/json' }, 228 | body: JSON.stringify({ 229 | author: 'antpb', 230 | slug: 'pixel', 231 | roomId: roomId 232 | }) 233 | })); 234 | 235 | if (!sessionResponse.ok) { 236 | throw new Error('Failed to initialize session'); 237 | } 238 | 239 | session = await sessionResponse.json(); 240 | } catch (error) { 241 | console.error('Session initialization error:', error); 242 | return new Response(JSON.stringify({ 243 | type: 4, 244 | data: { 245 | content: "I'm sorry, I couldn't initialize the chat session. Please try again later." 246 | } 247 | }), { 248 | headers: { 'Content-Type': 'application/json' } 249 | }); 250 | } 251 | 252 | // Send message to character 253 | const messageResponse = await registry.fetch(new Request('http://internal/send-chat-message', { 254 | method: 'POST', 255 | headers: { 'Content-Type': 'application/json' }, 256 | body: JSON.stringify({ 257 | sessionId: session.sessionId, 258 | message: message, 259 | nonce: session.nonce, 260 | userId: userId, 261 | userName: userName, 262 | roomId: roomId 263 | }) 264 | })); 265 | 266 | if (!messageResponse.ok) { 267 | throw new Error('Failed to process message'); 268 | } 269 | 270 | const response = await messageResponse.json(); 271 | 272 | // Reply with character's response 273 | return new Response(JSON.stringify({ 274 | type: 4, // CHANNEL_MESSAGE_WITH_SOURCE 275 | data: { 276 | content: response.text 277 | } 278 | }), { 279 | headers: { 'Content-Type': 'application/json' } 280 | }); 281 | 282 | } catch (error) { 283 | console.error('Error handling hey command:', error); 284 | return new Response(JSON.stringify({ 285 | type: 4, 286 | data: { 287 | content: "I apologize, but I encountered an error processing your message." 288 | } 289 | }), { 290 | headers: { 'Content-Type': 'application/json' } 291 | }); 292 | } 293 | } 294 | 295 | // Default response for other commands 296 | return new Response(JSON.stringify({ 297 | type: 4, 298 | data: { 299 | content: `Received command: ${body.data.name}` 300 | } 301 | }), { 302 | headers: { 'Content-Type': 'application/json' } 303 | }); 304 | } 305 | 306 | // Add this helper method to maintain session state 307 | async getOrCreateSession(channelId, registry) { 308 | const roomId = `discord-${channelId}`; 309 | const sessionKey = `session:${roomId}`; 310 | 311 | // Try to get existing session from storage 312 | let session = await this.state.storage.get(sessionKey); 313 | 314 | if (!session) { 315 | // Initialize new session 316 | const response = await registry.fetch(new Request('http://internal/initialize-session', { 317 | method: 'POST', 318 | headers: { 'Content-Type': 'application/json' }, 319 | body: JSON.stringify({ 320 | author: 'antpb', // Default author 321 | slug: 'eliza', // Default character 322 | roomId: roomId 323 | }) 324 | })); 325 | 326 | if (!response.ok) { 327 | throw new Error('Failed to initialize session'); 328 | } 329 | 330 | session = await response.json(); 331 | 332 | // Store session data 333 | await this.state.storage.put(sessionKey, session); 334 | } 335 | 336 | return session; 337 | } 338 | 339 | async handleInteractions(request) { 340 | const signature = request.headers.get('x-signature-ed25519'); 341 | const timestamp = request.headers.get('x-signature-timestamp'); 342 | const bodyText = await request.text(); 343 | 344 | if (!signature || !timestamp || !this.publicKey) { 345 | console.error('Missing verification parameters:', { signature, timestamp, hasPublicKey: !!this.publicKey }); 346 | return new Response('Invalid request signature', { status: 401 }); 347 | } 348 | 349 | try { 350 | 351 | // Add await here 352 | const isValidRequest = await verifyKey( 353 | bodyText, 354 | signature, 355 | timestamp, 356 | this.publicKey 357 | ); 358 | 359 | 360 | if (!isValidRequest) { 361 | console.error('Invalid signature verification'); 362 | return new Response('Invalid request signature', { status: 401 }); 363 | } 364 | 365 | const body = JSON.parse(bodyText); 366 | 367 | if (body.type === 1) { // PING 368 | return new Response(JSON.stringify({ type: 1 }), { 369 | headers: { 'Content-Type': 'application/json' } 370 | }); 371 | } else if (body.type === 2) { // APPLICATION_COMMAND 372 | return await this.handleApplicationCommand(body); 373 | } else if (body.type === 3) { // MESSAGE_COMPONENT 374 | return await this.handleMessageComponent(body); 375 | } else if (body.type === 4) { // APPLICATION_COMMAND_AUTOCOMPLETE 376 | return await this.handleAutocomplete(body); 377 | } else if (body.type === 5) { // MODAL_SUBMIT 378 | return await this.handleModalSubmit(body); 379 | } 380 | 381 | // If we don't recognize the type, return an error 382 | return new Response(JSON.stringify({ 383 | type: 4, 384 | data: { 385 | content: "Sorry, I don't know how to handle that type of interaction." 386 | } 387 | }), { 388 | headers: { 'Content-Type': 'application/json' } 389 | }); 390 | 391 | } catch (error) { 392 | console.error('Interaction handling error:', error); 393 | return new Response(JSON.stringify({ 394 | type: 4, 395 | data: { 396 | content: "Sorry, something went wrong while processing your request." 397 | } 398 | }), { 399 | headers: { 'Content-Type': 'application/json' } 400 | }); 401 | } 402 | } 403 | 404 | async handleWebSocket(request) { 405 | try { 406 | const protocol = request.headers.get('Sec-WebSocket-Protocol'); 407 | if (!protocol || !protocol.startsWith('cf-discord-token.')) { 408 | throw new Error('Invalid WebSocket protocol'); 409 | } 410 | 411 | const token = protocol.split('.')[1]; 412 | const pair = new WebSocketPair(); 413 | const [client, server] = Object.values(pair); 414 | const clientId = crypto.randomUUID(); 415 | 416 | const clientInfo = { 417 | ws: server, 418 | token: token, 419 | clientId: clientId, 420 | pollInterval: null 421 | }; 422 | 423 | server.addEventListener("message", async (msg) => { 424 | try { 425 | const data = JSON.parse(msg.data); 426 | if (data.type === "init") { 427 | clientInfo.channelId = data.channelId; 428 | this.setupMessagePolling(clientInfo); 429 | server.send(JSON.stringify({ 430 | type: "connected", 431 | clientId: clientId 432 | })); 433 | } 434 | } catch (err) { 435 | console.error('Message processing error:', err); 436 | } 437 | }); 438 | 439 | await this.state.acceptWebSocket(server); 440 | 441 | return new Response(null, { 442 | status: 101, 443 | webSocket: client, 444 | headers: { 445 | 'Upgrade': 'websocket', 446 | 'Connection': 'Upgrade', 447 | 'Sec-WebSocket-Protocol': protocol 448 | } 449 | }); 450 | } catch (err) { 451 | console.error('WebSocket setup error:', err); 452 | return new Response(err.stack, { status: 500 }); 453 | } 454 | } 455 | 456 | setupMessagePolling(clientInfo) { 457 | clientInfo.pollInterval = setInterval(async () => { 458 | try { 459 | const response = await fetch( 460 | `https://discord.com/api/v10/channels/${clientInfo.channelId}/messages`, 461 | { 462 | headers: { 463 | 'Authorization': `Bot ${clientInfo.token}`, 464 | 'Content-Type': 'application/json' 465 | } 466 | } 467 | ); 468 | if (!response.ok) { 469 | console.error('Discord API error:', await response.text()); 470 | return; 471 | } 472 | 473 | const messages = await response.json(); 474 | const relevantMessages = messages.filter(msg => 475 | !msg.author.bot && ( 476 | msg.mentions?.some(mention => mention.id === this.applicationId) || 477 | msg.content.includes(`<@${this.applicationId}>`) 478 | ) 479 | ); 480 | 481 | if (relevantMessages.length > 0) { 482 | clientInfo.ws.send(JSON.stringify({ 483 | type: "messages", 484 | messages: relevantMessages 485 | })); 486 | } 487 | } catch (err) { 488 | console.error('Polling error:', err); 489 | } 490 | }, 2000); 491 | } 492 | 493 | async handleHttpRequest(request) { 494 | const response = await fetch(`https://discord.com/api/v10${new URL(request.url).pathname}`, { 495 | method: request.method, 496 | headers: { 497 | ...request.headers, 498 | "Content-Type": "application/json" 499 | }, 500 | body: request.method !== "GET" ? await request.text() : undefined 501 | }); 502 | 503 | return new Response(await response.text(), { 504 | status: response.status, 505 | headers: { 506 | "Content-Type": "application/json", 507 | ...corsHeaders() 508 | } 509 | }); 510 | } 511 | 512 | // Helper methods for different interaction types 513 | // handleApplicationCommand(body) { 514 | // return new Response(JSON.stringify({ 515 | // type: 4, 516 | // data: { 517 | // content: `Received command: ${body.data.name}` 518 | // } 519 | // }), { 520 | // headers: { 'Content-Type': 'application/json' } 521 | // }); 522 | // } 523 | 524 | handleMessageComponent(body) { 525 | return new Response(JSON.stringify({ 526 | type: 4, 527 | data: { 528 | content: `Component interaction received: ${body.data.custom_id}` 529 | } 530 | }), { 531 | headers: { 'Content-Type': 'application/json' } 532 | }); 533 | } 534 | 535 | handleAutocomplete(body) { 536 | return new Response(JSON.stringify({ 537 | type: 8, 538 | data: { 539 | choices: [] 540 | } 541 | }), { 542 | headers: { 'Content-Type': 'application/json' } 543 | }); 544 | } 545 | 546 | handleModalSubmit(body) { 547 | return new Response(JSON.stringify({ 548 | type: 4, 549 | data: { 550 | content: `Modal submission received: ${body.data.custom_id}` 551 | } 552 | }), { 553 | headers: { 'Content-Type': 'application/json' } 554 | }); 555 | } 556 | 557 | async sendDiscordMessage(clientInfo, data) { 558 | const response = await fetch( 559 | `https://discord.com/api/v10/channels/${clientInfo.channelId}/messages`, 560 | { 561 | method: "POST", 562 | headers: { 563 | "Authorization": `Bot ${clientInfo.token}`, 564 | "Content-Type": "application/json", 565 | "X-Discord-Intents": "4096" 566 | }, 567 | body: JSON.stringify({ 568 | content: data.content, 569 | embeds: data.embed ? [data.embed] : [] 570 | }) 571 | } 572 | ); 573 | 574 | if (!response.ok) { 575 | throw new Error(`Discord API error: ${response.status}`); 576 | } 577 | 578 | const messageData = await response.json(); 579 | clientInfo.ws.send(JSON.stringify({ 580 | type: "message_sent", 581 | message: messageData 582 | })); 583 | } 584 | } 585 | 586 | -------------------------------------------------------------------------------- /src/headerSearchBar.js: -------------------------------------------------------------------------------- 1 | 2 | const mainLogo = 'https://xrpublisher.com/wp-content/uploads/2024/10/xrpublisher-logo-300x70.png'; 3 | const logoMarkup = `Logo`; 4 | 5 | export function createHeaderSearchBar(currentQuery = '', tags = [], request) { 6 | const safeQuery = currentQuery.replace(/[&<>"']/g, (match) => { 7 | const escape = { 8 | '&': '&', 9 | '<': '<', 10 | '>': '>', 11 | '"': '"', 12 | "'": ''' 13 | }; 14 | return escape[match]; 15 | }); 16 | 17 | const mainLogo = 'https://xrpublisher.com/wp-content/uploads/2024/10/xrpublisher-logo-300x70.png'; 18 | const logoMarkup = `Logo`; 19 | 20 | return ` 21 |
22 |
23 | ${logoMarkup} 24 |
25 | 32 | 40 |
41 |
42 |
43 | `; 44 | } -------------------------------------------------------------------------------- /src/homeTemplate.js: -------------------------------------------------------------------------------- 1 | import { createHeaderSearchBar } from './headerSearchBar'; 2 | 3 | export default async function generateHomeHTML(authors, env) { 4 | const mainLogo = 'https://xrpublisher.com/wp-content/uploads/2024/10/xrpublisher-logo-300x70.png'; 5 | 6 | // Ensure authors is an array and has data 7 | const validAuthors = Array.isArray(authors) ? authors : []; 8 | console.log('Processing authors for homepage:', { 9 | authorCount: validAuthors.length, 10 | sampleAuthor: validAuthors[0] ? { 11 | username: validAuthors[0].username, 12 | worldCount: validAuthors[0].world_count, 13 | totalVisits: validAuthors[0].total_visits 14 | } : null 15 | }); 16 | 17 | // Sort authors by activity (world count and visits) 18 | const sortedAuthors = [...validAuthors].sort((a, b) => { 19 | const visitsA = Number(a.total_visits) || 0; 20 | const visitsB = Number(b.total_visits) || 0; 21 | const worldsA = Number(a.world_count) || 0; 22 | const worldsB = Number(b.world_count) || 0; 23 | 24 | // First sort by world count, then by visits 25 | if (worldsA !== worldsB) return worldsB - worldsA; 26 | return visitsB - visitsA; 27 | }); 28 | 29 | const html = ` 30 | 31 | 32 | 33 | 34 | World Publisher - Discover Amazing Virtual Worlds 35 | 40 | 46 | 47 | 48 |
49 | ${createHeaderSearchBar()} 50 | 51 | 52 |
53 |
54 | 55 | Logo 56 | 57 |

Explore virtual worlds created by talented builders across the metaverse.

58 |
59 |
60 | 66 | 72 |
73 |
74 |
75 |
76 | 77 | 78 |
79 |
80 |

Featured Creators

81 | ${sortedAuthors.length > 0 ? ` 82 |
83 | ${sortedAuthors.map(author => ` 84 |
85 |
86 | ${author.username} 91 |
92 |

${author.username}

93 |

Creator since ${new Date(author.created_at || Date.now()).toLocaleDateString()}

94 |
95 |
96 |

${author.bio || ''}

97 |
98 | ${author.github ? ` 99 | 100 | GitHub 101 | 102 | ` : ''} 103 | ${author.twitter ? ` 104 | 105 | Twitter 106 | 107 | ` : ''} 108 | ${author.website ? ` 109 | 110 | Website 111 | 112 | ` : ''} 113 |
114 |
115 | ${author.world_count || 0} worlds created 116 | ${author.total_visits || 0} total visits 117 |
118 | 119 | View Worlds 120 | 121 |
122 | `).join('')} 123 |
124 | ` : ` 125 |
126 |

No creators found. Be the first to create a world!

127 | 128 | Get Started 129 | 130 |
131 | `} 132 |
133 |
134 | 135 | 136 |
137 |
138 |

© ${new Date().getFullYear()} World Publisher

139 |

140 | Terms | 141 | Privacy | 142 | 143 | Source 144 | 145 |

146 |
147 |
148 |
149 | 150 | 151 | `; 152 | 153 | return new Response(html, { 154 | headers: { 155 | 'Content-Type': 'text/html', 156 | 'Cache-Control': 'public, max-age=3600' 157 | }, 158 | }); 159 | } -------------------------------------------------------------------------------- /src/management.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes an author and all their associated worlds and data 3 | * @param {string} authorName - The name of the author to remove 4 | * @param {Object} env - Environment containing storage connections 5 | * @returns {Promise<{ success: boolean, message: string }>} 6 | */ 7 | export async function removeAuthor(authorName, env) { 8 | if (!authorName) { 9 | return { 10 | success: false, 11 | message: 'Invalid author name provided' 12 | }; 13 | } 14 | 15 | try { 16 | // Get DO instance 17 | const id = env.WORLD_REGISTRY.idFromName("global"); 18 | const registry = env.WORLD_REGISTRY.get(id); 19 | 20 | // Create internal request to delete author 21 | const deleteRequest = new Request('http://internal/delete-author', { 22 | method: 'POST', 23 | body: JSON.stringify({ authorName }) 24 | }); 25 | 26 | const response = await registry.fetch(deleteRequest); 27 | if (!response.ok) { 28 | throw new Error(`Failed to delete author: ${response.status}`); 29 | } 30 | 31 | // Delete from bucket 32 | const prefix = `${authorName}/`; 33 | const files = await env.WORLD_BUCKET.list({ prefix }); 34 | for (const file of files.objects) { 35 | await env.WORLD_BUCKET.delete(file.key); 36 | } 37 | 38 | // Delete visit tracking data if you have any 39 | const visitsPrefix = `visits:${authorName}:`; 40 | const visits = await env.VISIT_COUNTS.list({ prefix: visitsPrefix }); 41 | for (const key of visits.keys) { 42 | await env.VISIT_COUNTS.delete(key.name); 43 | } 44 | 45 | return { 46 | success: true, 47 | message: `Successfully removed author ${authorName} and all associated worlds` 48 | }; 49 | 50 | } catch (error) { 51 | console.error('Error removing author:', error); 52 | return { 53 | success: false, 54 | message: 'Failed to remove author. Please try again later.' 55 | }; 56 | } 57 | } 58 | 59 | /** 60 | * Removes a specific world 61 | * @param {string} authorName - The world's author name 62 | * @param {string} worldName - The name of the world to remove 63 | * @param {Object} env - Environment containing storage connections 64 | * @returns {Promise<{ success: boolean, message: string }>} 65 | */ 66 | export async function removeWorld(authorName, worldName, env) { 67 | if (!authorName || !worldName) { 68 | return { 69 | success: false, 70 | message: 'Invalid author or world name provided' 71 | }; 72 | } 73 | 74 | try { 75 | // Get DO instance 76 | const id = env.WORLD_REGISTRY.idFromName("global"); 77 | const registry = env.WORLD_REGISTRY.get(id); 78 | 79 | // Create internal request to delete world 80 | const deleteRequest = new Request('http://internal/delete-world', { 81 | method: 'POST', 82 | body: JSON.stringify({ authorName, worldName }) 83 | }); 84 | 85 | const response = await registry.fetch(deleteRequest); 86 | if (!response.ok) { 87 | throw new Error(`Failed to delete world: ${response.status}`); 88 | } 89 | 90 | // Get world slug from response 91 | const { slug } = await response.json(); 92 | 93 | // Delete all world files (HTML, previews, versions) 94 | const prefix = `${authorName}/${slug}/`; 95 | const files = await env.WORLD_BUCKET.list({ prefix }); 96 | for (const file of files.objects) { 97 | await env.WORLD_BUCKET.delete(file.key); 98 | } 99 | 100 | // Delete version history 101 | const versionsPrefix = `versions:${authorName}:${slug}/`; 102 | const versions = await env.WORLD_BUCKET.list({ prefix: versionsPrefix }); 103 | for (const version of versions.objects) { 104 | await env.WORLD_BUCKET.delete(version.key); 105 | } 106 | 107 | // Delete visit statistics 108 | const visitKey = `visits:${authorName}:${slug}`; 109 | await env.VISIT_COUNTS?.delete(visitKey); 110 | 111 | // Delete active users tracking 112 | const activeUsersKey = `active:${authorName}:${slug}`; 113 | await env.ACTIVE_USERS?.delete(activeUsersKey); 114 | 115 | return { 116 | success: true, 117 | message: `Successfully removed world "${worldName}"` 118 | }; 119 | 120 | } catch (error) { 121 | console.error('Error removing world:', error); 122 | return { 123 | success: false, 124 | message: error.message === 'World not found' ? 125 | 'World not found' : 126 | 'Failed to remove world. Please try again later.' 127 | }; 128 | } 129 | } 130 | 131 | /** 132 | * Archives a world instead of deleting it 133 | * @param {string} authorName - The world's author name 134 | * @param {string} worldName - The name of the world to archive 135 | * @param {Object} env - Environment containing storage connections 136 | * @returns {Promise<{ success: boolean, message: string }>} 137 | */ 138 | export async function archiveWorld(authorName, worldName, env) { 139 | if (!authorName || !worldName) { 140 | return { 141 | success: false, 142 | message: 'Invalid author or world name provided' 143 | }; 144 | } 145 | 146 | try { 147 | // Get DO instance 148 | const id = env.WORLD_REGISTRY.idFromName("global"); 149 | const registry = env.WORLD_REGISTRY.get(id); 150 | 151 | // Create internal request to archive world 152 | const archiveRequest = new Request('http://internal/archive-world', { 153 | method: 'POST', 154 | body: JSON.stringify({ authorName, worldName }) 155 | }); 156 | 157 | const response = await registry.fetch(archiveRequest); 158 | if (!response.ok) { 159 | throw new Error(`Failed to archive world: ${response.status}`); 160 | } 161 | 162 | return { 163 | success: true, 164 | message: `Successfully archived world "${worldName}"` 165 | }; 166 | 167 | } catch (error) { 168 | console.error('Error archiving world:', error); 169 | return { 170 | success: false, 171 | message: 'Failed to archive world. Please try again later.' 172 | }; 173 | } 174 | } -------------------------------------------------------------------------------- /src/registrationTemplate.js: -------------------------------------------------------------------------------- 1 | export default async function generateRegisterHTML() { 2 | const mainLogo = 'https://assets.pluginpublisher.com/main-logo.png'; 3 | 4 | const html = ` 5 | 6 | 7 | 8 | Register as Author - Plugin Publisher 9 | 10 | 15 | 47 | 114 | 115 | 116 |
117 |
118 |
119 | 120 |
121 | 122 | Logo 123 | 124 |
125 | 126 | 127 |
128 |

Register as an Author

129 | 130 |
131 |
132 | 133 | 141 |
142 | 143 |
144 | 145 | 153 |
154 | 155 |
156 | 157 | 165 |
166 | 167 |
168 | 169 | 177 |
178 | 179 |
180 |
181 | 182 | 188 |
189 |
190 | 191 | 192 |
193 |
194 | 195 | 196 | 197 |
198 |

Welcome, !

199 |
200 |

Your credentials have been downloaded successfully! Here's what to do next:

201 |
    202 |
  1. Install Local WP if you haven't already
  2. 203 |
  3. Create a new Local WP site
  4. 204 |
  5. Place the downloaded plugin-publisher-config.txt file in the root of your site
  6. 205 |
  7. Scaffold your first plugin using our CLI tool
  8. 206 |
207 | 212 |
213 |
214 | 215 | 216 |
217 | Back to Home 218 |
219 |
220 |
221 |
222 | 223 | 224 | `; 225 | 226 | return new Response(html, { 227 | headers: { 228 | 'Content-Type': 'text/html', 229 | }, 230 | }); 231 | } -------------------------------------------------------------------------------- /src/rollKeyTemplate.js: -------------------------------------------------------------------------------- 1 | // First step HTML - User enters username and current API key 2 | export default async function generateRollKeyHTML() { 3 | const mainLogo = 'https://assets.pluginpublisher.com/main-logo.png'; 4 | 5 | const html = ` 6 | 7 | 8 | 9 | Roll API Key - Plugin Publisher 10 | 11 | 16 | 23 | 132 | 133 | 134 |
135 |
136 |
137 |
138 | 139 | Logo 140 | 141 |
142 | 143 |
144 |

Roll API Key

145 |
146 |

Note: You will need to have your GitHub username set in your account settings to use this feature. Contact an administrator if you need to update your GitHub username.

147 |
148 | 149 |
150 |
151 |
152 | 153 | 161 |
162 | 163 |
164 | 165 | 173 |
174 | 175 | 181 |
182 |
183 | 184 |
185 |
186 |

Verify Your Identity

187 |
    188 |
  1. Create a new public GitHub gist
  2. 189 |
  3. Name the file:
  4. 190 |
  5. Add this content: 191 |
    
    192 |                                     
  6. 193 |
  7. Copy the gist URL and paste it below
  8. 194 |
195 |
196 | 197 |
198 |
199 | 200 | 208 |
209 | 210 | 216 |
217 |
218 | 219 |
220 |
221 |
222 | 223 |
224 | Back to Home 225 |
226 |
227 |
228 |
229 | 230 | 231 | `; 232 | 233 | return new Response(html, { 234 | headers: { 235 | 'Content-Type': 'text/html', 236 | }, 237 | }); 238 | } -------------------------------------------------------------------------------- /src/searchBar.js: -------------------------------------------------------------------------------- 1 | 2 | const mainLogo = 'https://xrpublisher.com/wp-content/uploads/2024/10/xrpublisher-logo-300x70.png'; 3 | const logoMarkup = `Logo`; 4 | 5 | export function createSearchBar(currentQuery = '', tags = [], request) { 6 | const safeQuery = currentQuery.replace(/[&<>"']/g, (match) => { 7 | const escape = { 8 | '&': '&', 9 | '<': '<', 10 | '>': '>', 11 | '"': '"', 12 | "'": ''' 13 | }; 14 | return escape[match]; 15 | }); 16 | 17 | const mainLogo = 'https://xrpublisher.com/wp-content/uploads/2024/10/xrpublisher-logo-300x70.png'; 18 | const logoMarkup = `Logo`; 19 | 20 | return ` 21 |
22 |
23 |
24 | 31 | 39 |
40 |
41 |
42 | `; 43 | } -------------------------------------------------------------------------------- /src/searchTemplate.js: -------------------------------------------------------------------------------- 1 | import { createSearchBar } from './searchBar'; 2 | import { createHeaderSearchBar } from './headerSearchBar'; 3 | import { createSecureHtmlService } from './secureHtmlService'; 4 | 5 | export default async function generateSearchHTML(results, query = '', tags = [], offset = 0, limit = 20, env, request) { 6 | const secureHtmlService = createSecureHtmlService(); 7 | const safeResults = results.map(world => ({ 8 | ...world, 9 | preview_image: world.preview_image || '/images/default-preview.jpg', 10 | visit_count: world.visit_count || 0, 11 | active_users: world.active_users || 0 12 | })); 13 | 14 | const totalResults = results.length; 15 | const hasMore = totalResults === limit; 16 | const currentPage = Math.floor(offset / limit) + 1; 17 | 18 | const paginationSection = ` 19 |
20 |
21 | Showing ${offset + 1}-${offset + safeResults.length} results 22 |
23 |
24 | ${offset > 0 ? ` 25 | 27 | Previous 28 | 29 | ` : ''} 30 | ${hasMore ? ` 31 | 33 | Next 34 | 35 | ` : ''} 36 |
37 |
38 | `; 39 | 40 | const html = ` 41 | 42 | 43 | 44 | Search Results ${query ? `for "${query}"` : ''} - World Directory 45 | 50 | 54 | 61 | 62 | 63 |
64 | ${createHeaderSearchBar()} 65 | 66 | 67 |
68 |
69 |

70 | ${query 71 | ? `Search Results for "${secureHtmlService.sanitizeText(query)}"` 72 | : 'Explore Virtual Worlds'} 73 |

74 |
75 |
76 | 83 | 89 |
90 |
91 | ${tags.length > 0 ? ` 92 |
93 | ${tags.map(tag => ` 94 | 95 | #${secureHtmlService.sanitizeText(tag)} 96 | 97 | `).join('')} 98 |
99 | ` : ''} 100 |
101 |
102 | 103 | 104 |
105 |
106 |
107 | ${safeResults.length > 0 ? safeResults.map(world => ` 108 |
109 | 110 |
111 | ${world.name} 115 |
116 |

${world.name}

117 |

by ${world.author}

118 |
119 |
120 | 121 | 122 |
123 |

${world.short_description}

124 | 125 |
126 |
127 |
128 | 129 | ${world.active_users} active 130 |
131 |
132 | 133 | ${world.visit_count} visits 134 |
135 |
136 | 137 |
138 | Version ${world.version} 139 | Capacity: ${world.capacity} 140 |
141 | 142 | 144 | Enter World 145 | 146 |
147 |
148 |
149 | `).join('') : ` 150 |
151 |

No worlds found

152 |

Try adjusting your search terms

153 |
154 | `} 155 |
156 | 157 | ${paginationSection} 158 |
159 |
160 |
161 | 164 | 165 | 166 | `; 167 | 168 | return secureHtmlService.transformHTML(html); 169 | } -------------------------------------------------------------------------------- /src/secureHtmlService.js: -------------------------------------------------------------------------------- 1 | class SecureHtmlService { 2 | generateNonce() { 3 | const array = new Uint8Array(16); 4 | crypto.getRandomValues(array); 5 | return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); 6 | } 7 | 8 | getSecurityHeaders(nonce) { 9 | return { 10 | 'Content-Security-Policy': [ 11 | // Allow resources from same origin and CDN 12 | "default-src 'self' https://cdn.jsdelivr.net https://builds.sxp.digital https://items.sxp.digital https://unpkg.com", 13 | // Allow scripts and our web components 14 | `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://cdn.jsdelivr.net https://builds.sxp.digital https://unpkg.com`, 15 | "worker-src 'self' blob:", 16 | // Allow styles from CDN and inline styles (needed for web components) 17 | "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 18 | // Allow images from any HTTPS source 19 | "img-src 'self' https: data: blob:", 20 | // Allow fonts from CDN 21 | "font-src 'self' https://cdn.jsdelivr.net", 22 | // Allow custom elements 23 | `script-src-elem 'self' 'nonce-${nonce}' blob: https://cdn.jsdelivr.net https://builds.sxp.digital https://unpkg.com`, 24 | // Allow connections (needed for any real-time features) 25 | "connect-src 'self' wss: https: blob:", 26 | // Basic security headers 27 | "base-uri 'self'" 28 | ].join('; '), 29 | 'X-Content-Type-Options': 'nosniff', 30 | 'X-Frame-Options': 'SAMEORIGIN', 31 | 'Referrer-Policy': 'strict-origin-when-cross-origin' 32 | }; 33 | } 34 | 35 | sanitizeText(text) { 36 | if (!text) return ''; 37 | return String(text) 38 | .replace(/&/g, '&') 39 | .replace(//g, '>') 41 | .replace(/"/g, '"') 42 | .replace(/'/g, '''); 43 | } 44 | 45 | sanitizeUrl(url) { 46 | if (!url) return ''; 47 | try { 48 | const parsed = new URL(url); 49 | return ['http:', 'https:'].includes(parsed.protocol) ? url : ''; 50 | } catch { 51 | return url.startsWith('/') && !url.includes('..') ? url : ''; 52 | } 53 | } 54 | 55 | createMetaTransformer(nonce) { 56 | return { 57 | element: (element) => { 58 | if (element.tagName === 'head') { 59 | element.append(` 60 | 61 | 62 | `, { html: true }); 63 | } 64 | } 65 | }; 66 | } 67 | 68 | createScriptTransformer(nonce) { 69 | return { 70 | element: (element) => { 71 | if (element.tagName === 'script') { 72 | const src = element.getAttribute('src'); 73 | if (src && ( 74 | src.includes('cdn.jsdelivr.net') || 75 | src.includes('playground.wordpress.net') || 76 | src.includes('playground.xr.foundation') || 77 | src.includes('builds.sxp.digital') || 78 | src.includes('unpkg.com') 79 | )) { 80 | element.setAttribute('nonce', nonce); 81 | return; 82 | } 83 | element.remove(); 84 | } 85 | } 86 | }; 87 | } 88 | 89 | createLinkTransformer() { 90 | return { 91 | element: (element) => { 92 | if (element.tagName === 'a') { 93 | const href = element.getAttribute('href'); 94 | if (href) { 95 | const sanitizedHref = this.sanitizeUrl(href); 96 | if (!sanitizedHref) { 97 | element.remove(); 98 | return; 99 | } 100 | element.setAttribute('href', sanitizedHref); 101 | if (sanitizedHref.startsWith('http')) { 102 | element.setAttribute('rel', 'noopener noreferrer'); 103 | element.setAttribute('target', '_blank'); 104 | } 105 | } 106 | } 107 | } 108 | }; 109 | } 110 | 111 | async transformHTML(rawHtml) { 112 | const nonce = this.generateNonce(); 113 | const response = new Response(rawHtml, { 114 | headers: { 115 | 'Content-Type': 'text/html', 116 | ...this.getSecurityHeaders(nonce) 117 | } 118 | }); 119 | 120 | return new HTMLRewriter() 121 | .on('head', this.createMetaTransformer(nonce)) 122 | .on('script', this.createScriptTransformer(nonce)) 123 | .on('a', this.createLinkTransformer()) 124 | .transform(response); 125 | } 126 | 127 | // Slugify function that preserves case but handles spaces and special characters 128 | slugifyCharacterName(name) { 129 | return name 130 | .trim() 131 | .replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens 132 | .replace(/\s+/g, '-'); // Replace spaces with hyphens 133 | } 134 | 135 | // Function to normalize character name for comparison 136 | normalizeCharacterName(name) { 137 | return name.trim().toLowerCase(); 138 | } 139 | 140 | sanitizeCharacterData(character) { 141 | if (!character) return null; 142 | 143 | return { 144 | name: this.sanitizeText(character.name), 145 | slug: this.slugifyCharacterName(this.sanitizeText(this.normalizeCharacterName(character.name))), 146 | modelProvider: this.sanitizeText(character.modelProvider || 'LLAMALOCAL'), 147 | bio: this.sanitizeText(character.bio), 148 | author: this.sanitizeText(character.author), 149 | lore: Array.isArray(character.lore) 150 | ? character.lore.map(item => this.sanitizeText(item)) 151 | : [], 152 | messageExamples: Array.isArray(character.messageExamples) 153 | ? character.messageExamples.map(conversation => { 154 | if (!Array.isArray(conversation)) return []; 155 | return conversation.map(message => ({ 156 | user: this.sanitizeText(message.user), 157 | content: { 158 | text: this.sanitizeText(message.content?.text || '') 159 | } 160 | })); 161 | }) 162 | : [], 163 | postExamples: Array.isArray(character.postExamples) 164 | ? character.postExamples.map(post => this.sanitizeText(post)) 165 | : [], 166 | topics: Array.isArray(character.topics) 167 | ? character.topics.map(topic => this.sanitizeText(topic)) 168 | : [], 169 | style: character.style ? { 170 | all: Array.isArray(character.style.all) 171 | ? character.style.all.map(style => this.sanitizeText(style)) 172 | : [], 173 | chat: Array.isArray(character.style.chat) 174 | ? character.style.chat.map(style => this.sanitizeText(style)) 175 | : [], 176 | post: Array.isArray(character.style.post) 177 | ? character.style.post.map(style => this.sanitizeText(style)) 178 | : [] 179 | } : { 180 | all: [], 181 | chat: [], 182 | post: [] 183 | }, 184 | adjectives: Array.isArray(character.adjectives) 185 | ? character.adjectives.map(adj => this.sanitizeText(adj)) 186 | : [], 187 | settings: character.settings ? { 188 | model: this.sanitizeText(character.settings.model || ''), 189 | voice: { 190 | model: this.sanitizeText(character.settings.voice?.model || '') 191 | } 192 | } : { 193 | model: '', 194 | voice: { model: '' } 195 | }, 196 | vrmUrl: this.sanitizeUrl(character.vrmUrl), 197 | created_at: this.sanitizeText(character.created_at), 198 | updated_at: this.sanitizeText(character.updated_at), 199 | authorData: character.authorData ? this.sanitizeAuthorData(character.authorData) : null 200 | }; 201 | } 202 | 203 | sanitizePluginData(plugin) { 204 | if (!plugin) return null; 205 | return { 206 | name: this.sanitizeText(plugin.name), 207 | slug: this.sanitizeText(plugin.slug), 208 | short_description: this.sanitizeText(plugin.short_description), 209 | version: this.sanitizeText(plugin.version), 210 | download_link: this.sanitizeUrl(plugin.download_link), 211 | support_url: this.sanitizeUrl(plugin.support_url), 212 | requires: this.sanitizeText(plugin.requires), 213 | tested: this.sanitizeText(plugin.tested), 214 | requires_php: this.sanitizeText(plugin.requires_php), 215 | rating: parseFloat(plugin.rating) || 0, 216 | active_installs: parseInt(plugin.active_installs) || 0, 217 | last_updated: this.sanitizeText(plugin.updated_at), 218 | author: this.sanitizeText(plugin.author), 219 | banners: { 220 | "high": this.sanitizeUrl(plugin.banners_high || plugin.banners?.high || 'default-banner-1500x620.jpg'), 221 | "low": this.sanitizeUrl(plugin.banners_low || plugin.banners?.low || '/images/default-banner.jpg') 222 | }, 223 | icons: { 224 | '1x': this.sanitizeUrl(plugin.icons_1x || plugin.icons?.['1x'] || '/images/default-icon.jpg'), 225 | '2x': this.sanitizeUrl(plugin.icons_2x || plugin.icons?.['2x'] || '/images/default-icon.jpg') 226 | }, 227 | sections: plugin.sections ? { 228 | installation: this.sanitizeText(plugin.sections.installation), 229 | faq: this.sanitizeHtml(plugin.sections.faq), 230 | description: this.sanitizeHtml(plugin.sections.description) 231 | } : {}, 232 | authorData: plugin.authorData ? this.sanitizeAuthorData(plugin.authorData) : null 233 | }; 234 | } 235 | 236 | sanitizeAuthorData(author) { 237 | if (!author) return null; 238 | 239 | return { 240 | username: this.sanitizeText(author.username), 241 | bio: this.sanitizeText(author.bio), 242 | website: this.sanitizeUrl(author.website), 243 | avatar_url: this.sanitizeUrl(author.avatar_url || '/images/default-avatar.jpg'), 244 | twitter: this.sanitizeText(author.twitter), 245 | github: this.sanitizeText(author.github), 246 | plugins: Array.isArray(author.plugins) ? 247 | author.plugins.map(plugin => this.sanitizePluginData(plugin)) : [] 248 | }; 249 | } 250 | 251 | sanitizeHtml(html) { 252 | if (!html) return ''; 253 | 254 | const decoded = html.replace(/&/g, '&') 255 | .replace(/</g, '<') 256 | .replace(/>/g, '>') 257 | .replace(/"/g, '"') 258 | .replace(/'/g, "'") 259 | .replace(///g, "/"); 260 | 261 | // Extended allowed tags to include our web components 262 | const allowedTags = { 263 | // Standard HTML tags 264 | 'p': [], 265 | 'h1': [], 266 | 'h2': [], 267 | 'h3': [], 268 | 'h4': [], 269 | 'h5': [], 270 | 'h6': [], 271 | 'br': [], 272 | 'strong': [], 273 | 'em': [], 274 | 'ul': [], 275 | 'ol': [], 276 | 'li': [], 277 | 'a': ['href', 'title', 'target'], 278 | 'code': [], 279 | 'pre': [], 280 | // 3D World Web Components 281 | 'three-environment-block': [ 282 | 'class', 283 | 'devicetarget', 284 | 'threeobjecturl', 285 | 'scale', 286 | 'positiony', 287 | 'rotationy', 288 | 'animations', 289 | 'camcollisions' 290 | ], 291 | 'three-spawn-point-block': [ 292 | 'class', 293 | 'positionx', 294 | 'positiony', 295 | 'positionz', 296 | 'rotationx', 297 | 'rotationy', 298 | 'rotationz' 299 | ], 300 | 'three-model-block': [ 301 | 'class', 302 | 'threeobjecturl', 303 | 'scalex', 304 | 'scaley', 305 | 'scalez', 306 | 'positionx', 307 | 'positiony', 308 | 'positionz', 309 | 'rotationx', 310 | 'rotationy', 311 | 'rotationz', 312 | 'animations', 313 | 'collidable', 314 | 'alt' 315 | ] 316 | }; 317 | 318 | // Use the same sanitization logic but with our extended allowedTags 319 | return decoded.replace(/<[^>]*>/g, (tag) => { 320 | const matches = tag.match(/<\/?([a-z0-9\-]+)(.*?)\/?\s*>/i); 321 | if (!matches) return ''; 322 | 323 | const tagName = matches[1].toLowerCase(); 324 | const attrs = matches[2]; 325 | 326 | if (!allowedTags[tagName]) { 327 | return ''; 328 | } 329 | 330 | if (tag.startsWith('`; 332 | } 333 | 334 | let sanitizedAttrs = ''; 335 | if (attrs) { 336 | const allowedAttrs = allowedTags[tagName]; 337 | const attrMatches = attrs.match(/([a-z0-9\-]+)="([^"]*?)"/gi); 338 | if (attrMatches) { 339 | attrMatches.forEach(attr => { 340 | const [name, value] = attr.split('='); 341 | const cleanName = name.toLowerCase(); 342 | if (allowedAttrs.includes(cleanName)) { 343 | if (cleanName === 'href') { 344 | const sanitizedUrl = this.sanitizeUrl(value.slice(1, -1)); 345 | if (sanitizedUrl) { 346 | sanitizedAttrs += ` href="${sanitizedUrl}"`; 347 | } 348 | } else { 349 | sanitizedAttrs += ` ${cleanName}=${value}`; 350 | } 351 | } 352 | }); 353 | } 354 | } 355 | 356 | return `<${tagName}${sanitizedAttrs}>`; 357 | }); 358 | } 359 | sanitizeWorldData(world) { 360 | if (!world) return null; 361 | return { 362 | name: this.sanitizeText(world.name), 363 | slug: this.sanitizeText(world.slug), 364 | short_description: this.sanitizeText(world.short_description), 365 | long_description: this.sanitizeText(world.long_description), 366 | version: this.sanitizeText(world.version), 367 | preview_image: this.sanitizeUrl(world.preview_image || '/images/default-preview.jpg'), 368 | html_url: this.sanitizeUrl(world.html_url), 369 | entry_point: this.sanitizeText(world.entry_point || '0,0,0'), 370 | visibility: this.sanitizeText(world.visibility || 'public'), 371 | capacity: parseInt(world.capacity) || 100, 372 | visit_count: parseInt(world.visit_count) || 0, 373 | active_users: parseInt(world.active_users) || 0, 374 | content_rating: this.sanitizeText(world.content_rating || 'everyone'), 375 | author: this.sanitizeText(world.author), 376 | html_content: this.sanitizeHtml(world.html_content), 377 | created_at: this.sanitizeText(world.created_at), 378 | updated_at: this.sanitizeText(world.updated_at), 379 | authorData: world.authorData ? this.sanitizeAuthorData(world.authorData) : null 380 | }; 381 | } 382 | } 383 | 384 | 385 | 386 | // Export a factory function instead of an instance 387 | export function createSecureHtmlService() { 388 | return new SecureHtmlService(); 389 | } -------------------------------------------------------------------------------- /src/userAuthDO.js: -------------------------------------------------------------------------------- 1 | export class UserAuthDO { 2 | constructor(state, env) { 3 | this.state = state; 4 | this.env = env; 5 | this.sql = state.storage.sql; // This is correct 6 | 7 | if (!env.USER_KEY_SALT) { 8 | throw new Error('Missing required secret: USER_KEY_SALT'); 9 | } 10 | 11 | this.initializeSchema(); 12 | } 13 | 14 | async initializeSchema() { 15 | try { 16 | await this.sql.exec(` 17 | CREATE TABLE IF NOT EXISTS users ( 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, 19 | username TEXT NOT NULL UNIQUE, 20 | email TEXT NOT NULL, 21 | github_username TEXT, 22 | key_id TEXT NOT NULL UNIQUE, 23 | key_hash TEXT NOT NULL, 24 | invite_code_used TEXT NOT NULL, 25 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 26 | last_key_rotation TIMESTAMP DEFAULT CURRENT_TIMESTAMP 27 | ); 28 | 29 | CREATE INDEX IF NOT EXISTS idx_users_key_id 30 | ON users(key_id); 31 | 32 | CREATE TABLE IF NOT EXISTS key_roll_verifications ( 33 | id INTEGER PRIMARY KEY AUTOINCREMENT, 34 | username TEXT NOT NULL, 35 | verification_token TEXT NOT NULL UNIQUE, 36 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 37 | expires_at TIMESTAMP NOT NULL, 38 | used BOOLEAN DEFAULT 0, 39 | FOREIGN KEY(username) REFERENCES users(username) 40 | ); 41 | `); 42 | } catch (error) { 43 | console.error("Error initializing user auth schema:", error); 44 | throw error; 45 | } 46 | } 47 | 48 | 49 | // Generate a secure random key ID 50 | generateKeyId() { 51 | const buffer = new Uint8Array(16); // 128-bit random value 52 | crypto.getRandomValues(buffer); 53 | return Array.from(buffer) 54 | .map(byte => byte.toString(16).padStart(2, '0')) 55 | .join(''); 56 | } 57 | 58 | // Generate API key by combining key ID with master salt 59 | async generateApiKey(keyId) { 60 | // Create a TextEncoder to convert strings to Uint8Array 61 | const encoder = new TextEncoder(); 62 | 63 | // Convert master salt and key ID to Uint8Array 64 | const masterSalt = encoder.encode(this.env.USER_KEY_SALT); 65 | const keyIdBytes = encoder.encode(keyId); 66 | 67 | // Import master salt as HMAC key 68 | const key = await crypto.subtle.importKey( 69 | 'raw', 70 | masterSalt, 71 | { name: 'HMAC', hash: 'SHA-256' }, 72 | false, 73 | ['sign'] 74 | ); 75 | 76 | // Generate HMAC 77 | const signature = await crypto.subtle.sign( 78 | 'HMAC', 79 | key, 80 | keyIdBytes 81 | ); 82 | 83 | // Convert signature to hex string 84 | return Array.from(new Uint8Array(signature)) 85 | .map(byte => byte.toString(16).padStart(2, '0')) 86 | .join(''); 87 | } 88 | 89 | // Create a new user account 90 | async createUser(username, inviteCode, github_username, email) { 91 | try { 92 | const existingUser = await this.sql.exec( 93 | "SELECT 1 FROM users WHERE username = ?", 94 | [username] 95 | ).toArray(); 96 | 97 | if (existingUser.length > 0) { 98 | throw new Error('Username already taken'); 99 | } 100 | 101 | if (!this.env.INVITE_CODE || inviteCode !== this.env.INVITE_CODE) { 102 | throw new Error('Invalid invite code'); 103 | } 104 | 105 | const keyId = this.generateKeyId(); 106 | const keyHash = await this.generateApiKey(keyId); 107 | 108 | const query = ` 109 | INSERT INTO users ( 110 | username, 111 | github_username, 112 | email, 113 | key_id, 114 | key_hash, 115 | invite_code_used, 116 | created_at 117 | ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`; 118 | 119 | // Pass each parameter individually 120 | await this.sql.exec(query, username, github_username, email, keyId, keyHash, inviteCode); 121 | 122 | return { username, keyId, keyHash, apiKey: `${username}.${keyId}` }; 123 | } catch (error) { 124 | console.error(error); 125 | throw error; 126 | } 127 | } 128 | async updateUserAsAdmin(username, updates) { 129 | try { 130 | // Check if github_username column exists, add it if it doesn't 131 | const columns = await this.sql.exec(`PRAGMA table_info(users)`).toArray(); 132 | if (!columns.some(col => col.name === 'github_username')) { 133 | await this.sql.exec(`ALTER TABLE users ADD COLUMN github_username TEXT`); 134 | } 135 | 136 | const user = await this.sql.exec( 137 | "SELECT * FROM users WHERE username = ?", 138 | username 139 | ).one(); 140 | 141 | if (!user) { 142 | throw new Error('User not found'); 143 | } 144 | 145 | // Start building update query 146 | let updateFields = []; 147 | let updateValues = []; 148 | 149 | if (updates.email) { 150 | updateFields.push('email = ?'); 151 | updateValues.push(updates.email); 152 | } 153 | 154 | if (updates.github_username) { 155 | updateFields.push('github_username = ?'); 156 | updateValues.push(updates.github_username); 157 | } 158 | 159 | if (updates.newUsername) { 160 | // Check if new username is available 161 | const existing = await this.sql.exec( 162 | "SELECT username FROM users WHERE username = ?", 163 | updates.newUsername 164 | ).one(); 165 | 166 | if (existing) { 167 | throw new Error('New username already taken'); 168 | } 169 | 170 | updateFields.push('username = ?'); 171 | updateValues.push(updates.newUsername); 172 | } 173 | 174 | // If a new API key is requested, generate one 175 | if (updates.generateNewKey) { 176 | const newKeyId = this.generateKeyId(); 177 | const newKeyHash = await this.generateApiKey(newKeyId); 178 | 179 | updateFields.push('key_id = ?'); 180 | updateValues.push(newKeyId); 181 | 182 | updateFields.push('key_hash = ?'); 183 | updateValues.push(newKeyHash); 184 | 185 | updateFields.push('last_key_rotation = CURRENT_TIMESTAMP'); 186 | 187 | // Store the new API key to return to admin 188 | updates.newApiKey = `${updates.newUsername || username}.${newKeyId}`; 189 | } 190 | 191 | if (updateFields.length === 0) { 192 | throw new Error('No valid updates provided'); 193 | } 194 | 195 | // Build and execute update query 196 | const query = ` 197 | UPDATE users 198 | SET ${updateFields.join(', ')} 199 | WHERE username = ? 200 | `; 201 | 202 | await this.sql.exec(query, ...updateValues, username); 203 | 204 | return { 205 | success: true, 206 | message: 'User updated successfully', 207 | username: updates.newUsername || username, 208 | email: updates.email, 209 | github_username: updates.github_username, 210 | newApiKey: updates.newApiKey 211 | }; 212 | } catch (error) { 213 | console.error("Admin update error:", error); 214 | throw error; 215 | } 216 | } 217 | 218 | async deleteUser(username) { 219 | try { 220 | // First delete any pending key roll verifications 221 | await this.sql.exec( 222 | "DELETE FROM key_roll_verifications WHERE username = ?", 223 | username 224 | ); 225 | 226 | // Then delete the user 227 | const result = await this.sql.exec( 228 | "DELETE FROM users WHERE username = ?", 229 | username 230 | ); 231 | 232 | // Return success even if no user was found (idempotent delete) 233 | return { 234 | success: true, 235 | deleted: result.changes > 0 236 | }; 237 | } catch (error) { 238 | console.error("Error deleting user from auth database:", error); 239 | throw new Error(`Failed to delete user from auth database: ${error.message}`); 240 | } 241 | } 242 | 243 | // Verify API key 244 | async verifyApiKey(apiKey) { 245 | try { 246 | const [username, keyId] = apiKey.split('.'); 247 | if (!username || !keyId) { 248 | return false; 249 | } 250 | 251 | const expectedHash = await this.generateApiKey(keyId); 252 | const user = await this.sql.exec( 253 | "SELECT username FROM users WHERE username = ? AND key_id = ? AND key_hash = ?", 254 | username, keyId, expectedHash 255 | ).one(); 256 | 257 | return {valid: !!user, username: username}; 258 | } catch (error) { 259 | console.error("Error verifying API key:", error); 260 | return false; 261 | } 262 | } 263 | 264 | // Rotate API key for a user 265 | async rotateApiKey(username, currentApiKey) { 266 | try { 267 | // Verify current API key 268 | if (!await this.verifyApiKey(currentApiKey)) { 269 | throw new Error('Invalid credentials'); 270 | } 271 | 272 | // Generate new key ID and hash 273 | const newKeyId = this.generateKeyId(); 274 | const newKeyHash = await this.generateApiKey(newKeyId); 275 | 276 | // Update user record 277 | await this.sql.exec(` 278 | UPDATE users 279 | SET key_id = ?, key_hash = ?, last_key_rotation = CURRENT_TIMESTAMP 280 | WHERE username = ? 281 | `, newKeyId, newKeyHash, username); 282 | 283 | return { 284 | success: true, 285 | message: 'Store this API key securely - it cannot be recovered if lost', 286 | apiKey: `${username}.${newKeyId}` 287 | }; 288 | } catch (error) { 289 | console.error("Error rotating API key:", error); 290 | throw error; 291 | } 292 | } 293 | 294 | async initiateKeyRoll(username, email) { 295 | // Verify username and email match 296 | const user = await this.sql.exec( 297 | "SELECT * FROM users WHERE username = ? AND email = ?", 298 | username, email 299 | ).one(); 300 | 301 | if (!user) { 302 | throw new Error('Invalid username or email'); 303 | } 304 | 305 | if (!user.github_username) { 306 | throw new Error('GitHub username not set for this account. Please contact support to update your GitHub username.'); 307 | } 308 | 309 | // Generate verification token and content 310 | const buffer = new Uint8Array(32); 311 | crypto.getRandomValues(buffer); 312 | const verificationToken = Array.from(buffer) 313 | .map(byte => byte.toString(16).padStart(2, '0')) 314 | .join(''); 315 | 316 | // Store verification info with expiration 317 | await this.sql.exec(` 318 | INSERT INTO key_roll_verifications ( 319 | username, 320 | verification_token, 321 | created_at, 322 | expires_at 323 | ) VALUES (?, ?, CURRENT_TIMESTAMP, datetime('now', '+1 hour')) 324 | `, username, verificationToken); 325 | 326 | return { 327 | verificationToken, 328 | verificationFilename: `plugin-publisher-verify-${username}.txt`, 329 | verificationContent: `Verifying plugin-publisher key roll request for ${username}\nToken: ${verificationToken}\nTimestamp: ${new Date().toISOString()}` 330 | }; 331 | } 332 | 333 | async verifyGistAndRollKey(gistUrl, verificationToken) { 334 | try { 335 | // Verify the token is valid and not expired 336 | const verification = await this.sql.exec(` 337 | SELECT username FROM key_roll_verifications 338 | WHERE verification_token = ? 339 | AND expires_at > CURRENT_TIMESTAMP 340 | AND used = 0 341 | `, verificationToken).one(); 342 | 343 | if (!verification) { 344 | throw new Error('Invalid or expired verification token'); 345 | } 346 | 347 | // Extract gist ID from URL 348 | const gistId = gistUrl.split('/').pop(); 349 | 350 | // Fetch gist content from GitHub API 351 | const response = await fetch(`https://api.github.com/gists/${gistId}`, { 352 | headers: { 353 | 'User-Agent': 'antpb-plugin-publisher' 354 | } 355 | }); 356 | 357 | if (!response.ok) { 358 | const errorText = await response.text(); 359 | console.error("GitHub API error response:", errorText); 360 | throw new Error(`Could not verify gist: ${response.status} ${errorText}`); 361 | } 362 | 363 | const gistData = await response.json(); 364 | 365 | const expectedFilename = `plugin-publisher-verify-${verification.username}.txt`; 366 | 367 | // Verify gist content 368 | const file = gistData.files[expectedFilename]; 369 | if (!file) { 370 | console.error("File not found in gist. Available files:", Object.keys(gistData.files)); 371 | throw new Error(`Verification file "${expectedFilename}" not found in gist`); 372 | } 373 | 374 | if (!file.content.includes(verificationToken)) { 375 | console.error("Token not found in file content. Content:", file.content); 376 | throw new Error('Verification token not found in gist content'); 377 | } 378 | 379 | // Verify gist owner matches GitHub username in our records 380 | const user = await this.sql.exec(` 381 | SELECT github_username FROM users 382 | WHERE username = ? 383 | `, verification.username).one(); 384 | 385 | if (!user || user.github_username !== gistData.owner.login) { 386 | throw new Error(`GitHub username mismatch. Expected: ${user?.github_username}, Found: ${gistData.owner.login}`); 387 | } 388 | 389 | // Mark verification as used 390 | await this.sql.exec(` 391 | UPDATE key_roll_verifications 392 | SET used = 1 393 | WHERE verification_token = ? 394 | `, verificationToken); 395 | 396 | // Generate and set new API key 397 | const newKeyId = this.generateKeyId(); 398 | const newKeyHash = await this.generateApiKey(newKeyId); 399 | 400 | await this.sql.exec(` 401 | UPDATE users 402 | SET key_id = ?, 403 | key_hash = ?, 404 | last_key_rotation = CURRENT_TIMESTAMP 405 | WHERE username = ? 406 | `, newKeyId, newKeyHash, verification.username); 407 | 408 | return { 409 | success: true, 410 | message: 'API key successfully rolled. Store this key securely - it cannot be recovered if lost.', 411 | apiKey: `${verification.username}.${newKeyId}` 412 | }; 413 | } catch (error) { 414 | console.error("Error verifying gist and rolling key:", error); 415 | throw error; 416 | } 417 | } 418 | 419 | // Handle incoming requests 420 | async fetch(request) { 421 | const url = new URL(request.url); 422 | 423 | if (request.method === "POST") { 424 | const body = await request.json(); 425 | 426 | switch (url.pathname) { 427 | case '/create-user': { 428 | const { username, inviteCode, github_username, email } = body; 429 | if (!username || !inviteCode) { 430 | return new Response(JSON.stringify({ 431 | error: 'Missing required fields' 432 | }), { status: 400 }); 433 | } 434 | 435 | try { 436 | const result = await this.createUser(username, inviteCode, github_username, email); 437 | return new Response(JSON.stringify(result)); 438 | } catch (error) { 439 | return new Response(JSON.stringify({ 440 | error: error.message 441 | }), { status: 400 }); 442 | } 443 | } 444 | case '/delete-user': { 445 | const { username } = body; 446 | if (!username) { 447 | return new Response(JSON.stringify({ 448 | error: 'Missing username' 449 | }), { status: 400 }); 450 | } 451 | 452 | try { 453 | await this.deleteUser(username); 454 | return new Response(JSON.stringify({ success: true })); 455 | } catch (error) { 456 | return new Response(JSON.stringify({ 457 | error: error.message 458 | }), { status: 500 }); 459 | } 460 | } 461 | 462 | case '/verify-key': { 463 | const { apiKey } = body; 464 | const verifyResponse = await this.verifyApiKey(apiKey); 465 | return new Response(JSON.stringify(verifyResponse)); 466 | } 467 | 468 | case '/rotate-key': { 469 | const { username, currentApiKey } = body; 470 | try { 471 | const result = await this.rotateApiKey(username, currentApiKey); 472 | return new Response(JSON.stringify(result)); 473 | } catch (error) { 474 | return new Response(JSON.stringify({ 475 | error: error.message 476 | }), { status: 400 }); 477 | } 478 | } 479 | 480 | case '/initiate-key-roll': { 481 | const { username, email } = body; 482 | try { 483 | const result = await this.initiateKeyRoll(username, email); 484 | return new Response(JSON.stringify(result), { 485 | headers: { 'Content-Type': 'application/json' } 486 | }); 487 | } catch (error) { 488 | return new Response(JSON.stringify({ 489 | error: error.message 490 | }), { 491 | status: 400, 492 | headers: { 'Content-Type': 'application/json' } 493 | }); 494 | } 495 | } 496 | 497 | case '/verify-key-roll': { 498 | const { gistUrl, verificationToken } = body; 499 | try { 500 | const result = await this.verifyGistAndRollKey(gistUrl, verificationToken); 501 | return new Response(JSON.stringify(result), { 502 | headers: { 'Content-Type': 'application/json' } 503 | }); 504 | } catch (error) { 505 | return new Response(JSON.stringify({ 506 | error: error.message 507 | }), { 508 | status: 400, 509 | headers: { 'Content-Type': 'application/json' } 510 | }); 511 | } 512 | } 513 | 514 | case '/admin-update-user': { 515 | const { username, ...updates } = body; 516 | if (!username) { 517 | return new Response(JSON.stringify({ 518 | error: 'Username is required' 519 | }), { status: 400 }); 520 | } 521 | 522 | try { 523 | const result = await this.updateUserAsAdmin(username, updates); 524 | return new Response(JSON.stringify(result)); 525 | } catch (error) { 526 | return new Response(JSON.stringify({ 527 | error: error.message 528 | }), { status: 400 }); 529 | } 530 | } 531 | 532 | default: 533 | return new Response('Not found', { status: 404 }); 534 | } 535 | } 536 | 537 | return new Response('Method not allowed', { status: 405 }); 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /src/worldTemplate.js: -------------------------------------------------------------------------------- 1 | import { createSecureHtmlService } from './secureHtmlService'; 2 | import { createHeaderSearchBar } from './headerSearchBar'; 3 | 4 | export default async function generateWorldHTML(worldData, env) { 5 | const secureHtmlService = createSecureHtmlService(); 6 | 7 | const visitsKey = `visits:${worldData.author}:${worldData.slug}`; 8 | const visitCount = parseInt(await env.VISIT_COUNTS.get(visitsKey)) || 0; 9 | 10 | const html = ` 11 | 12 | 13 | 14 | ${worldData.name} by ${worldData.author} 15 | 20 | 35 | 36 | 37 |
38 |
39 |
40 | 41 | Logo 42 | 43 |
44 | 45 | ${worldData.author} 48 | ${worldData.author} 49 | 50 | presents 51 |

${worldData.name}

52 | ${createHeaderSearchBar()} 53 |
54 | 👥 ${worldData.active_users || 0} Active 55 | 👁️ ${visitCount.toLocaleString()} Visits 56 |
57 |
58 |
59 |
60 |
61 | 62 |
63 |
64 | ${worldData.html_content} 65 |
66 |
67 | 68 |
69 |
70 |

About this World

71 |

${worldData.short_description || ''}

72 | 73 | ${worldData.long_description ? ` 74 |
75 | ${worldData.long_description} 76 |
77 | ` : ''} 78 | 79 |
80 |
81 | Version 82 | ${worldData.version} 83 |
84 |
85 | Capacity 86 | ${worldData.capacity} users 87 |
88 |
89 | Rating 90 | ${worldData.content_rating} 91 |
92 |
93 | Created 94 | ${new Date(worldData.created_at).toLocaleDateString()} 95 |
96 |
97 |
98 |
99 | 100 |
101 |
102 |

© ${new Date().getFullYear()} World Publisher

103 |
104 | Terms 105 | Privacy 106 | GitHub 107 |
108 |
109 |
110 | 111 | 112 | 129 | 130 | 131 | `; 132 | 133 | return secureHtmlService.transformHTML(html); 134 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // webpack.config.js 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | target: 'webworker', 6 | entry: './src/worker.js', 7 | mode: 'production', 8 | externals: { 9 | '@langchain/core/documents': '@langchain/core/documents', 10 | '@langchain/core/utils/tiktoken': '@langchain/core/utils/tiktoken', 11 | '@langchain/textsplitters': '@langchain/textsplitters', 12 | 'fastembed': 'fastembed', 13 | '@fal-ai/client': '@fal-ai/client', 14 | // Add this line: 15 | 'cloudflare:workers': 'self' 16 | }, 17 | resolve: { 18 | alias: { 19 | '@ai16z/eliza': path.resolve(__dirname, 'src/components/eliza-core') 20 | } 21 | } 22 | }; -------------------------------------------------------------------------------- /wrangler.toml.copy: -------------------------------------------------------------------------------- 1 | name = "xr-publisher" 2 | main = "src/worker.js" 3 | compatibility_date = "2024-11-04" 4 | compatibility_flags = ["nodejs_compat"] 5 | account_id = "" 6 | 7 | kv_namespaces = [ 8 | { binding = "VISIT_COUNTS", id = "" } 9 | ] 10 | 11 | [ai] 12 | binding = "AI" 13 | 14 | [observability] 15 | enabled = true 16 | head_sampling_rate = 1 17 | 18 | 19 | [triggers] 20 | crons = ["*/5 * * * *"] 21 | 22 | [[durable_objects.bindings]] 23 | name = "WORLD_REGISTRY" 24 | class_name = "WorldRegistryDO" 25 | 26 | [[durable_objects.bindings]] 27 | name = "USER_AUTH" 28 | class_name = "UserAuthDO" 29 | 30 | [[durable_objects.bindings]] 31 | name = "CHARACTER_REGISTRY" 32 | class_name = "CharacterRegistryDO" 33 | 34 | [[migrations]] 35 | tag = "v1" 36 | new_sqlite_classes = ["WorldRegistryDO"] 37 | 38 | [[migrations]] 39 | tag = "v2" 40 | new_sqlite_classes = ["UserAuthDO"] 41 | 42 | [[migrations]] 43 | tag = "v3" 44 | new_sqlite_classes = ["CharacterRegistryDO"] 45 | 46 | [vars] 47 | ENVIRONMENT = "production" 48 | WORLD_BUCKET_URL = "" 49 | OPENAI_API_KEY = "" 50 | ANTHROPIC_API_KEY = "" 51 | CF_ACCOUNT_ID = "" 52 | CF_GATEWAY_ID = "agent-gateway" 53 | 54 | 55 | [[r2_buckets]] 56 | binding = "WORLD_BUCKET" 57 | bucket_name = "xr-publisher-bucket" 58 | preview_bucket_name = "xr-publisher-bucket-preview" 59 | 60 | [env.production] 61 | vars = { ENVIRONMENT = "production" } --------------------------------------------------------------------------------