Commit 47820191eaa5b2806d86055eb0111b6b26425d20

Authored by 谭苏航
1 parent 2154352e

refactor: use phpdotenv for wechat config

Showing 3 changed files with 602 additions and 592 deletions
... ... @@ -28,3 +28,7 @@ REDIS_PREFIX=ai_
28 28 # 心跳配置
29 29 # HEARTBEAT_INTERVAL=30
30 30 # HEARTBEAT_CHECK_INTERVAL=10
  31 +
  32 +# 微信小程序配置
  33 +WECHAT_APP_ID=wx6daaa8a7e2889a50
  34 +WECHAT_APP_SECRET=d394d5f0f82b328a2754d53027370282
... ...
... ... @@ -7,8 +7,8 @@
7 7 return [
8 8 // 微信小程序配置
9 9 'wechat' => [
10   - 'app_id' => getenv('WECHAT_APP_ID') ?: 'wx6daaa8a7e2889a50',
11   - 'app_secret' => getenv('WECHAT_APP_SECRET') ?: 'd394d5f0f82b328a2754d53027370282',
  10 + 'app_id' => getenv('WECHAT_APP_ID') ?: '',
  11 + 'app_secret' => getenv('WECHAT_APP_SECRET') ?: '',
12 12 ],
13 13
14 14 // Session 配置
... ...
1   -<?php
2   -use Workerman\Worker;
3   -use Workerman\Timer;
4   -use Tos\TosClient;
5   -use Tos\Exception\TosClientException;
6   -use Tos\Exception\TosServerException;
7   -use Tos\Model\PutObjectInput;
8   -
9   -require_once __DIR__ . '/vendor/autoload.php';
10   -require_once __DIR__ . '/auth.php';
11   -require_once __DIR__ . '/device.php';
12   -
13   -// 加载配置文件
14   -$config = require __DIR__ . '/config.php';
15   -
16   -// Define Heartbeat Interval
17   -define('HEARTBEAT_TIME', $config['heartbeat']['interval']);
18   -
19   -// Database Connection
20   -$pdo = null;
21   -try {
22   - $db_config = $config['database'];
23   - $dsn = "mysql:host={$db_config['host']};port={$db_config['port']};dbname={$db_config['database']};charset=utf8mb4";
24   - $pdo = new PDO($dsn, $db_config['username'], $db_config['password'], [
25   - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
26   - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
27   - ]);
28   - echo " Connected to MySQL at {$db_config['host']}:{$db_config['port']}\n";
29   -} catch (Exception $e) {
30   - echo "⚠️ MySQL Connection Failed: " . $e->getMessage() . "\n";
31   -}
32   -
33   -// Redis Connection
34   -$redis = null;
35   -try {
36   - $redis_config = $config['redis'];
37   -
38   - $params = [
39   - 'scheme' => 'tcp',
40   - 'host' => $redis_config['host'],
41   - 'port' => $redis_config['port'],
42   - ];
43   -
44   - if (!empty($redis_config['password'])) {
45   - $params['password'] = $redis_config['password'];
46   - }
47   -
48   - // Predis Client
49   - $redis = new Predis\Client($params, ['prefix' => $redis_config['prefix']]);
50   - $redis->connect();
51   - echo " Connected to Redis at {$redis_config['host']}:{$redis_config['port']} ({$redis_config['prefix']})\n";
52   -} catch (Exception $e) {
53   - echo "⚠️ Redis Connection Failed: " . $e->getMessage() . "\n";
54   -}
55   -
56   -// Initialize Services
57   -$authService = $pdo ? new AuthService($pdo, $config, $redis) : null;
58   -$deviceService = $pdo ? new DeviceService($pdo, $config) : null;
59   -
60   -// Create a WebSocket worker
61   -$ws_host = $config['server']['websocket_host'];
62   -$ws_port = $config['server']['websocket_port'];
63   -$ws_worker = new Worker("websocket://{$ws_host}:{$ws_port}");
64   -
65   -// Store connections (Memory for now, can move to Redis later)
66   -$clients = []; // ClientID -> Connection
67   -$devices = []; // DeviceID -> Connection
68   -
69   -$ws_worker->onWorkerStart = function ($worker) use ($config) {
70   - $ws_host = $config['server']['websocket_host'];
71   - $ws_port = $config['server']['websocket_port'];
72   - echo "Relay Server Started on {$ws_host}:{$ws_port}\n";
73   -
74   - // Heartbeat check
75   - $check_interval = $config['heartbeat']['check_interval'];
76   - Timer::add($check_interval, function () use ($worker) {
77   - $time_now = time();
78   - foreach ($worker->connections as $connection) {
79   - // Check if connection is alive possibly?
80   - // Workerman handles basic disconnects, but we can enforce ping logic here if needed
81   - }
82   - });
83   -};
84   -
85   -$ws_worker->onConnect = function ($connection) {
86   - echo "New connection: " . $connection->id . "\n";
87   - $connection->authVerified = false;
88   -};
89   -
90   -$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices, $authService, $deviceService) {
91   - $msg = json_decode($data, true);
92   - if (!$msg || !isset($msg['type'])) {
93   - return;
94   - }
95   -
96   - // 1. Authenticate / Register
97   - if ($msg['type'] === 'register') {
98   - if ($msg['role'] === 'device') {
99   - // Device 注册 - 验证密钥
100   - $deviceId = $msg['id'] ?? null;
101   - $secret = $msg['secret'] ?? null;
102   -
103   - if (!$deviceId) {
104   - $connection->send(json_encode(['type' => 'error', 'msg' => 'Device ID required']));
105   - return;
106   - }
107   -
108   - // 验证设备(如果有 deviceService)
109   - if ($deviceService && $secret) {
110   - if (!$deviceService->verifyDevice($deviceId, $secret)) {
111   - $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid device credentials']));
112   - $connection->close();
113   - return;
114   - }
115   - // 更新设备状态为在线
116   - $deviceService->updateDeviceStatus($deviceId, 'online');
117   - }
118   -
119   - $devices[$deviceId] = $connection;
120   - $connection->deviceId = $deviceId;
121   - $connection->role = 'device';
122   - $connection->authVerified = true;
123   - $connection->send(json_encode(['type' => 'ack', 'status' => 'registered']));
124   - echo "Device Registered: $deviceId\n";
125   -
126   - } elseif ($msg['role'] === 'client') {
127   - // Mini Program 注册 - 验证 Token
128   - $clientId = $msg['id'] ?? null;
129   - $token = $msg['token'] ?? null;
130   -
131   - if (!$clientId) {
132   - $connection->send(json_encode(['type' => 'error', 'msg' => 'Client ID required']));
133   - return;
134   - }
135   -
136   - // 验证 Token(如果有 authService 和 token)
137   - $user = null;
138   - if ($authService && $token) {
139   - $user = $authService->verifyToken($token);
140   - if (!$user) {
141   - $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid or expired token']));
142   - $connection->close();
143   - return;
144   - }
145   - $connection->userId = $user['id'];
146   - $connection->userPhone = $user['phone'];
147   - }
148   -
149   - $clients[$clientId] = $connection;
150   - $connection->clientId = $clientId;
151   - $connection->role = 'client';
152   - $connection->authVerified = true;
153   - $connection->send(json_encode([
154   - 'type' => 'ack',
155   - 'status' => 'connected',
156   - 'user' => $user
157   - ]));
158   - echo "Client Connected: $clientId" . ($user ? " (User: {$user['phone']})" : "") . "\n";
159   - }
160   - return;
161   - }
162   -
163   - if (!$connection->authVerified) {
164   - $connection->close();
165   - return;
166   - }
167   -
168   - // 2. Proxy Logic
169   -
170   - // Client -> Device
171   - if ($msg['type'] === 'proxy' && $connection->role === 'client') {
172   - $targetDeviceId = $msg['targetDeviceId'] ?? null;
173   -
174   - // 验证用户是否有权限访问该设备
175   - if ($deviceService && isset($connection->userId)) {
176   - if (!$deviceService->canAccessDevice($connection->userId, $targetDeviceId)) {
177   - $connection->send(json_encode(['type' => 'error', 'msg' => 'No permission to access this device']));
178   - return;
179   - }
180   - }
181   -
182   - if ($targetDeviceId && isset($devices[$targetDeviceId])) {
183   - $payload = $msg['payload'];
184   - // Wrap it so device knows who sent it
185   - $forwardMsg = [
186   - 'type' => 'cmd:execute',
187   - 'fromClientId' => $connection->clientId,
188   - 'fromUserId' => $connection->userId ?? null,
189   - 'payload' => $payload
190   - ];
191   - $devices[$targetDeviceId]->send(json_encode($forwardMsg));
192   - echo "Forwarded msg from Client {$connection->clientId} to Device {$targetDeviceId}\n";
193   - } else {
194   - $connection->send(json_encode(['type' => 'error', 'msg' => 'Device offline or not found']));
195   - }
196   - }
197   -
198   - // Device -> Client
199   - if ($msg['type'] === 'proxy_response' && $connection->role === 'device') {
200   - $targetClientId = $msg['targetClientId'] ?? null;
201   - if ($targetClientId && isset($clients[$targetClientId])) {
202   - $payload = $msg['payload'];
203   - $forwardMsg = [
204   - 'type' => 'response',
205   - 'fromDeviceId' => $connection->deviceId,
206   - 'payload' => $payload
207   - ];
208   - $clients[$targetClientId]->send(json_encode($forwardMsg));
209   - echo "Forwarded response from Device {$connection->deviceId} to Client {$targetClientId}\n";
210   - }
211   - }
212   -};
213   -
214   -$ws_worker->onClose = function ($connection) use (&$clients, &$devices, $deviceService) {
215   - if (isset($connection->role)) {
216   - if ($connection->role === 'device' && isset($connection->deviceId)) {
217   - unset($devices[$connection->deviceId]);
218   - // 更新设备状态为离线
219   - if ($deviceService) {
220   - $deviceService->updateDeviceStatus($connection->deviceId, 'offline');
221   - }
222   - echo "Device disconnected: {$connection->deviceId}\n";
223   - } elseif ($connection->role === 'client' && isset($connection->clientId)) {
224   - unset($clients[$connection->clientId]);
225   - echo "Client disconnected: {$connection->clientId}\n";
226   - }
227   - }
228   -};
229   -
230   -// ---------------------------------------------------------
231   -// [New] HTTP Server for file uploads and static serving
232   -// ---------------------------------------------------------
233   -$http_host = $config['server']['http_host'];
234   -$http_port = $config['server']['http_port'];
235   -$http_worker = new Worker("http://{$http_host}:{$http_port}");
236   -$http_worker->count = $config['server']['worker_count'];
237   -$http_worker->onMessage = function ($connection, $request) use ($config, $authService, $deviceService) {
238   - $path = $request->path();
239   - $method = $request->method();
240   -
241   - // Helper: JSON Response
242   - $jsonResponse = function ($data, $status = 200) use ($connection) {
243   - $connection->send(new \Workerman\Protocols\Http\Response(
244   - $status,
245   - ['Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*'],
246   - json_encode($data)
247   - ));
248   - };
249   -
250   - // CORS Preflight
251   - if ($method === 'OPTIONS') {
252   - $connection->send(new \Workerman\Protocols\Http\Response(200, [
253   - 'Access-Control-Allow-Origin' => '*',
254   - 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
255   - 'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
256   - ], ''));
257   - return;
258   - }
259   -
260   - // ==================== Auth API ====================
261   -
262   - // POST /api/auth/login - 微信登录
263   - if ($path === '/api/auth/login' && $method === 'POST') {
264   - if (!$authService) {
265   - $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
266   - return;
267   - }
268   -
269   - $body = json_decode($request->rawBody(), true) ?: [];
270   - $code = $body['code'] ?? null;
271   - $phone = $body['phone'] ?? null;
272   - $phoneCode = $body['phoneCode'] ?? null;
273   -
274   - if (!$code) {
275   - $jsonResponse(['ok' => false, 'error' => 'code is required'], 400);
276   - return;
277   - }
278   -
279   - $result = $authService->wechatLogin($code, $phone, $phoneCode);
280   - $jsonResponse($result, $result['ok'] ? 200 : 400);
281   - return;
282   - }
283   -
284   - // POST /api/auth/verify - 验证 Token
285   - if ($path === '/api/auth/verify' && $method === 'POST') {
286   - if (!$authService) {
287   - $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
288   - return;
289   - }
290   -
291   - $body = json_decode($request->rawBody(), true) ?: [];
292   - $token = $body['token'] ?? null;
293   -
294   - if (!$token) {
295   - $jsonResponse(['ok' => false, 'error' => 'token is required'], 400);
296   - return;
297   - }
298   -
299   - $user = $authService->verifyToken($token);
300   - if ($user) {
301   - $jsonResponse(['ok' => true, 'user' => $user]);
302   - } else {
303   - $jsonResponse(['ok' => false, 'error' => 'Invalid or expired token'], 401);
304   - }
305   - return;
306   - }
307   -
308   - // POST /api/auth/logout - 注销
309   - if ($path === '/api/auth/logout' && $method === 'POST') {
310   - if (!$authService) {
311   - $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
312   - return;
313   - }
314   -
315   - $body = json_decode($request->rawBody(), true) ?: [];
316   - $token = $body['token'] ?? null;
317   -
318   - if ($token) {
319   - $authService->logout($token);
320   - }
321   - $jsonResponse(['ok' => true]);
322   - return;
323   - }
324   -
325   - // ==================== Device API ====================
326   -
327   - // Helper: Get user from token
328   - $getUser = function () use ($request, $authService) {
329   - $authHeader = $request->header('Authorization');
330   - if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
331   - return null;
332   - }
333   - $token = substr($authHeader, 7);
334   - return $authService ? $authService->verifyToken($token) : null;
335   - };
336   -
337   - // POST /api/device/bind - 绑定设备
338   - if ($path === '/api/device/bind' && $method === 'POST') {
339   - if (!$deviceService) {
340   - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
341   - return;
342   - }
343   -
344   - $user = $getUser();
345   - if (!$user) {
346   - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
347   - return;
348   - }
349   -
350   - $body = json_decode($request->rawBody(), true) ?: [];
351   - $deviceId = $body['deviceId'] ?? null;
352   - $deviceSecret = $body['deviceSecret'] ?? null;
353   -
354   - if (!$deviceId) {
355   - $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
356   - return;
357   - }
358   -
359   - $result = $deviceService->bindDevice($user['id'], $deviceId, $deviceSecret);
360   - $jsonResponse($result, $result['ok'] ? 200 : 400);
361   - return;
362   - }
363   -
364   - // POST /api/device/unbind - 解绑设备
365   - if ($path === '/api/device/unbind' && $method === 'POST') {
366   - if (!$deviceService) {
367   - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
368   - return;
369   - }
370   -
371   - $user = $getUser();
372   - if (!$user) {
373   - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
374   - return;
375   - }
376   -
377   - $body = json_decode($request->rawBody(), true) ?: [];
378   - $deviceId = $body['deviceId'] ?? null;
379   -
380   - if (!$deviceId) {
381   - $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
382   - return;
383   - }
384   -
385   - $result = $deviceService->unbindDevice($user['id'], $deviceId);
386   - $jsonResponse($result, $result['ok'] ? 200 : 400);
387   - return;
388   - }
389   -
390   - // GET /api/device/list - 获取绑定的设备列表
391   - if ($path === '/api/device/list' && $method === 'GET') {
392   - if (!$deviceService) {
393   - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
394   - return;
395   - }
396   -
397   - $user = $getUser();
398   - if (!$user) {
399   - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
400   - return;
401   - }
402   -
403   - $devices = $deviceService->getUserDevices($user['id']);
404   - $jsonResponse(['ok' => true, 'devices' => $devices]);
405   - return;
406   - }
407   -
408   - // POST /api/device/primary - 设置主设备
409   - if ($path === '/api/device/primary' && $method === 'POST') {
410   - if (!$deviceService) {
411   - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
412   - return;
413   - }
414   -
415   - $user = $getUser();
416   - if (!$user) {
417   - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
418   - return;
419   - }
420   -
421   - $body = json_decode($request->rawBody(), true) ?: [];
422   - $deviceId = $body['deviceId'] ?? null;
423   -
424   - if (!$deviceId) {
425   - $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
426   - return;
427   - }
428   -
429   - $result = $deviceService->setPrimaryDevice($user['id'], $deviceId);
430   - $jsonResponse($result, $result['ok'] ? 200 : 400);
431   - return;
432   - }
433   -
434   - // ==================== File API ====================
435   -
436   - // 1. Static File Serving (Simple implementation)
437   - if (strpos($path, '/uploads/') === 0) {
438   - $file = __DIR__ . $path;
439   - if (is_file($file)) {
440   - $connection->send(new \Workerman\Protocols\Http\Response(
441   - 200,
442   - ['Content-Type' => mime_content_type($file)],
443   - file_get_contents($file)
444   - ));
445   - return;
446   - }
447   - }
448   -
449   - // 2. Upload Handler
450   - if ($path === '/upload') {
451   - $files = $request->file();
452   -
453   - if (empty($files['file'])) {
454   - $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'No file'])));
455   - return;
456   - }
457   -
458   - $file = $files['file'];
459   -
460   - // Validate Size
461   - $max_size = $config['upload']['max_size'];
462   - if ($file['size'] > $max_size) {
463   - $max_mb = round($max_size / 1024 / 1024);
464   - $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => "File too large (Max {$max_mb}MB)"])));
465   - return;
466   - }
467   -
468   - // Validate Extension
469   - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
470   - $allowed = $config['upload']['allowed_extensions'];
471   - if (!in_array($ext, $allowed)) {
472   - $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'File type not allowed'])));
473   - return;
474   - }
475   -
476   - // TOS Configuration
477   - $tos_config = $config['tos'];
478   -
479   - try {
480   - $client = new TosClient([
481   - 'region' => $tos_config['region'],
482   - 'endpoint' => $tos_config['endpoint'],
483   - 'ak' => $tos_config['ak'],
484   - 'sk' => $tos_config['sk'],
485   - ]);
486   -
487   - // Generate Key
488   - $uuid = bin2hex(random_bytes(8));
489   - $userPhone = $config['upload']['default_user'];
490   - $objectKey = "clawdbot/{$userPhone}/{$uuid}.{$ext}";
491   - $bucket = $tos_config['bucket'];
492   -
493   - // Read file content
494   - $contentFn = fopen($file['tmp_name'], 'r');
495   -
496   - // Upload using Object Input
497   - $input = new PutObjectInput($bucket, $objectKey, $contentFn);
498   - $input->setACL('public-read');
499   -
500   - $client->putObject($input);
501   -
502   - if (is_resource($contentFn)) {
503   - fclose($contentFn);
504   - }
505   -
506   - // Generate URL
507   - $url = "https://{$bucket}.{$tos_config['endpoint']}/{$objectKey}";
508   -
509   - $connection->send(json_encode([
510   - 'ok' => true,
511   - 'url' => $url
512   - ]));
513   -
514   - } catch (Exception $e) {
515   - $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => 'Upload failed: ' . $e->getMessage()])));
516   - }
517   - return;
518   - }
519   -
520   - // 3. Generate Pre-Signed URL for Direct Upload
521   - if (strpos($path, '/tos/sign') === 0) {
522   - $query = $request->get();
523   - $filename = $query['filename'] ?? 'file_' . time();
524   - $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
525   -
526   - // Validation
527   - $allowed = $config['upload']['allowed_extensions'];
528   - if (!in_array($ext, $allowed) && $ext !== '') {
529   - // If no extension providing, we might just allow it or fail.
530   - // Ideally client should provide full filename with extension.
531   - }
532   -
533   - // TOS Configuration
534   - $tos_config = $config['tos'];
535   -
536   - try {
537   - $client = new TosClient([
538   - 'region' => $tos_config['region'],
539   - 'endpoint' => $tos_config['endpoint'],
540   - 'ak' => $tos_config['ak'],
541   - 'sk' => $tos_config['sk'],
542   - ]);
543   -
544   - $uuid = bin2hex(random_bytes(8));
545   - $userPhone = $config['upload']['default_user'];
546   - // Ensure filename is safe
547   - $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
548   - if (!$safeName)
549   - $safeName = 'unnamed';
550   -
551   - $objectKey = "clawdbot/{$userPhone}/direct_{$uuid}_{$safeName}";
552   - $bucket = $tos_config['bucket'];
553   -
554   - // Generate Pre-Signed PUT URL (Valid for 15 mins)
555   - // Note: The SDK method name might vary slightly based on version,
556   - // but `preSignedURL` is standard for TOS PHP SDK v2.
557   - $input = new \Tos\Model\PreSignedURLInput(
558   - 'PUT',
559   - $tos_config['bucket'],
560   - $objectKey,
561   - 300 // 5 minutes validity
562   - );
563   -
564   - // Add content-type if known? client will send it.
565   - // For simple PUT, we just sign the method and resource.
566   -
567   - $output = $client->preSignedURL($input);
568   - $signedUrl = $output->getSignedUrl();
569   -
570   - // Public Access URL (Assuming bucket is public-read or we use signed Get URL)
571   - // For this project, we used public-read ACL in previous code, so we assume public access.
572   - $publicUrl = "https://{$tos_config['bucket']}.{$tos_config['endpoint']}/{$objectKey}";
573   -
574   - $connection->send(json_encode([
575   - 'ok' => true,
576   - 'uploadUrl' => $signedUrl,
577   - 'publicUrl' => $publicUrl,
578   - 'key' => $objectKey
579   - ]));
580   -
581   - } catch (Exception $e) {
582   - $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => $e->getMessage()])));
583   - }
584   - return;
585   - }
586   -
587   - $connection->send("Moltbot Relay HTTP Server");
588   -};
589   -
590   -Worker::runAll();
  1 +<?php
  2 +use Workerman\Worker;
  3 +use Workerman\Timer;
  4 +use Tos\TosClient;
  5 +use Tos\Exception\TosClientException;
  6 +use Tos\Exception\TosServerException;
  7 +use Tos\Model\PutObjectInput;
  8 +
  9 +require_once __DIR__ . '/vendor/autoload.php';
  10 +require_once __DIR__ . '/auth.php';
  11 +require_once __DIR__ . '/device.php';
  12 +
  13 +// Load .env
  14 +if (class_exists('Dotenv\Dotenv') && file_exists(__DIR__ . '/.env')) {
  15 + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
  16 + $dotenv->load();
  17 +}
  18 +
  19 +// 加载配置文件
  20 +$config = require __DIR__ . '/config.php';
  21 +
  22 +// Define Heartbeat Interval
  23 +define('HEARTBEAT_TIME', $config['heartbeat']['interval']);
  24 +
  25 +// Database Connection
  26 +$pdo = null;
  27 +try {
  28 + $db_config = $config['database'];
  29 + $dsn = "mysql:host={$db_config['host']};port={$db_config['port']};dbname={$db_config['database']};charset=utf8mb4";
  30 + $pdo = new PDO($dsn, $db_config['username'], $db_config['password'], [
  31 + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  32 + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
  33 + ]);
  34 + echo " Connected to MySQL at {$db_config['host']}:{$db_config['port']}\n";
  35 +} catch (Exception $e) {
  36 + echo "⚠️ MySQL Connection Failed: " . $e->getMessage() . "\n";
  37 +}
  38 +
  39 +// Redis Connection
  40 +$redis = null;
  41 +try {
  42 + $redis_config = $config['redis'];
  43 +
  44 + $params = [
  45 + 'scheme' => 'tcp',
  46 + 'host' => $redis_config['host'],
  47 + 'port' => $redis_config['port'],
  48 + ];
  49 +
  50 + if (!empty($redis_config['password'])) {
  51 + $params['password'] = $redis_config['password'];
  52 + }
  53 +
  54 + // Predis Client
  55 + $redis = new Predis\Client($params, ['prefix' => $redis_config['prefix']]);
  56 + $redis->connect();
  57 + echo " Connected to Redis at {$redis_config['host']}:{$redis_config['port']} ({$redis_config['prefix']})\n";
  58 +} catch (Exception $e) {
  59 + echo "⚠️ Redis Connection Failed: " . $e->getMessage() . "\n";
  60 +}
  61 +
  62 +// Initialize Services
  63 +$authService = $pdo ? new AuthService($pdo, $config, $redis) : null;
  64 +$deviceService = $pdo ? new DeviceService($pdo, $config) : null;
  65 +
  66 +// Create a WebSocket worker
  67 +$ws_host = $config['server']['websocket_host'];
  68 +$ws_port = $config['server']['websocket_port'];
  69 +$ws_worker = new Worker("websocket://{$ws_host}:{$ws_port}");
  70 +
  71 +// Store connections (Memory for now, can move to Redis later)
  72 +$clients = []; // ClientID -> Connection
  73 +$devices = []; // DeviceID -> Connection
  74 +
  75 +$ws_worker->onWorkerStart = function ($worker) use ($config) {
  76 + $ws_host = $config['server']['websocket_host'];
  77 + $ws_port = $config['server']['websocket_port'];
  78 + echo "Relay Server Started on {$ws_host}:{$ws_port}\n";
  79 +
  80 + // Heartbeat check
  81 + $check_interval = $config['heartbeat']['check_interval'];
  82 + Timer::add($check_interval, function () use ($worker) {
  83 + $time_now = time();
  84 + foreach ($worker->connections as $connection) {
  85 + // Check if connection is alive possibly?
  86 + // Workerman handles basic disconnects, but we can enforce ping logic here if needed
  87 + }
  88 + });
  89 +};
  90 +
  91 +$ws_worker->onConnect = function ($connection) {
  92 + echo "New connection: " . $connection->id . "\n";
  93 + $connection->authVerified = false;
  94 +};
  95 +
  96 +$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices, $authService, $deviceService) {
  97 + $msg = json_decode($data, true);
  98 + if (!$msg || !isset($msg['type'])) {
  99 + return;
  100 + }
  101 +
  102 + // 1. Authenticate / Register
  103 + if ($msg['type'] === 'register') {
  104 + if ($msg['role'] === 'device') {
  105 + // Device 注册 - 验证密钥
  106 + $deviceId = $msg['id'] ?? null;
  107 + $secret = $msg['secret'] ?? null;
  108 +
  109 + if (!$deviceId) {
  110 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Device ID required']));
  111 + return;
  112 + }
  113 +
  114 + // 验证设备(如果有 deviceService)
  115 + if ($deviceService && $secret) {
  116 + if (!$deviceService->verifyDevice($deviceId, $secret)) {
  117 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid device credentials']));
  118 + $connection->close();
  119 + return;
  120 + }
  121 + // 更新设备状态为在线
  122 + $deviceService->updateDeviceStatus($deviceId, 'online');
  123 + }
  124 +
  125 + $devices[$deviceId] = $connection;
  126 + $connection->deviceId = $deviceId;
  127 + $connection->role = 'device';
  128 + $connection->authVerified = true;
  129 + $connection->send(json_encode(['type' => 'ack', 'status' => 'registered']));
  130 + echo "Device Registered: $deviceId\n";
  131 +
  132 + } elseif ($msg['role'] === 'client') {
  133 + // Mini Program 注册 - 验证 Token
  134 + $clientId = $msg['id'] ?? null;
  135 + $token = $msg['token'] ?? null;
  136 +
  137 + if (!$clientId) {
  138 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Client ID required']));
  139 + return;
  140 + }
  141 +
  142 + // 验证 Token(如果有 authService 和 token)
  143 + $user = null;
  144 + if ($authService && $token) {
  145 + $user = $authService->verifyToken($token);
  146 + if (!$user) {
  147 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid or expired token']));
  148 + $connection->close();
  149 + return;
  150 + }
  151 + $connection->userId = $user['id'];
  152 + $connection->userPhone = $user['phone'];
  153 + }
  154 +
  155 + $clients[$clientId] = $connection;
  156 + $connection->clientId = $clientId;
  157 + $connection->role = 'client';
  158 + $connection->authVerified = true;
  159 + $connection->send(json_encode([
  160 + 'type' => 'ack',
  161 + 'status' => 'connected',
  162 + 'user' => $user
  163 + ]));
  164 + echo "Client Connected: $clientId" . ($user ? " (User: {$user['phone']})" : "") . "\n";
  165 + }
  166 + return;
  167 + }
  168 +
  169 + if (!$connection->authVerified) {
  170 + $connection->close();
  171 + return;
  172 + }
  173 +
  174 + // 2. Proxy Logic
  175 +
  176 + // Client -> Device
  177 + if ($msg['type'] === 'proxy' && $connection->role === 'client') {
  178 + $targetDeviceId = $msg['targetDeviceId'] ?? null;
  179 +
  180 + // 验证用户是否有权限访问该设备
  181 + if ($deviceService && isset($connection->userId)) {
  182 + if (!$deviceService->canAccessDevice($connection->userId, $targetDeviceId)) {
  183 + $connection->send(json_encode(['type' => 'error', 'msg' => 'No permission to access this device']));
  184 + return;
  185 + }
  186 + }
  187 +
  188 + if ($targetDeviceId && isset($devices[$targetDeviceId])) {
  189 + $payload = $msg['payload'];
  190 + // Wrap it so device knows who sent it
  191 + $forwardMsg = [
  192 + 'type' => 'cmd:execute',
  193 + 'fromClientId' => $connection->clientId,
  194 + 'fromUserId' => $connection->userId ?? null,
  195 + 'payload' => $payload
  196 + ];
  197 + $devices[$targetDeviceId]->send(json_encode($forwardMsg));
  198 + echo "Forwarded msg from Client {$connection->clientId} to Device {$targetDeviceId}\n";
  199 + } else {
  200 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Device offline or not found']));
  201 + }
  202 + }
  203 +
  204 + // Device -> Client
  205 + if ($msg['type'] === 'proxy_response' && $connection->role === 'device') {
  206 + $targetClientId = $msg['targetClientId'] ?? null;
  207 + if ($targetClientId && isset($clients[$targetClientId])) {
  208 + $payload = $msg['payload'];
  209 + $forwardMsg = [
  210 + 'type' => 'response',
  211 + 'fromDeviceId' => $connection->deviceId,
  212 + 'payload' => $payload
  213 + ];
  214 + $clients[$targetClientId]->send(json_encode($forwardMsg));
  215 + echo "Forwarded response from Device {$connection->deviceId} to Client {$targetClientId}\n";
  216 + }
  217 + }
  218 +};
  219 +
  220 +$ws_worker->onClose = function ($connection) use (&$clients, &$devices, $deviceService) {
  221 + if (isset($connection->role)) {
  222 + if ($connection->role === 'device' && isset($connection->deviceId)) {
  223 + unset($devices[$connection->deviceId]);
  224 + // 更新设备状态为离线
  225 + if ($deviceService) {
  226 + $deviceService->updateDeviceStatus($connection->deviceId, 'offline');
  227 + }
  228 + echo "Device disconnected: {$connection->deviceId}\n";
  229 + } elseif ($connection->role === 'client' && isset($connection->clientId)) {
  230 + unset($clients[$connection->clientId]);
  231 + echo "Client disconnected: {$connection->clientId}\n";
  232 + }
  233 + }
  234 +};
  235 +
  236 +// ---------------------------------------------------------
  237 +// [New] HTTP Server for file uploads and static serving
  238 +// ---------------------------------------------------------
  239 +$http_host = $config['server']['http_host'];
  240 +$http_port = $config['server']['http_port'];
  241 +$http_worker = new Worker("http://{$http_host}:{$http_port}");
  242 +$http_worker->count = $config['server']['worker_count'];
  243 +$http_worker->onMessage = function ($connection, $request) use ($config, $authService, $deviceService) {
  244 + $path = $request->path();
  245 + $method = $request->method();
  246 +
  247 + // Helper: JSON Response
  248 + $jsonResponse = function ($data, $status = 200) use ($connection) {
  249 + $connection->send(new \Workerman\Protocols\Http\Response(
  250 + $status,
  251 + ['Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*'],
  252 + json_encode($data)
  253 + ));
  254 + };
  255 +
  256 + // CORS Preflight
  257 + if ($method === 'OPTIONS') {
  258 + $connection->send(new \Workerman\Protocols\Http\Response(200, [
  259 + 'Access-Control-Allow-Origin' => '*',
  260 + 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
  261 + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
  262 + ], ''));
  263 + return;
  264 + }
  265 +
  266 + // ==================== Auth API ====================
  267 +
  268 + // POST /api/auth/login - 微信登录
  269 + if ($path === '/api/auth/login' && $method === 'POST') {
  270 + if (!$authService) {
  271 + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
  272 + return;
  273 + }
  274 +
  275 + $body = json_decode($request->rawBody(), true) ?: [];
  276 + $code = $body['code'] ?? null;
  277 + $phone = $body['phone'] ?? null;
  278 + $phoneCode = $body['phoneCode'] ?? null;
  279 +
  280 + if (!$code) {
  281 + $jsonResponse(['ok' => false, 'error' => 'code is required'], 400);
  282 + return;
  283 + }
  284 +
  285 + $result = $authService->wechatLogin($code, $phone, $phoneCode);
  286 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  287 + return;
  288 + }
  289 +
  290 + // POST /api/auth/verify - 验证 Token
  291 + if ($path === '/api/auth/verify' && $method === 'POST') {
  292 + if (!$authService) {
  293 + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
  294 + return;
  295 + }
  296 +
  297 + $body = json_decode($request->rawBody(), true) ?: [];
  298 + $token = $body['token'] ?? null;
  299 +
  300 + if (!$token) {
  301 + $jsonResponse(['ok' => false, 'error' => 'token is required'], 400);
  302 + return;
  303 + }
  304 +
  305 + $user = $authService->verifyToken($token);
  306 + if ($user) {
  307 + $jsonResponse(['ok' => true, 'user' => $user]);
  308 + } else {
  309 + $jsonResponse(['ok' => false, 'error' => 'Invalid or expired token'], 401);
  310 + }
  311 + return;
  312 + }
  313 +
  314 + // POST /api/auth/logout - 注销
  315 + if ($path === '/api/auth/logout' && $method === 'POST') {
  316 + if (!$authService) {
  317 + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
  318 + return;
  319 + }
  320 +
  321 + $body = json_decode($request->rawBody(), true) ?: [];
  322 + $token = $body['token'] ?? null;
  323 +
  324 + if ($token) {
  325 + $authService->logout($token);
  326 + }
  327 + $jsonResponse(['ok' => true]);
  328 + return;
  329 + }
  330 +
  331 + // ==================== Device API ====================
  332 +
  333 + // Helper: Get user from token
  334 + $getUser = function () use ($request, $authService) {
  335 + $authHeader = $request->header('Authorization');
  336 + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
  337 + return null;
  338 + }
  339 + $token = substr($authHeader, 7);
  340 + return $authService ? $authService->verifyToken($token) : null;
  341 + };
  342 +
  343 + // POST /api/device/bind - 绑定设备
  344 + if ($path === '/api/device/bind' && $method === 'POST') {
  345 + if (!$deviceService) {
  346 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  347 + return;
  348 + }
  349 +
  350 + $user = $getUser();
  351 + if (!$user) {
  352 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  353 + return;
  354 + }
  355 +
  356 + $body = json_decode($request->rawBody(), true) ?: [];
  357 + $deviceId = $body['deviceId'] ?? null;
  358 + $deviceSecret = $body['deviceSecret'] ?? null;
  359 +
  360 + if (!$deviceId) {
  361 + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
  362 + return;
  363 + }
  364 +
  365 + $result = $deviceService->bindDevice($user['id'], $deviceId, $deviceSecret);
  366 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  367 + return;
  368 + }
  369 +
  370 + // POST /api/device/unbind - 解绑设备
  371 + if ($path === '/api/device/unbind' && $method === 'POST') {
  372 + if (!$deviceService) {
  373 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  374 + return;
  375 + }
  376 +
  377 + $user = $getUser();
  378 + if (!$user) {
  379 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  380 + return;
  381 + }
  382 +
  383 + $body = json_decode($request->rawBody(), true) ?: [];
  384 + $deviceId = $body['deviceId'] ?? null;
  385 +
  386 + if (!$deviceId) {
  387 + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
  388 + return;
  389 + }
  390 +
  391 + $result = $deviceService->unbindDevice($user['id'], $deviceId);
  392 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  393 + return;
  394 + }
  395 +
  396 + // GET /api/device/list - 获取绑定的设备列表
  397 + if ($path === '/api/device/list' && $method === 'GET') {
  398 + if (!$deviceService) {
  399 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  400 + return;
  401 + }
  402 +
  403 + $user = $getUser();
  404 + if (!$user) {
  405 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  406 + return;
  407 + }
  408 +
  409 + $devices = $deviceService->getUserDevices($user['id']);
  410 + $jsonResponse(['ok' => true, 'devices' => $devices]);
  411 + return;
  412 + }
  413 +
  414 + // POST /api/device/primary - 设置主设备
  415 + if ($path === '/api/device/primary' && $method === 'POST') {
  416 + if (!$deviceService) {
  417 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  418 + return;
  419 + }
  420 +
  421 + $user = $getUser();
  422 + if (!$user) {
  423 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  424 + return;
  425 + }
  426 +
  427 + $body = json_decode($request->rawBody(), true) ?: [];
  428 + $deviceId = $body['deviceId'] ?? null;
  429 +
  430 + if (!$deviceId) {
  431 + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
  432 + return;
  433 + }
  434 +
  435 + $result = $deviceService->setPrimaryDevice($user['id'], $deviceId);
  436 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  437 + return;
  438 + }
  439 +
  440 + // ==================== File API ====================
  441 +
  442 + // 1. Static File Serving (Simple implementation)
  443 + if (strpos($path, '/uploads/') === 0) {
  444 + $file = __DIR__ . $path;
  445 + if (is_file($file)) {
  446 + $connection->send(new \Workerman\Protocols\Http\Response(
  447 + 200,
  448 + ['Content-Type' => mime_content_type($file)],
  449 + file_get_contents($file)
  450 + ));
  451 + return;
  452 + }
  453 + }
  454 +
  455 + // 2. Upload Handler
  456 + if ($path === '/upload') {
  457 + $files = $request->file();
  458 +
  459 + if (empty($files['file'])) {
  460 + $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'No file'])));
  461 + return;
  462 + }
  463 +
  464 + $file = $files['file'];
  465 +
  466 + // Validate Size
  467 + $max_size = $config['upload']['max_size'];
  468 + if ($file['size'] > $max_size) {
  469 + $max_mb = round($max_size / 1024 / 1024);
  470 + $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => "File too large (Max {$max_mb}MB)"])));
  471 + return;
  472 + }
  473 +
  474 + // Validate Extension
  475 + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
  476 + $allowed = $config['upload']['allowed_extensions'];
  477 + if (!in_array($ext, $allowed)) {
  478 + $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'File type not allowed'])));
  479 + return;
  480 + }
  481 +
  482 + // TOS Configuration
  483 + $tos_config = $config['tos'];
  484 +
  485 + try {
  486 + $client = new TosClient([
  487 + 'region' => $tos_config['region'],
  488 + 'endpoint' => $tos_config['endpoint'],
  489 + 'ak' => $tos_config['ak'],
  490 + 'sk' => $tos_config['sk'],
  491 + ]);
  492 +
  493 + // Generate Key
  494 + $uuid = bin2hex(random_bytes(8));
  495 + $userPhone = $config['upload']['default_user'];
  496 + $objectKey = "clawdbot/{$userPhone}/{$uuid}.{$ext}";
  497 + $bucket = $tos_config['bucket'];
  498 +
  499 + // Read file content
  500 + $contentFn = fopen($file['tmp_name'], 'r');
  501 +
  502 + // Upload using Object Input
  503 + $input = new PutObjectInput($bucket, $objectKey, $contentFn);
  504 + $input->setACL('public-read');
  505 +
  506 + $client->putObject($input);
  507 +
  508 + if (is_resource($contentFn)) {
  509 + fclose($contentFn);
  510 + }
  511 +
  512 + // Generate URL
  513 + $url = "https://{$bucket}.{$tos_config['endpoint']}/{$objectKey}";
  514 +
  515 + $connection->send(json_encode([
  516 + 'ok' => true,
  517 + 'url' => $url
  518 + ]));
  519 +
  520 + } catch (Exception $e) {
  521 + $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => 'Upload failed: ' . $e->getMessage()])));
  522 + }
  523 + return;
  524 + }
  525 +
  526 + // 3. Generate Pre-Signed URL for Direct Upload
  527 + if (strpos($path, '/tos/sign') === 0) {
  528 + $query = $request->get();
  529 + $filename = $query['filename'] ?? 'file_' . time();
  530 + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
  531 +
  532 + // Validation
  533 + $allowed = $config['upload']['allowed_extensions'];
  534 + if (!in_array($ext, $allowed) && $ext !== '') {
  535 + // If no extension providing, we might just allow it or fail.
  536 + // Ideally client should provide full filename with extension.
  537 + }
  538 +
  539 + // TOS Configuration
  540 + $tos_config = $config['tos'];
  541 +
  542 + try {
  543 + $client = new TosClient([
  544 + 'region' => $tos_config['region'],
  545 + 'endpoint' => $tos_config['endpoint'],
  546 + 'ak' => $tos_config['ak'],
  547 + 'sk' => $tos_config['sk'],
  548 + ]);
  549 +
  550 + $uuid = bin2hex(random_bytes(8));
  551 + $userPhone = $config['upload']['default_user'];
  552 + // Ensure filename is safe
  553 + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
  554 + if (!$safeName)
  555 + $safeName = 'unnamed';
  556 +
  557 + $objectKey = "clawdbot/{$userPhone}/direct_{$uuid}_{$safeName}";
  558 + $bucket = $tos_config['bucket'];
  559 +
  560 + // Generate Pre-Signed PUT URL (Valid for 15 mins)
  561 + // Note: The SDK method name might vary slightly based on version,
  562 + // but `preSignedURL` is standard for TOS PHP SDK v2.
  563 + $input = new \Tos\Model\PreSignedURLInput(
  564 + 'PUT',
  565 + $tos_config['bucket'],
  566 + $objectKey,
  567 + 300 // 5 minutes validity
  568 + );
  569 +
  570 + // Add content-type if known? client will send it.
  571 + // For simple PUT, we just sign the method and resource.
  572 +
  573 + $output = $client->preSignedURL($input);
  574 + $signedUrl = $output->getSignedUrl();
  575 +
  576 + // Public Access URL (Assuming bucket is public-read or we use signed Get URL)
  577 + // For this project, we used public-read ACL in previous code, so we assume public access.
  578 + $publicUrl = "https://{$tos_config['bucket']}.{$tos_config['endpoint']}/{$objectKey}";
  579 +
  580 + $connection->send(json_encode([
  581 + 'ok' => true,
  582 + 'uploadUrl' => $signedUrl,
  583 + 'publicUrl' => $publicUrl,
  584 + 'key' => $objectKey
  585 + ]));
  586 +
  587 + } catch (Exception $e) {
  588 + $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => $e->getMessage()])));
  589 + }
  590 + return;
  591 + }
  592 +
  593 + $connection->send("Moltbot Relay HTTP Server");
  594 +};
  595 +
  596 +Worker::runAll();
... ...
Please register or login to post a comment