├── .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 | 
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 |
132 |
133 |
134 |
135 | Worlds
136 | Characters
137 |
138 |
139 |
140 |
141 |
142 |
143 | Worlds by ${safeAuthor.username}
144 |
145 |
146 | ${sortedWorlds.length > 0 ? `
147 |
148 | ${sortedWorlds.map(world => `
149 |
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 |
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 |
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 | [34mCLI[39m Building entry: src/index.ts
7 | [34mCLI[39m Using tsconfig: tsconfig.json
8 | [34mCLI[39m tsup v8.3.5
9 | [34mCLI[39m Using tsup config: /Users/anthonyburchell/scratch/eliza/eliza/packages/client-telegram/tsup.config.ts
10 | [34mCLI[39m Target: esnext
11 | [34mCLI[39m Cleaning output folder
12 | [34mESM[39m Build start
13 | [32mESM[39m [1mdist/index.js [22m[32m37.40 KB[39m
14 | [32mESM[39m [1mdist/index.js.map [22m[32m77.41 KB[39m
15 | [32mESM[39m ⚡️ 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 = ` `;
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 = ` `;
19 |
20 | return `
21 |
22 |
23 | ${logoMarkup}
24 |
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 |
56 |
57 |
Explore virtual worlds created by talented builders across the metaverse.
58 |
59 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
Featured Creators
81 | ${sortedAuthors.length > 0 ? `
82 |
83 | ${sortedAuthors.map(author => `
84 |
85 |
86 |
91 |
92 |
${author.username}
93 |
Creator since ${new Date(author.created_at || Date.now()).toLocaleDateString()}
94 |
95 |
96 |
${author.bio || ''}
97 |
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 |
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 |
123 |
124 |
125 |
126 |
127 |
128 |
Register as an Author
129 |
130 |
189 |
190 |
191 |
192 |
193 |
198 |
Welcome, !
199 |
200 |
Your credentials have been downloaded successfully! Here's what to do next:
201 |
202 | Install Local WP if you haven't already
203 | Create a new Local WP site
204 | Place the downloaded plugin-publisher-config.txt
file in the root of your site
205 | Scaffold your first plugin using our CLI tool
206 |
207 |
212 |
213 |
214 |
215 |
216 |
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 |
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 |
183 |
184 |
185 |
186 |
Verify Your Identity
187 |
188 | Create a new public GitHub gist
189 | Name the file:
190 | Add this content:
191 |
192 |
193 | Copy the gist URL and paste it below
194 |
195 |
196 |
197 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
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 = ` `;
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 = ` `;
19 |
20 | return `
21 |
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 |
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 |
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 |
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('')) {
331 | return `${tagName}>`;
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 |
42 |
43 |
44 |
45 |
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 |
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" }
--------------------------------------------------------------------------------