Showing
8 changed files
with
382 additions
and
0 deletions
.env
0 → 100644
Dockerfile
0 → 100644
| 1 | +FROM php:8.2-cli | |
| 2 | + | |
| 3 | +# Install dependencies and extensions | |
| 4 | +RUN apt-get update && apt-get install -y \ | |
| 5 | + git \ | |
| 6 | + unzip \ | |
| 7 | + libzip-dev \ | |
| 8 | + && docker-php-ext-install pcntl posix sockets zip pdo_mysql | |
| 9 | + | |
| 10 | +# Install Composer | |
| 11 | +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer | |
| 12 | + | |
| 13 | +# Set working directory | |
| 14 | +WORKDIR /app | |
| 15 | + | |
| 16 | +# Copy dependency definition first | |
| 17 | +COPY composer.json ./ | |
| 18 | + | |
| 19 | +# Install dependencies (if composer.lock exists it would be better, but we start fresh) | |
| 20 | +RUN composer install --no-scripts --no-autoloader | |
| 21 | + | |
| 22 | +# Copy application source | |
| 23 | +COPY . . | |
| 24 | + | |
| 25 | +# Generate autoloader | |
| 26 | +RUN composer dump-autoload --optimize | |
| 27 | + | |
| 28 | +# Expose WebSocket port | |
| 29 | +EXPOSE 8888 | |
| 30 | + | |
| 31 | +# Command to run the Workerman server | |
| 32 | +CMD ["php", "start.php", "start"] | ... | ... |
composer.json
0 → 100644
| 1 | +{ | |
| 2 | + "name": "moltbot/relay-server", | |
| 3 | + "description": "IoT Relay Server for Clawdbot WeChat Integration", | |
| 4 | + "type": "project", | |
| 5 | + "license": "MIT", | |
| 6 | + "require": { | |
| 7 | + "php": ">=8.0", | |
| 8 | + "workerman/workerman": "^4.1", | |
| 9 | + "firebase/php-jwt": "^6.10", | |
| 10 | + "predis/predis": "^2.3" | |
| 11 | + }, | |
| 12 | + "autoload": { | |
| 13 | + "psr-4": { | |
| 14 | + "Moltbot\\Relay\\": "src/" | |
| 15 | + } | |
| 16 | + } | |
| 17 | +} | |
| \ No newline at end of file | ... | ... |
docker-compose.yml
0 → 100644
| 1 | +services: | |
| 2 | + relay: | |
| 3 | + build: . | |
| 4 | + container_name: moltbot-relay | |
| 5 | + ports: | |
| 6 | + - "8888:8888" | |
| 7 | + env_file: | |
| 8 | + - .env | |
| 9 | + environment: | |
| 10 | + - REDIS_HOST=redis | |
| 11 | + - REDIS_PORT=6379 | |
| 12 | + | |
| 13 | + redis: | |
| 14 | + image: redis:alpine | |
| 15 | + container_name: moltbot-redis | |
| 16 | + ports: | |
| 17 | + - "6379:6379" | ... | ... |
migrate.php
0 → 100644
| 1 | +<?php | |
| 2 | +require_once __DIR__ . '/vendor/autoload.php'; | |
| 3 | + | |
| 4 | +// Load .env (simple loader since we don't have dotenv lib installed in the basic container, | |
| 5 | +// or relying on docker-compose env injection) | |
| 6 | +// Actually docker-compose injects env vars, so getenv() works. | |
| 7 | + | |
| 8 | +$host = getenv('DB_HOST'); | |
| 9 | +$user = getenv('DB_USER'); | |
| 10 | +$pass = getenv('DB_PASSWORD'); | |
| 11 | +$name = getenv('DB_NAME'); | |
| 12 | +$port = getenv('DB_PORT') ?: 3306; | |
| 13 | + | |
| 14 | +echo "Connecting to MySQL at $host...\n"; | |
| 15 | + | |
| 16 | +try { | |
| 17 | + $dsn = "mysql:host=$host;port=$port;charset=utf8mb4"; | |
| 18 | + $pdo = new PDO($dsn, $user, $pass, [ | |
| 19 | + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |
| 20 | + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC | |
| 21 | + ]); | |
| 22 | + | |
| 23 | + echo "Connected successfully.\n"; | |
| 24 | + | |
| 25 | + // Create Database if not exists | |
| 26 | + $pdo->exec("CREATE DATABASE IF NOT EXISTS `$name` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); | |
| 27 | + $pdo->exec("USE `$name`"); | |
| 28 | + echo "Selected database '$name'.\n"; | |
| 29 | + | |
| 30 | + $sql = <<<SQL | |
| 31 | +-- Devices Table | |
| 32 | +CREATE TABLE IF NOT EXISTS `devices` ( | |
| 33 | + `id` VARCHAR(64) NOT NULL COMMENT '设备ID (Device ID)', | |
| 34 | + `secret` VARCHAR(128) NOT NULL COMMENT '连接密钥 (Hash)', | |
| 35 | + `name` VARCHAR(50) DEFAULT NULL COMMENT '设备昵称', | |
| 36 | + `owner_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '绑定用户ID (NULL=未绑定)', | |
| 37 | + `status` ENUM('online', 'offline') DEFAULT 'offline' COMMENT '在线状态', | |
| 38 | + `last_seen` TIMESTAMP NULL DEFAULT NULL COMMENT '最后心跳时间', | |
| 39 | + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |
| 40 | + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |
| 41 | + PRIMARY KEY (`id`), | |
| 42 | + KEY `idx_owner` (`owner_id`) | |
| 43 | +) ENGINE=InnoDB COMMENT='设备管理表'; | |
| 44 | + | |
| 45 | +-- Users Table | |
| 46 | +CREATE TABLE IF NOT EXISTS `users` ( | |
| 47 | + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, | |
| 48 | + `phone` VARCHAR(20) NOT NULL COMMENT '手机号', | |
| 49 | + `password` VARCHAR(255) NOT NULL COMMENT '密码 Hash', | |
| 50 | + `wx_openid` VARCHAR(64) DEFAULT NULL COMMENT '微信 OpenID', | |
| 51 | + `nickname` VARCHAR(50) DEFAULT NULL COMMENT '显示的昵称', | |
| 52 | + `avatar_url` VARCHAR(255) DEFAULT NULL, | |
| 53 | + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |
| 54 | + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |
| 55 | + PRIMARY KEY (`id`), | |
| 56 | + UNIQUE KEY `uk_phone` (`phone`), | |
| 57 | + UNIQUE KEY `uk_wx_openid` (`wx_openid`) | |
| 58 | +) ENGINE=InnoDB COMMENT='用户表'; | |
| 59 | + | |
| 60 | +-- Device Logs Table | |
| 61 | +CREATE TABLE IF NOT EXISTS `device_logs` ( | |
| 62 | + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, | |
| 63 | + `device_id` VARCHAR(64) NOT NULL, | |
| 64 | + `level` VARCHAR(10) DEFAULT 'INFO', | |
| 65 | + `message` TEXT NOT NULL, | |
| 66 | + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |
| 67 | + PRIMARY KEY (`id`), | |
| 68 | + KEY `idx_device_time` (`device_id`, `created_at`) | |
| 69 | +) ENGINE=InnoDB COMMENT='设备日志表'; | |
| 70 | +SQL; | |
| 71 | + | |
| 72 | + $pdo->exec($sql); | |
| 73 | + echo "Tables created successfully.\n"; | |
| 74 | + | |
| 75 | + // Insert sample device if not exists | |
| 76 | + $pdo->exec("INSERT IGNORE INTO `devices` (`id`, `secret`, `name`, `status`) VALUES ('dev_test_001', '123456', 'My First Moltbot', 'offline')"); | |
| 77 | + echo "Sample data inserted.\n"; | |
| 78 | + | |
| 79 | +} catch (PDOException $e) { | |
| 80 | + echo "Database Error: " . $e->getMessage() . "\n"; | |
| 81 | + exit(1); | |
| 82 | +} | ... | ... |
package-deploy.ps1
0 → 100644
| 1 | +# Relay Server 部署打包脚本 | |
| 2 | +# 运行: .\package-deploy.ps1 | |
| 3 | + | |
| 4 | +$DeployDir = "$PSScriptRoot\deploy-package" | |
| 5 | +$ZipFile = "$PSScriptRoot\relay-server-deploy.zip" | |
| 6 | + | |
| 7 | +Write-Host "=== 打包 Relay Server 部署文件 ===" -ForegroundColor Cyan | |
| 8 | + | |
| 9 | +# 创建部署目录 | |
| 10 | +if (Test-Path $DeployDir) { Remove-Item $DeployDir -Recurse -Force } | |
| 11 | +New-Item -ItemType Directory -Path $DeployDir | Out-Null | |
| 12 | + | |
| 13 | +# 复制必要文件 | |
| 14 | +$files = @( | |
| 15 | + ".env", | |
| 16 | + "composer.json", | |
| 17 | + "docker-compose.yml", | |
| 18 | + "Dockerfile", | |
| 19 | + "migrate.php", | |
| 20 | + "start.php" | |
| 21 | +) | |
| 22 | + | |
| 23 | +foreach ($file in $files) { | |
| 24 | + Copy-Item "$PSScriptRoot\$file" "$DeployDir\" -Force | |
| 25 | + Write-Host " + $file" -ForegroundColor Green | |
| 26 | +} | |
| 27 | + | |
| 28 | +# 创建部署说明 | |
| 29 | +$readme = @" | |
| 30 | +# Relay Server 部署指南 | |
| 31 | + | |
| 32 | +## 1. 上传整个目录到服务器 | |
| 33 | + scp -r . user@server:/opt/moltbot-relay | |
| 34 | + | |
| 35 | +## 2. 修改 .env 配置 | |
| 36 | + - DB_HOST: 数据库地址 | |
| 37 | + - DB_PASSWORD: 数据库密码 | |
| 38 | + - REDIS_HOST: Redis 地址(使用 docker 内部网络可设为 redis) | |
| 39 | + | |
| 40 | +## 3. 启动服务 | |
| 41 | + cd /opt/moltbot-relay | |
| 42 | + docker-compose up -d --build | |
| 43 | + | |
| 44 | +## 4. 查看日志 | |
| 45 | + docker-compose logs -f | |
| 46 | + | |
| 47 | +## 5. 开放防火墙 | |
| 48 | + firewall-cmd --permanent --add-port=8888/tcp | |
| 49 | + firewall-cmd --reload | |
| 50 | + | |
| 51 | +## 6. 修改本地插件配置 | |
| 52 | + 编辑 ~/.clawdbot/clawdbot.json: | |
| 53 | + { | |
| 54 | + "channels": { | |
| 55 | + "wechat": { | |
| 56 | + "enabled": true, | |
| 57 | + "relayUrl": "ws://服务器IP:8888" | |
| 58 | + } | |
| 59 | + } | |
| 60 | + } | |
| 61 | +"@ | |
| 62 | +$readme | Set-Content "$DeployDir\README.md" -Encoding UTF8 | |
| 63 | + | |
| 64 | +# 打包成 zip | |
| 65 | +if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } | |
| 66 | +Compress-Archive -Path "$DeployDir\*" -DestinationPath $ZipFile | |
| 67 | + | |
| 68 | +Write-Host "" | |
| 69 | +Write-Host "=== 打包完成 ===" -ForegroundColor Green | |
| 70 | +Write-Host "部署包: $ZipFile" -ForegroundColor Yellow | |
| 71 | +Write-Host "" | |
| 72 | +Write-Host "将 zip 文件上传到服务器后解压并运行 docker-compose up -d --build" -ForegroundColor White | |
| 73 | + | |
| 74 | +# 清理临时目录 | |
| 75 | +Remove-Item $DeployDir -Recurse -Force | ... | ... |
relay-server-deploy.zip
0 → 100644
No preview for this file type
start.php
0 → 100644
| 1 | +<?php | |
| 2 | +use Workerman\Worker; | |
| 3 | +use Workerman\Timer; | |
| 4 | + | |
| 5 | +require_once __DIR__ . '/vendor/autoload.php'; | |
| 6 | + | |
| 7 | +// Define Heartbeat Interval | |
| 8 | +define('HEARTBEAT_TIME', 30); | |
| 9 | + | |
| 10 | +// Create a WebSocket worker | |
| 11 | +$ws_worker = new Worker("websocket://0.0.0.0:8888"); | |
| 12 | + | |
| 13 | +// Emulate simple routing/state for now | |
| 14 | +// In production, use Redis for distributed state | |
| 15 | +$ws_worker->count = 1; // Single process for dev simplicity | |
| 16 | + | |
| 17 | +// Redis Connection | |
| 18 | +$redis = null; | |
| 19 | +try { | |
| 20 | + $redis_host = getenv('REDIS_HOST') ?: '127.0.0.1'; | |
| 21 | + $redis_port = getenv('REDIS_PORT') ?: 6379; | |
| 22 | + $redis_auth = getenv('REDIS_PASSWORD'); | |
| 23 | + $redis_prefix = getenv('REDIS_PREFIX') ?: 'ai_'; | |
| 24 | + | |
| 25 | + $params = [ | |
| 26 | + 'scheme' => 'tcp', | |
| 27 | + 'host' => $redis_host, | |
| 28 | + 'port' => $redis_port, | |
| 29 | + ]; | |
| 30 | + | |
| 31 | + if ($redis_auth) { | |
| 32 | + $params['password'] = $redis_auth; | |
| 33 | + } | |
| 34 | + | |
| 35 | + // Predis Client | |
| 36 | + $redis = new Predis\Client($params, ['prefix' => $redis_prefix]); | |
| 37 | + $redis->connect(); | |
| 38 | + echo "✅ Connected to Redis at $redis_host ($redis_prefix)\n"; | |
| 39 | +} catch (Exception $e) { | |
| 40 | + echo "⚠️ Redis Connection Failed: " . $e->getMessage() . "\n"; | |
| 41 | +} | |
| 42 | + | |
| 43 | +// Store connections (Memory for now, can move to Redis later) | |
| 44 | +$clients = []; // ClientID -> Connection | |
| 45 | +$devices = []; // DeviceID -> Connection | |
| 46 | + | |
| 47 | +$ws_worker->onWorkerStart = function ($worker) { | |
| 48 | + echo "Relay Server Started on 0.0.0.0:8888\n"; | |
| 49 | + | |
| 50 | + // Heartbeat check | |
| 51 | + Timer::add(10, function () use ($worker) { | |
| 52 | + $time_now = time(); | |
| 53 | + foreach ($worker->connections as $connection) { | |
| 54 | + // Check if connection is alive possibly? | |
| 55 | + // Workerman handles basic disconnects, but we can enforce ping logic here if needed | |
| 56 | + } | |
| 57 | + }); | |
| 58 | +}; | |
| 59 | + | |
| 60 | +$ws_worker->onConnect = function ($connection) { | |
| 61 | + echo "New connection: " . $connection->id . "\n"; | |
| 62 | + $connection->authVerified = false; | |
| 63 | +}; | |
| 64 | + | |
| 65 | +$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices) { | |
| 66 | + $msg = json_decode($data, true); | |
| 67 | + if (!$msg || !isset($msg['type'])) { | |
| 68 | + return; | |
| 69 | + } | |
| 70 | + | |
| 71 | + // 1. Authenticate / Register | |
| 72 | + if ($msg['type'] === 'register') { | |
| 73 | + if ($msg['role'] === 'device') { | |
| 74 | + // Device 注册 | |
| 75 | + $deviceId = $msg['id']; // TODO: Add secret validation | |
| 76 | + $devices[$deviceId] = $connection; | |
| 77 | + $connection->deviceId = $deviceId; | |
| 78 | + $connection->role = 'device'; | |
| 79 | + $connection->authVerified = true; | |
| 80 | + $connection->send(json_encode(['type' => 'ack', 'status' => 'registered'])); | |
| 81 | + echo "Device Registered: $deviceId\n"; | |
| 82 | + } elseif ($msg['role'] === 'client') { | |
| 83 | + // Mini Program 注册 | |
| 84 | + // TODO: Validate Token | |
| 85 | + $clientId = $msg['id']; | |
| 86 | + $clients[$clientId] = $connection; | |
| 87 | + $connection->clientId = $clientId; | |
| 88 | + $connection->role = 'client'; | |
| 89 | + $connection->authVerified = true; | |
| 90 | + $connection->send(json_encode(['type' => 'ack', 'status' => 'connected'])); | |
| 91 | + echo "Client Connected: $clientId\n"; | |
| 92 | + } | |
| 93 | + return; | |
| 94 | + } | |
| 95 | + | |
| 96 | + if (!$connection->authVerified) { | |
| 97 | + $connection->close(); | |
| 98 | + return; | |
| 99 | + } | |
| 100 | + | |
| 101 | + // 2. Proxy Logic | |
| 102 | + | |
| 103 | + // Client -> Device | |
| 104 | + if ($msg['type'] === 'proxy' && $connection->role === 'client') { | |
| 105 | + $targetDeviceId = $msg['targetDeviceId'] ?? null; | |
| 106 | + if ($targetDeviceId && isset($devices[$targetDeviceId])) { | |
| 107 | + $payload = $msg['payload']; | |
| 108 | + // Wrap it so device knows who sent it | |
| 109 | + $forwardMsg = [ | |
| 110 | + 'type' => 'cmd:execute', | |
| 111 | + 'fromClientId' => $connection->clientId, | |
| 112 | + 'payload' => $payload | |
| 113 | + ]; | |
| 114 | + $devices[$targetDeviceId]->send(json_encode($forwardMsg)); | |
| 115 | + echo "Forwarded msg from Client {$connection->clientId} to Device {$targetDeviceId}\n"; | |
| 116 | + } else { | |
| 117 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'Device offline or not found'])); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + // Device -> Client | |
| 122 | + if ($msg['type'] === 'proxy_response' && $connection->role === 'device') { | |
| 123 | + $targetClientId = $msg['targetClientId'] ?? null; | |
| 124 | + if ($targetClientId && isset($clients[$targetClientId])) { | |
| 125 | + $payload = $msg['payload']; | |
| 126 | + $forwardMsg = [ | |
| 127 | + 'type' => 'response', | |
| 128 | + 'fromDeviceId' => $connection->deviceId, | |
| 129 | + 'payload' => $payload | |
| 130 | + ]; | |
| 131 | + $clients[$targetClientId]->send(json_encode($forwardMsg)); | |
| 132 | + echo "Forwarded response from Device {$connection->deviceId} to Client {$targetClientId}\n"; | |
| 133 | + } | |
| 134 | + } | |
| 135 | +}; | |
| 136 | + | |
| 137 | +$ws_worker->onClose = function ($connection) use (&$clients, &$devices) { | |
| 138 | + if (isset($connection->role)) { | |
| 139 | + if ($connection->role === 'device' && isset($connection->deviceId)) { | |
| 140 | + unset($devices[$connection->deviceId]); | |
| 141 | + echo "Device disconnected: {$connection->deviceId}\n"; | |
| 142 | + } elseif ($connection->role === 'client' && isset($connection->clientId)) { | |
| 143 | + unset($clients[$connection->clientId]); | |
| 144 | + echo "Client disconnected: {$connection->clientId}\n"; | |
| 145 | + } | |
| 146 | + } | |
| 147 | +}; | |
| 148 | + | |
| 149 | +Worker::runAll(); | ... | ... |
Please
register
or
login
to post a comment