├── README.md ├── composer.json └── src ├── BusinessException.php ├── Config.php ├── Controller ├── Base.php ├── Image.php └── Task.php ├── Discord.php ├── Install.php ├── Log.php ├── MessageHandler ├── Base.php ├── DescribeSuccess.php ├── Error.php ├── InteractionFailure.php ├── ModalCreateStart.php ├── Progress.php ├── Start.php ├── Success.php ├── UpscaleSuccess.php ├── VaryRegionProgress.php └── VaryRegionStart.php ├── Server.php ├── Service ├── Attachment.php └── Image.php ├── Task.php ├── TaskCondition.php ├── TaskStore ├── File.php └── TaskStoreInterface.php └── config └── plugin └── webman └── midjourney ├── app.php ├── banned-words.txt ├── log.php └── process.php /README.md: -------------------------------------------------------------------------------- 1 | # midjourney-proxy 2 | 全功能Midjourney Discord代理,支持Midjourney所有功能,高性能,稳定,免费。 3 | 4 | 如果喜欢,请给个Star⭐️,谢谢! 5 | 6 | ## 支持功能 7 | 8 | - [x] 支持 Imagine(画图) 9 | - [x] 支持 Imagine 时支持添加图片垫图 10 | - [x] 支持扩图 Pan ⬅️ ➡️ ⬆️ ⬇️ 11 | - [x] 支持扩图 ZoomOut 🔍 12 | - [x] 支持自定义扩图 Custom Zoom 🔍 13 | - [x] 支持局部重绘 Vary (Region) 🖌 14 | - [x] 支持 Make Square 15 | - [x] 支持任务实时进度 16 | - [x] 支持 Blend(图片混合) 17 | - [x] 支持 Describe(图生文) 18 | - [x] 支持账号池 19 | - [x] 支持禁用词设置 20 | - [x] 支持图片cdn替换 21 | 22 | ## 相关项目 23 | ![image](https://github.com/webman-php/midjourney-proxy/assets/6073368/2d249e52-5e2a-4ca3-b356-99ea95c238e1) 24 | 25 | 26 | 27 | [https://bla.cn](https://bla.cn/#module=painting) 28 | [https://jey.cn](https://jey.cn) 29 | [webman AI](https://www.workerman.net/app/view/ai) 30 | 31 | ## webman AI QQ2000人群 32 | ![image](https://github.com/webman-php/midjourney-proxy/assets/6073368/7b7aa50c-9f4b-4825-95a5-d034ce8f54fa) 33 | 34 | **QQ群 789898358** 35 | 36 | ## 文档 37 | [webman/midjourney](https://www.workerman.net/plugin/159) 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webman/midjourney", 3 | "type": "library", 4 | "license": "MIT", 5 | "description": "Webman plugin webman/midjourney", 6 | "require": { 7 | "workerman/http-client": "^2.1 || ^3.0", 8 | "jenssegers/agent": "^2.6" 9 | }, 10 | "autoload": { 11 | "psr-4": { 12 | "Webman\\Midjourney\\": "src" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/BusinessException.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | use Exception; 19 | 20 | class BusinessException extends Exception 21 | { 22 | public $banWord = ''; 23 | } -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | class Config 19 | { 20 | 21 | protected static $config = []; 22 | 23 | public static function get($key = null, $default = null) 24 | { 25 | if ($key === null) { 26 | return static::$config; 27 | } 28 | $keyArray = explode('.', $key); 29 | $value = static::$config; 30 | foreach ($keyArray as $index) { 31 | if (!isset($value[$index])) { 32 | return $default; 33 | } 34 | $value = $value[$index]; 35 | } 36 | return $value; 37 | } 38 | 39 | public static function init($config) 40 | { 41 | static::$config = $config; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Controller/Base.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\Controller; 17 | 18 | 19 | use Webman\Midjourney\BusinessException; 20 | use Webman\Midjourney\Discord; 21 | use Workerman\Protocols\Http\Response; 22 | 23 | class Base 24 | { 25 | /** 26 | * @param $taskId 27 | * @param array $data 28 | * @param int $code 29 | * @param string $msg 30 | * @return Response 31 | */ 32 | protected function json($taskId, array $data = [], int $code = 0, string $msg = 'ok'): Response 33 | { 34 | $data = [ 35 | 'code' => $code, 36 | 'msg' => $msg, 37 | 'taskId' => $taskId, 38 | 'data' => $data 39 | ]; 40 | return new Response(200, ['Content-Type' => 'application/json'], json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 41 | } 42 | 43 | /** 44 | * @param $images 45 | * @return bool 46 | */ 47 | protected function invalidImages($images): bool 48 | { 49 | if (!is_array($images)) { 50 | return false; 51 | } 52 | foreach ($images as $image) { 53 | if (!is_string($image) || !filter_var($image, FILTER_VALIDATE_URL)) { 54 | return false; 55 | } 56 | } 57 | return true; 58 | } 59 | 60 | /** 61 | * @param $request 62 | * @param ...$args 63 | * @return array 64 | * @throws BusinessException 65 | */ 66 | protected function input($request, ...$args): array 67 | { 68 | $input = []; 69 | foreach ($args as $arg) { 70 | $explode = explode('|', $arg); 71 | $field = $explode[0]; 72 | $required = in_array('required', $explode); 73 | $value = $request->post($field); 74 | if ($required && !$value) { 75 | throw new BusinessException($field . ' is required'); 76 | } 77 | switch ($field) { 78 | case 'prompt': 79 | $word = ''; 80 | if ($this->containBannedWords($value, $word)) { 81 | $exception = new BusinessException('出于政策隐私和安全的考虑,我们无法生成相关内容'); 82 | $exception->banWord = $word; 83 | throw $exception; 84 | } 85 | $input[] = $value ? preg_replace('/\s+/', ' ', $value) : $value; 86 | break; 87 | case 'images': 88 | $value = $value ?: []; 89 | if (!$this->invalidImages($value)) { 90 | throw new BusinessException('images is invalid'); 91 | } 92 | $input[] = $value; 93 | break; 94 | case 'dimensions': 95 | $value = $value ?: []; 96 | $dimensions = [ 97 | 'PORTRAIT' => Discord::DIMENSIONS_PORTRAIT, 98 | 'SQUARE' => Discord::DIMENSIONS_SQUARE, 99 | 'LANDSCAPE' => Discord::DIMENSIONS_LANDSCAPE, 100 | ]; 101 | if ($value && !isset($dimensions[$value])) { 102 | throw new BusinessException('dimensions is invalid'); 103 | } 104 | $input[] = $dimensions[$value]; 105 | break; 106 | case 'data': 107 | if ($value !== null && !is_array($value)) { 108 | throw new BusinessException('data is invalid'); 109 | } 110 | $input[] = $value; 111 | break; 112 | case 'customId': 113 | if (!is_string($value) || strpos($value, '::') === false) { 114 | throw new BusinessException('customId is invalid'); 115 | } 116 | $input[] = $value; 117 | break; 118 | case 'taskId': 119 | if (!is_string($value) || !preg_match('/\d{19}/', $value)) { 120 | throw new BusinessException('taskId is invalid'); 121 | } 122 | $input[] = $value; 123 | break; 124 | case 'mask': 125 | if ($value !== null && !is_string($value) || $value === '') { 126 | throw new BusinessException('mask is invalid'); 127 | } 128 | $input[] = $value; 129 | break; 130 | default: 131 | $input[] = $value; 132 | break; 133 | } 134 | } 135 | return $input; 136 | } 137 | 138 | /** 139 | * 包含禁用词 140 | * @param $prompt 141 | * @param string $word 142 | * @return bool 143 | */ 144 | protected function containBannedWords($prompt, string &$word = ''): bool 145 | { 146 | $bannedWordsFile = base_path('config/plugin/webman/midjourney/banned-words.txt'); 147 | if (!$prompt || !file_exists($bannedWordsFile)) { 148 | return false; 149 | } 150 | $bannedWords = file($bannedWordsFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 151 | foreach ($bannedWords as $bannedWord) { 152 | $pattern = '/\b' . preg_quote($bannedWord, '/') . '\b/'; 153 | if (preg_match($pattern, $prompt)) { 154 | $word = $bannedWord; 155 | return true; 156 | } 157 | } 158 | return false; 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /src/Controller/Image.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\Controller; 17 | 18 | use Webman\Midjourney\BusinessException; 19 | use Webman\Midjourney\Discord; 20 | use Webman\Midjourney\Task; 21 | use Workerman\Http\Client; 22 | use Workerman\Protocols\Http\Request; 23 | use Workerman\Protocols\Http\Response; 24 | 25 | class Image extends Base 26 | { 27 | 28 | /** 29 | * 画图 30 | * @param Request $request 31 | * @return Response 32 | * @throws BusinessException 33 | */ 34 | public function imagine(Request $request): Response 35 | { 36 | [$prompt, $notifyUrl, $images, $data] = $this->input($request, 'prompt|required', 'notifyUrl', 'images', 'data'); 37 | $task = new Task(Task::ACTION_IMAGINE); 38 | $task->images($images); 39 | $task->prompt($prompt); 40 | if ($notifyUrl) { 41 | $task->notifyUrl($notifyUrl); 42 | } 43 | if ($data) { 44 | $task->data($data); 45 | } 46 | $task->save(); 47 | Discord::submit($task); 48 | return $this->json($task->id()); 49 | } 50 | 51 | 52 | /** 53 | * 任务 54 | * @param Request $request 55 | * @return Response 56 | * @throws BusinessException 57 | */ 58 | public function action(Request $request): Response 59 | { 60 | [$taskId, $customId, $prompt, $mask, $notifyUrl, $data] = $this->input($request, 'taskId|required', 'customId|required', 'prompt', 'mask', 'notifyUrl', 'data'); 61 | if (!$task = Task::get($taskId)) { 62 | throw new BusinessException('任务不存在'); 63 | } 64 | $jobNames = [ 65 | 'MJ::JOB::upsample' => Task::ACTION_UPSCALE, 66 | 'MJ::JOB::variation' => Task::ACTION_VARIATION, 67 | 'MJ::JOB::reroll' => Task::ACTION_REROLL, 68 | 'MJ::Outpaint' => Task::ACTION_ZOOMOUT, 69 | 'MJ::JOB::pan_left' => Task::ACTION_PANLEFT, 70 | 'MJ::JOB::pan_right' => Task::ACTION_PANRIGHT, 71 | 'MJ::JOB::pan_up' => Task::ACTION_PANUP, 72 | 'MJ::JOB::pan_down' => Task::ACTION_PANDOWN, 73 | 'MJ::CustomZoom' => Task::ACITON_ZOOMOUT_CUSTOM, 74 | 'MJ::JOB::upsample_v5_2x' => Task::ACTION_UPSCALE_V5_2X, 75 | 'MJ::JOB::upsample_v5_4x' => Task::ACTION_UPSCALE_V5_4X, 76 | 'MJ::JOB::upsample_v6_2x_subtle' => Task::ACTION_UPSCALE_V6_2X_SUBTLE, 77 | 'MJ::JOB::upsample_v6_2x_creative' => Task::ACTION_UPSCALE_V6_2X_CREATIVE, 78 | 'MJ::JOB::low_variation' => Task::ACTION_VARIATION_SUBTLE, 79 | 'MJ::JOB::high_variation' => Task::ACTION_VARIATION_STRONG, 80 | 'MJ::Inpaint' => Task::ACTION_VARIATION_REGION, 81 | 'MJ::Job::PicReader' => Task::ACTION_PIC_READER, 82 | 'MJ::CancelJob::ByJobid' => Task::ACTION_CANCEL_JOB, 83 | ]; 84 | $action = ''; 85 | foreach ($jobNames as $jobName => $taskAction) { 86 | if (strpos($customId, $jobName) === 0) { 87 | $action = $taskAction; 88 | break; 89 | } 90 | } 91 | if (!$action) { 92 | throw new BusinessException('action not found'); 93 | } 94 | if ($action === Task::ACTION_VARIATION_REGION && !$mask) { 95 | throw new BusinessException('mask is required'); 96 | } 97 | $needPrompt = in_array($action, [Task::ACTION_PIC_READER, Task::ACITON_ZOOMOUT_CUSTOM, Task::ACTION_VARIATION_REGION]); 98 | if ($needPrompt && !$prompt) { 99 | throw new BusinessException('prompt is required'); 100 | } 101 | $prompt = $needPrompt ? $prompt : $task->prompt(); 102 | $newTask = new Task($action); 103 | $params = [ 104 | 'customId' => $customId, 105 | 'messageId' => $task->messageId() 106 | ]; 107 | if ($action === Task::ACTION_UPSCALE) { 108 | $items = explode('::', $customId); 109 | $params['index'] = $items[3] ?? 1; 110 | } 111 | if ($action === Task::ACTION_VARIATION_REGION) { 112 | $params['mask'] = $mask; 113 | } 114 | if ($notifyUrl) { 115 | $newTask->notifyUrl($notifyUrl); 116 | } 117 | if ($data) { 118 | $newTask->data($data); 119 | } 120 | $newTask->params($params); 121 | $newTask->discordId($task->discordId()); 122 | $newTask->prompt($prompt); 123 | $newTask->data(['uid' => time()]); 124 | $newTask->save(); 125 | Discord::submit($newTask); 126 | return $this->json($newTask->id()); 127 | } 128 | 129 | /** 130 | * 画图 131 | * @param Request $request 132 | * @return Response 133 | * @throws BusinessException 134 | */ 135 | public function blend(Request $request): Response 136 | { 137 | [$images, $notifyUrl, $data, $dimensions] = $this->input($request, 'images|required', 'notifyUrl', 'data', 'dimensions'); 138 | $task = new Task(Task::ACTION_BLEND); 139 | if ($dimensions) { 140 | $task->params(['dimensions' => $dimensions]); 141 | } 142 | $task->images($images); 143 | if ($notifyUrl) { 144 | $task->notifyUrl($notifyUrl); 145 | } 146 | if ($data) { 147 | $task->data($data); 148 | } 149 | $task->save(); 150 | Discord::submit($task); 151 | return $this->json($task->id()); 152 | } 153 | 154 | /** 155 | * @param Request $request 156 | * @return Response 157 | * @throws BusinessException 158 | */ 159 | public function describe(Request $request): Response 160 | { 161 | [$images, $notifyUrl, $data] = $this->input($request, 'images|required', 'notifyUrl', 'data'); 162 | $task = new Task(Task::ACTION_DESCRIBE); 163 | $task->images($images); 164 | if ($notifyUrl) { 165 | $task->notifyUrl($notifyUrl); 166 | } 167 | if ($data) { 168 | $task->data($data); 169 | } 170 | $task->save(); 171 | Discord::submit($task); 172 | return $this->json($task->id()); 173 | } 174 | 175 | } -------------------------------------------------------------------------------- /src/Controller/Task.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\Controller; 17 | 18 | use Webman\Midjourney\Task as TaskService; 19 | use Workerman\Protocols\Http\Request; 20 | use Workerman\Protocols\Http\Response; 21 | 22 | class Task 23 | { 24 | public function fetch(Request $request) 25 | { 26 | $id = $request->get('taskId'); 27 | if (!$id || !$task = TaskService::get($id)) { 28 | return new Response(200, [ 29 | 'Content-Type' => 'application/json' 30 | ], json_encode([ 31 | 'code' => 404, 32 | 'msg' => '未找到任务', 'data' => null 33 | ])); 34 | } 35 | return new Response(200, [ 36 | 'Content-Type' => 'application/json' 37 | ], json_encode([ 38 | 'code' => 0, 39 | 'msg' => 'success', 40 | 'data' => $task->toArray() 41 | ])); 42 | } 43 | } -------------------------------------------------------------------------------- /src/Discord.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | use Jenssegers\Agent\Agent; 19 | use Throwable; 20 | use Webman\Midjourney\MessageHandler\DescribeSuccess; 21 | use Webman\Midjourney\MessageHandler\Error; 22 | use Webman\Midjourney\MessageHandler\InteractionFailure; 23 | use Webman\Midjourney\MessageHandler\ModalCreateStart; 24 | use Webman\Midjourney\MessageHandler\Progress; 25 | use Webman\Midjourney\MessageHandler\Start; 26 | use Webman\Midjourney\MessageHandler\Success; 27 | use Webman\Midjourney\MessageHandler\UpscaleSuccess; 28 | use Webman\Midjourney\MessageHandler\VaryRegionStart; 29 | use Webman\Midjourney\MessageHandler\VaryRegionProgress; 30 | use Webman\Midjourney\Service\Image; 31 | use Workerman\Connection\AsyncTcpConnection; 32 | use Workerman\Connection\TcpConnection; 33 | use Workerman\Http\Client; 34 | use Workerman\Timer; 35 | use Workerman\Worker; 36 | 37 | class Discord 38 | { 39 | const APPLICATION_ID = '936929561302675456'; 40 | const SESSION_ID = '52d9197beda5646c1c52cdd7ff75fdd6'; 41 | const IMAGINE_COMMAND_ID = '938956540159881230'; 42 | const IMAGINE_COMMAND_VERSION = '1237876415471554623'; 43 | const BLEND_COMMAND_ID = '1062880104792997970'; 44 | const BLEND_COMMAND_VERSION = '1237876415471554624'; 45 | const DESCRIBE_COMMAND_ID = '1092492867185950852'; 46 | const DESCRIBE_COMMAND_VERSION = '1237876415471554625'; 47 | const SERVER_URL = "https://discord.com"; 48 | const CDN_URL = "https://cdn.discordapp.com"; 49 | const GATEWAY_URL = "wss://gateway.discord.gg"; 50 | const UPLOAD_URL = "https://discord-attachments-uploads-prd.storage.googleapis.com"; 51 | 52 | const INTERACTION_CREATE = 'INTERACTION_CREATE'; 53 | const INTERACTION_FAILURE = 'INTERACTION_FAILURE'; 54 | const MESSAGE_CREATE = 'MESSAGE_CREATE'; 55 | const MESSAGE_UPDATE = 'MESSAGE_UPDATE'; 56 | const MESSAGE_DELETE = 'MESSAGE_DELETE'; 57 | const INTERACTION_IFRAME_MODAL_CREATE = 'INTERACTION_IFRAME_MODAL_CREATE'; 58 | const INTERACTION_MODAL_CREATE = 'INTERACTION_MODAL_CREATE'; 59 | 60 | const MESSAGE_OPTION_DISPATCH = 0; 61 | const MESSAGE_OPTION_HEARTBEAT = 1; 62 | const MESSAGE_OPTION_IDENTIFY = 2; 63 | const MESSAGE_OPTION_PRESENCE = 3; 64 | const MESSAGE_OPTION_VOICE_STATE = 4; 65 | const MESSAGE_OPTION_RESUME = 6; 66 | const MESSAGE_OPTION_RECONNECT = 7; 67 | const MESSAGE_OPTION_MEMBER_CHUNK_REQUEST = 8; 68 | const MESSAGE_OPTION_INVALIDATE_SESSION = 9; 69 | const MESSAGE_OPTION_HELLO = 10; 70 | const MESSAGE_OPTION_HEARTBEAT_ACK = 11; 71 | const MESSAGE_OPTION_GUILD_SYNC = 12; 72 | 73 | const DIMENSIONS_PORTRAIT = '--ar 2:3'; 74 | const DIMENSIONS_LANDSCAPE = '--ar 3:2'; 75 | const DIMENSIONS_SQUARE = '--ar 1:1'; 76 | 77 | 78 | protected $id; 79 | 80 | protected $token; 81 | 82 | protected $guildId; 83 | 84 | protected $channelId; 85 | 86 | protected $sessionId; 87 | 88 | protected $useragent; 89 | 90 | protected $concurrency; 91 | 92 | protected $timeoutMinutes; 93 | 94 | public $lastSubmitTime = 0; 95 | 96 | /** 97 | * @var AsyncTcpConnection 98 | */ 99 | protected $gatewayConnection; 100 | 101 | /** 102 | * @var Discord[] 103 | */ 104 | protected static $instances = []; 105 | 106 | protected $sequence; 107 | 108 | protected $heartbeatTimer = 0; 109 | 110 | protected $heartbeatAck = true; 111 | 112 | /** 113 | * @param array $account 114 | */ 115 | public function __construct(array $account) 116 | { 117 | $this->id = $this->channelId = $account['channel_id']; 118 | if (isset(static::$instances[$this->id])) { 119 | Log::error("DISCORD:{$this->id()} already exists"); 120 | } 121 | $this->token = $account['token']; 122 | $this->guildId = $account['guild_id']; 123 | $this->useragent = $account['useragent']; 124 | $this->concurrency = $account['concurrency']; 125 | $this->timeoutMinutes = $account['timeoutMinutes']; 126 | static::$instances[$this->id] = $this; 127 | $this->createWss(); 128 | $this->createTimeoutTimer(); 129 | } 130 | 131 | public function createWss() 132 | { 133 | $gateway = static::getGateway(); 134 | $transport = 'tcp'; 135 | if (strpos($gateway, 'wss://') === 0) { 136 | $gateway = str_replace('wss://', 'ws://', $gateway); 137 | $transport = 'ssl'; 138 | } 139 | if (strpos(':', $gateway) === false) { 140 | $gateway .= $transport === 'ssl' ? ':443' : ':80'; 141 | } 142 | $ws = new AsyncTcpConnection("$gateway?encoding=json&v=9&compress=zlib-stream"); 143 | $ws->headers = [ 144 | 'Accept-Encoding' => 'gzip, deflate, br', 145 | 'Accept-Language' => 'zh-CN,zh;q=0.9,en;q=0.8', 146 | 'Cache-Control' => 'no-cache', 147 | 'Pragma' => 'no-cache', 148 | 'Sec-Websocket-Extensions' => 'permessage-deflate; client_max_window_bits', 149 | 'User-Agent' => $this->useragent, 150 | 'Origin' => 'https://discord.com', 151 | ]; 152 | $ws->transport = $transport; 153 | $ws->onWebSocketConnect = function() { 154 | Log::debug("DISCORD:{$this->id()} WSS Connected"); 155 | $this->login(); 156 | }; 157 | $ws->onMessage = function (TcpConnection $connection, $data) { 158 | // 解析discord数据 159 | try { 160 | $json = static::inflate($connection, $data); 161 | } catch (Throwable $e) { 162 | Log::error("DISCORD:{$this->id()} zlib stream inflate error data:" . bin2hex($data) . " " . $e->getMessage()); 163 | Worker::stopAll(); 164 | return; 165 | } 166 | $data = json_decode($json, true); 167 | $code = $data['op'] ?? null; 168 | if ($code != Discord::MESSAGE_OPTION_HEARTBEAT_ACK) { 169 | if (!in_array($data['t'], ['GUILD_MEMBER_LIST_UPDATE', 'PASSIVE_UPDATE_V1', 'READY_SUPPLEMENTAL', 'READY', 'CHANNEL_UPDATE'])) { 170 | Log::debug("DISCORD:{$this->id()} WSS Receive Message \n" . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 171 | } else { 172 | Log::debug("DISCORD:{$this->id()} WSS Receive Message " . $data['t'] ?? ''); 173 | } 174 | } 175 | // White list 176 | $titleWhiteList = [ 177 | 'Zoom Out', 178 | 'Inpaint' 179 | ]; 180 | if (in_array($data['t'] ?? '', [Discord::INTERACTION_MODAL_CREATE, Discord::INTERACTION_IFRAME_MODAL_CREATE]) && !in_array($data['d']['title'] ?? '', $titleWhiteList)) { 181 | file_put_contents(runtime_path('logs/midjourney/midjourney.warning.log'), date('Y-m-d H:i:s') . ' ' . ($data['d']['title'] ?? '') . "\n", FILE_APPEND); 182 | } 183 | switch ($code) { 184 | case Discord::MESSAGE_OPTION_HELLO: 185 | $this->handleHello($data); 186 | break; 187 | case Discord::MESSAGE_OPTION_DISPATCH: 188 | $this->handleDispatch($data); 189 | break; 190 | case Discord::MESSAGE_OPTION_HEARTBEAT_ACK: 191 | $this->heartbeatAck = true; 192 | break; 193 | } 194 | }; 195 | $ws->onError = function (TcpConnection $connection, $err, $code) { 196 | Log::error("DISCORD:{$this->id()} WSS Error $err $code"); 197 | }; 198 | $ws->onClose = function () { 199 | Log::info("DISCORD:{$this->id()} WSS Closed"); 200 | $this->gatewayConnection->context->inflator = null; 201 | $this->heartbeatAck = true; 202 | $this->gatewayConnection->reconnect(1); 203 | }; 204 | $ws->connect(); 205 | Log::debug("DISCORD:{$this->id()} WSS Connecting..."); 206 | $this->gatewayConnection = $ws; 207 | } 208 | 209 | protected function createTimeoutTimer() 210 | { 211 | Timer::add(60, function () { 212 | foreach ($this->getRunningTasks() as $task) { 213 | if ($task->startTime() + $this->timeoutMinutes * 60 < time()) { 214 | $task->removeFromList(static::getRunningListName($this->id)); 215 | if ($task->status() === Task::STATUS_FINISHED) { 216 | continue; 217 | } 218 | $this->failed($task, "任务超时"); 219 | } 220 | } 221 | }); 222 | } 223 | 224 | public function id() 225 | { 226 | return $this->id; 227 | } 228 | 229 | public function token(): string 230 | { 231 | return $this->token; 232 | } 233 | 234 | public function guildId() 235 | { 236 | return $this->guildId; 237 | } 238 | 239 | public function channelId() 240 | { 241 | return $this->channelId; 242 | } 243 | 244 | public function sessionId() 245 | { 246 | return $this->sessionId ?? static::SESSION_ID; 247 | } 248 | 249 | public function useragent() 250 | { 251 | return $this->useragent; 252 | } 253 | 254 | /** 255 | * @param Task $task 256 | * @return null 257 | */ 258 | public static function submit(Task $task) 259 | { 260 | $status = $task->status(); 261 | if ($status !== Task::STATUS_PENDING) { 262 | Log::error("Task:{$task->id()} status($status) is not pending"); 263 | return null; 264 | } 265 | $task->addToList(static::getPendingListName()); 266 | static::tryToExecute(); 267 | return null; 268 | } 269 | 270 | public static function tryToExecute() 271 | { 272 | foreach (static::getPendingTasks() as $task) { 273 | static::execute($task); 274 | } 275 | } 276 | 277 | public static function execute(Task $task) 278 | { 279 | $discordId = $task->discordId() ?: null; 280 | if (!$instance = static::getIdleInstance($discordId)) { 281 | Log::debug("TASK:{$task->id()} DISCORD No idle instance found"); 282 | return; 283 | } 284 | $instance->lastSubmitTime = microtime(true); 285 | $instance->doExecute($task); 286 | } 287 | 288 | protected static function getPendingTasks(): array 289 | { 290 | $listName = static::getPendingListName(); 291 | $tasks = Task::getList($listName); 292 | ksort($tasks); 293 | return $tasks; 294 | } 295 | 296 | protected function getRunningTasks(): array 297 | { 298 | $listName = static::getRunningListName($this->id); 299 | return Task::getList($listName); 300 | } 301 | 302 | public static function getPendingListName(): string 303 | { 304 | return "discord-pending-tasks"; 305 | } 306 | 307 | public static function getRunningListName(string $id): string 308 | { 309 | return "discord-$id-running-tasks"; 310 | } 311 | 312 | protected function doExecute(Task $task) 313 | { 314 | $task->addToList(static::getRunningListName($this->id))->removeFromList(static::getPendingListName())->discordId($this->id); 315 | Log::info("TASK:{$task->id()} execute by discord {$this->id}"); 316 | try { 317 | $task->startTime(time())->status(Task::STATUS_STARTED); 318 | switch ($task->action()) { 319 | case Task::ACTION_IMAGINE: 320 | Image::imagine($task, $this); 321 | break; 322 | case Task::ACTION_UPSCALE: 323 | case Task::ACTION_VARIATION: 324 | case Task::ACTION_VARIATION_STRONG: 325 | case Task::ACTION_VARIATION_SUBTLE: 326 | case Task::ACTION_REROLL: 327 | case Task::ACTION_ZOOMOUT: 328 | case Task::ACTION_PANLEFT: 329 | case Task::ACTION_PANRIGHT: 330 | case Task::ACTION_PANUP: 331 | case Task::ACTION_PANDOWN: 332 | case Task::ACTION_MAKE_SQUARE: 333 | case Task::ACITON_ZOOMOUT_CUSTOM: 334 | case Task::ACTION_UPSCALE_V5_2X: 335 | case Task::ACTION_UPSCALE_V5_4X: 336 | case Task::ACTION_UPSCALE_V6_2X_CREATIVE: 337 | case Task::ACTION_UPSCALE_V6_2X_SUBTLE: 338 | case Task::ACTION_PIC_READER; 339 | Image::change($task, $this); 340 | break; 341 | case Task::ACTION_DESCRIBE: 342 | Image::describe($task, $this); 343 | break; 344 | case Task::ACTION_BLEND: 345 | Image::blend($task, $this); 346 | break; 347 | case Task::ACTION_VARIATION_REGION: 348 | Image::varyRegion($task, $this); 349 | break; 350 | case Task::ACTION_CANCEL_JOB; 351 | Image::cancelJob($task, $this); 352 | break; 353 | default: 354 | throw new BusinessException("Unknown action {$task->action()}"); 355 | } 356 | } catch (Throwable $exception) { 357 | $this->failed($task, (string)$exception); 358 | } 359 | } 360 | 361 | public static function getServer() 362 | { 363 | return Config::get('proxy.server') ?? static::SERVER_URL; 364 | } 365 | 366 | public static function getGateway() 367 | { 368 | return Config::get('proxy.gateway') ?? static::GATEWAY_URL; 369 | } 370 | 371 | public static function finished(Task $task) 372 | { 373 | if ($task->status() === Task::STATUS_FAILED) { 374 | return; 375 | } 376 | $task->removeFromList(static::getRunningListName($task->discordId())); 377 | $task->status(Task::STATUS_FINISHED)->finishTime(time())->progress('100%'); 378 | $task->save(); 379 | static::notify($task); 380 | static::tryToExecute(); 381 | } 382 | 383 | public static function failed(Task $task, string $reason) 384 | { 385 | Log::error("TASK:{$task->id()} FAILED, reason $reason"); 386 | if ($task->status() === Task::STATUS_FAILED) { 387 | return; 388 | } 389 | if ($discordId = $task->discordId()) { 390 | $task->removeFromList(static::getRunningListName($discordId)); 391 | } 392 | $task->status(Task::STATUS_FAILED)->finishTime(time())->failReason($reason); 393 | $task->save(); 394 | static::notify($task); 395 | } 396 | 397 | /** 398 | * @return false|mixed|null 399 | */ 400 | public static function getIdleInstance($instanceId = null) 401 | { 402 | $availableInstances = []; 403 | $sort = []; 404 | if ($instanceId) { 405 | if (!isset(static::$instances[$instanceId])) { 406 | return null; 407 | } 408 | $instances = [$instanceId => static::$instances[$instanceId]]; 409 | } else { 410 | $instances = static::$instances; 411 | } 412 | foreach ($instances as $instance) { 413 | $runningTasks = $instance->getRunningTasks(); 414 | if ($instance->concurrency - count($runningTasks) > 0) { 415 | $availableInstances[] = $instance; 416 | $sort[] = $instance->lastSubmitTime; 417 | } 418 | } 419 | if (empty($availableInstances)) { 420 | return null; 421 | } 422 | // 找到一个最空闲的实例 423 | array_multisort($sort, SORT_ASC, $availableInstances); 424 | return $availableInstances[0]; 425 | } 426 | 427 | public static function get(?string $instanceId = null) 428 | { 429 | if ($instanceId) { 430 | return static::$instances[$instanceId] ?? null; 431 | } 432 | return static::$instances[array_rand(static::$instances)]; 433 | } 434 | 435 | public static function getRunningTaskByCondition(TaskCondition $condition): ?Task 436 | { 437 | foreach (static::$instances as $instance) { 438 | foreach (array_reverse($instance->getRunningTasks()) as $task) { 439 | if ($condition->match($task)) { 440 | return $task; 441 | } 442 | } 443 | } 444 | return null; 445 | } 446 | 447 | public static function getMessageHash($message) 448 | { 449 | if ($customId = $message['d']['components'][0]['components'][0]['custom_id'] ?? '') { 450 | if (strpos($customId, 'MJ::CancelJob::ByJobid::') === 0) { 451 | return substr($customId, strlen('MJ::CancelJob::ByJobid::')); 452 | } 453 | } 454 | $filename = $message['d']['attachments'][0]['filename'] ?? ''; 455 | if (!$filename) { 456 | return null; 457 | } 458 | if (substr($filename, -4) === '.png' || substr($filename, -4) === '.jpg') { 459 | $pos = strrpos($filename, '_'); 460 | return substr($filename, $pos + 1, strlen($filename) - $pos - 5); 461 | } 462 | return explode('_', $filename)[0]; 463 | } 464 | 465 | public static function hasImage($message) 466 | { 467 | return isset($message['d']['attachments'][0]['url']); 468 | } 469 | 470 | public static function attachmentsReady(Task $task): bool 471 | { 472 | foreach ($task->attachments() as $attachment) { 473 | if (!is_array($attachment)) { 474 | return false; 475 | } 476 | } 477 | return true; 478 | } 479 | 480 | public static function getButtons($message): array 481 | { 482 | $components = $message['d']['components'] ?? null; 483 | if (!is_array($components)) { 484 | return []; 485 | } 486 | $result = []; 487 | foreach ($components as $section) { 488 | $buttons = []; 489 | foreach ($section['components'] ?? [] as $button) { 490 | if (!isset($button['custom_id'])) { 491 | continue; 492 | } 493 | if (strpos($button['custom_id'], 'MJ::BOOKMARK::') !== false || $button['custom_id'] === 'MJ::Job::PicReader::all') { 494 | break; 495 | } 496 | $buttons[] = $button; 497 | } 498 | if ($buttons) { 499 | $result[] = $buttons; 500 | } 501 | } 502 | return array_values($result); 503 | } 504 | 505 | public static function notify(Task $task) 506 | { 507 | if (!$task->notifyUrl()) { 508 | return; 509 | } 510 | $client = new Client(); 511 | $data = $task->toArray(); 512 | $url = $task->notifyUrl(); 513 | Log::debug("Task::{$task->id()} notify $url \n" . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 514 | $client->request($url, [ 515 | 'method' => 'POST', 516 | 'headers' => [ 517 | 'Content-Type' => 'application/json' 518 | ], 519 | 'data' => json_encode($data), 520 | 'success' => function ($response) use ($task) { 521 | Log::debug("NOTIFY RESULT " . $response->getBody()); 522 | }, 523 | 'error' => function ($error) use ($task) { 524 | Log::error("NOTIFY ERROR " . $error); 525 | } 526 | ]); 527 | } 528 | 529 | protected function handleHello($data) 530 | { 531 | if ($this->heartbeatTimer) { 532 | Timer::del($this->heartbeatTimer); 533 | } 534 | $this->heartbeatTimer = Timer::add($data['d']['heartbeat_interval']/1000 ?? 41.25, function () { 535 | if (!$this->heartbeatAck) { 536 | Log::error("DISCORD:{$this->id()} WSS Heartbeat timeout"); 537 | $this->gatewayConnection->close(); 538 | return; 539 | } 540 | $this->heartbeatAck = false; 541 | if ($this->gatewayConnection->getStatus() === TcpConnection::STATUS_ESTABLISHED) { 542 | $this->send([ 543 | 'op' => Discord::MESSAGE_OPTION_HEARTBEAT, 544 | 'd' => $this->sequence, 545 | ]); 546 | } 547 | }, null); 548 | } 549 | 550 | protected function send($data) 551 | { 552 | $data = json_encode($data, true); 553 | $this->gatewayConnection->send($data); 554 | } 555 | 556 | public function login() 557 | { 558 | $agent = new Agent(); 559 | $agent->setUserAgent($this->useragent); 560 | $platform = $agent->platform() === 'OS X' ? 'Mac OS X' : $agent->platform(); 561 | $data = [ 562 | 'op' => Discord::MESSAGE_OPTION_IDENTIFY, 563 | 'd' => [ 564 | 'token' => $this->token, 565 | 'capabilities' => 16381, 566 | 'properties' => [ 567 | 'os' => $platform, 568 | 'browser' => $agent->browser(), 569 | 'device' => '', 570 | 'system_locale' => 'zh-CN', 571 | 'browser_user_agent' => $this->useragent, 572 | 'browser_version' => $agent->version($agent->browser()), 573 | 'os_version' => $agent->version($platform), 574 | 'referrer' => 'https://www.midjourney.com', 575 | 'referring_domain' => 'www.midjourney.com', 576 | 'referrer_current' => '', 577 | 'referring_domain_current' => '', 578 | 'release_channel' => 'stable', 579 | 'client_build_number' => 268600, 580 | 'client_event_source' => null, 581 | ], 582 | 'presence' => [ 583 | 'status' => 'online', 584 | 'since' => 0, 585 | 'activities' => [], 586 | 'afk' => false, 587 | ], 588 | 'compress' => false, 589 | 'client_state' => [ 590 | 'guild_versions' => [], 591 | 'api_code_version' => 0, 592 | ], 593 | ], 594 | ]; 595 | Log::info("DISCORD:{$this->id()} WSS Send Identify"); 596 | Log::debug("DISCORD:{$this->id()} WSS Send Identify\n" . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 597 | $this->send($data); 598 | $this->tryToExecute(); 599 | } 600 | 601 | /** 602 | * @param $data 603 | * @return void 604 | */ 605 | protected function handleDispatch($data) 606 | { 607 | $sequence = $data['s'] ?? null; 608 | $this->sequence = $sequence ?? $this->sequence; 609 | if (!is_array($data['d'])) { 610 | return; 611 | } 612 | $handlers = [ 613 | Error::class, 614 | InteractionFailure::class, 615 | Start::class, 616 | Progress::class, 617 | Success::class, 618 | UpscaleSuccess::class, 619 | DescribeSuccess::class, 620 | ModalCreateStart::class, 621 | VaryRegionStart::class, 622 | VaryRegionProgress::class, 623 | ]; 624 | $type = $data['t'] ?? null; 625 | if ($type === 'READY') { 626 | $this->sessionId = $data['d']['session_id']; 627 | } 628 | $messageId = $data['d']['id'] ?? ''; 629 | $nonce = $data['d']['nonce'] ?? ''; 630 | foreach ($handlers as $handler) { 631 | try { 632 | if(call_user_func([$handler, 'handle'], $data)) { 633 | $handlerName = substr(strrchr($handler, "\\"), 1); 634 | Log::debug("DISCORD:{$this->id()} WSS DISPATCH SUCCESS Handler:$handlerName type:$type MessageId:$messageId Nonce:$nonce"); 635 | return; 636 | } 637 | } catch (Throwable $e) { 638 | Log::error("DISCORD:{$this->id()} WSS DISPATCH ERROR Handler:$handler " . $e); 639 | } 640 | } 641 | } 642 | 643 | public static function uniqId(): string 644 | { 645 | $string = str_replace('.', '', (string)microtime(true)); 646 | return substr(substr($string, 0, 13) . random_int(100000000, 999999999), 0, 19); 647 | } 648 | 649 | public static function replaceImageCdn($url) 650 | { 651 | $cdn = Config::get('proxy.cdn'); 652 | $defaultCdn = Discord::CDN_URL; 653 | if ($cdn && $defaultCdn) { 654 | return str_replace($defaultCdn, $cdn, $url); 655 | } 656 | return $url; 657 | } 658 | 659 | public static function replaceUploadUrl($url) 660 | { 661 | $cdn = Config::get('proxy.upload'); 662 | $defaultCdn = Discord::UPLOAD_URL; 663 | if ($cdn && $defaultCdn) { 664 | return str_replace($defaultCdn, $cdn, $url); 665 | } 666 | return $url; 667 | } 668 | 669 | /** 670 | * @param $connection 671 | * @param $buffer 672 | * @return false|string 673 | */ 674 | protected static function inflate($connection, $buffer) 675 | { 676 | if (!isset($connection->context->inflator)) { 677 | $connection->context->inflator = \inflate_init( 678 | ZLIB_ENCODING_DEFLATE 679 | ); 680 | } 681 | return \inflate_add($connection->context->inflator, $buffer); 682 | } 683 | } 684 | -------------------------------------------------------------------------------- /src/Install.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | class Install 19 | { 20 | const WEBMAN_PLUGIN = true; 21 | 22 | /** 23 | * @var array 24 | */ 25 | protected static $pathRelation = array ( 26 | 'config/plugin/webman/midjourney' => 'config/plugin/webman/midjourney', 27 | ); 28 | 29 | /** 30 | * Install 31 | * @return void 32 | */ 33 | public static function install() 34 | { 35 | static::installByRelation(); 36 | } 37 | 38 | /** 39 | * Uninstall 40 | * @return void 41 | */ 42 | public static function uninstall() 43 | { 44 | self::uninstallByRelation(); 45 | } 46 | 47 | /** 48 | * installByRelation 49 | * @return void 50 | */ 51 | public static function installByRelation() 52 | { 53 | foreach (static::$pathRelation as $source => $dest) { 54 | if ($pos = strrpos($dest, '/')) { 55 | $parent_dir = base_path().'/'.substr($dest, 0, $pos); 56 | if (!is_dir($parent_dir)) { 57 | mkdir($parent_dir, 0777, true); 58 | } 59 | } 60 | //symlink(__DIR__ . "/$source", base_path()."/$dest"); 61 | copy_dir(__DIR__ . "/$source", base_path()."/$dest"); 62 | echo "Create $dest 63 | "; 64 | } 65 | } 66 | 67 | /** 68 | * uninstallByRelation 69 | * @return void 70 | */ 71 | public static function uninstallByRelation() 72 | { 73 | foreach (static::$pathRelation as $source => $dest) { 74 | $path = base_path()."/$dest"; 75 | if (!is_dir($path) && !is_file($path)) { 76 | continue; 77 | } 78 | echo "Remove $dest 79 | "; 80 | if (is_file($path) || is_link($path)) { 81 | unlink($path); 82 | continue; 83 | } 84 | remove_dir($path); 85 | } 86 | } 87 | 88 | } -------------------------------------------------------------------------------- /src/Log.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | use support\Log as MonoLog; 19 | use Workerman\Worker; 20 | 21 | class Log 22 | { 23 | const ERROR_LOG_CHANNEL = 'plugin.webman.midjourney.error'; 24 | 25 | const INFO_LOG_CHANNEL = 'plugin.webman.midjourney.info'; 26 | 27 | const DEBUG_LOG_CHANNEL = 'plugin.webman.midjourney.debug'; 28 | 29 | public static function debug($content) 30 | { 31 | if (Config::get('settings.debug') === false) { 32 | return; 33 | } 34 | if (class_exists(MonoLog::class, 'Log')) { 35 | MonoLog::channel(static::DEBUG_LOG_CHANNEL)->debug($content); 36 | } 37 | if (!Worker::$daemonize) { 38 | echo date('Y-m-d H:i:s') . " " . $content . "\n"; 39 | } 40 | } 41 | 42 | public static function error($content) 43 | { 44 | if (class_exists(MonoLog::class, 'Log')) { 45 | MonoLog::channel(static::ERROR_LOG_CHANNEL)->error($content); 46 | } 47 | static::info($content); 48 | } 49 | 50 | public static function info($content) 51 | { 52 | if (class_exists(MonoLog::class, 'Log')) { 53 | MonoLog::channel(static::INFO_LOG_CHANNEL)->info($content); 54 | } 55 | static::debug($content); 56 | } 57 | } -------------------------------------------------------------------------------- /src/MessageHandler/Base.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | class Base 19 | { 20 | protected static $regex = [ 21 | '/.*?\*\*(.*?)\*\*.+<@\d+> \((.*?)\)/' 22 | ]; 23 | 24 | /** 25 | * @param $content 26 | * @return string 27 | */ 28 | public static function parseContent($content): string 29 | { 30 | if ($content === '') { 31 | return ''; 32 | } 33 | foreach (static::$regex as $preg) { 34 | if (preg_match($preg, $content, $matches)) { 35 | return $matches[1]; 36 | } 37 | } 38 | return ''; 39 | } 40 | } -------------------------------------------------------------------------------- /src/MessageHandler/DescribeSuccess.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class DescribeSuccess extends Base 24 | { 25 | public static function handle($message): bool 26 | { 27 | $messageType = $message['t'] ?? ''; 28 | $description = $message['d']['embeds'][0]['description'] ?? ''; 29 | $interactionName = $message['d']['interaction']['name'] ?? ''; 30 | $messageId = $message['d']['id'] ?? ''; 31 | if ($messageType === Discord::MESSAGE_UPDATE && $interactionName === 'describe' && $description) { 32 | if (!$task = Discord::getRunningTaskByCondition((new TaskCondition())->messageId($messageId)->action(Task::ACTION_DESCRIBE))) { 33 | Log::debug("MessageHandler DescribeSuccess no task found messageId={$messageId}"); 34 | return false; 35 | } 36 | $task->description($description); 37 | $task->buttons(Discord::getButtons($message)); 38 | $task->save(); 39 | Discord::finished($task); 40 | return true; 41 | } 42 | return false; 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/MessageHandler/Error.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\TaskCondition; 21 | 22 | class Error extends Base 23 | { 24 | 25 | public static function handle($message): bool 26 | { 27 | $messageType = $message['t'] ?? ''; 28 | $content = $message['d']['content'] ?? ''; 29 | $nonce = $message['d']['nonce'] ?? ''; 30 | if (strpos($content, 'Failed to process your command') === 0 && $task = Discord::getRunningTaskByCondition((new TaskCondition())->nonce($nonce))) { 31 | Discord::failed($task, $content); 32 | return true; 33 | } 34 | if ($messageType === Discord::MESSAGE_CREATE && ($title = $message['d']['embeds'][0]['title'] ?? '')) { 35 | $color = $message['d']['embeds'][0]['color'] ?? 0; 36 | $description = $message['d']['embeds'][0]['description'] ?? ''; 37 | $referenceMessageId = $message['d']['message_reference']['message_id'] ?? ''; 38 | $errorContent = "[ $title ] $description"; 39 | if ($color === 16711680 && $nonce && $task = Discord::getRunningTaskByCondition((new TaskCondition())->nonce($nonce))) { 40 | Discord::failed($task, $errorContent); 41 | return true; 42 | } else if(strpos(strtolower($title), 'invalid link')) { 43 | if ($task = Discord::getRunningTaskByCondition((new TaskCondition())->params(['messageId' => $referenceMessageId]))) { 44 | Discord::failed($task, $errorContent); 45 | return true; 46 | } 47 | } else if ($color === 16239475) { 48 | Log::info($errorContent); 49 | return true; 50 | } 51 | } 52 | return false; 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /src/MessageHandler/InteractionFailure.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Task; 20 | use Webman\Midjourney\TaskCondition; 21 | 22 | class InteractionFailure extends Base 23 | { 24 | public static function handle($message): bool 25 | { 26 | $nonce = $message['d']['nonce'] ?? ''; 27 | $messageType = $message['t'] ?? ''; 28 | if ($messageType === Discord::INTERACTION_FAILURE && $nonce) { 29 | $task = Discord::getRunningTaskByCondition((new TaskCondition())->nonce($nonce)); 30 | $params = $task->params(); 31 | $params[Discord::INTERACTION_FAILURE] = true; 32 | $task->params($params); 33 | $task->save(); 34 | } 35 | return false; 36 | } 37 | } -------------------------------------------------------------------------------- /src/MessageHandler/ModalCreateStart.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class ModalCreateStart extends Base 24 | { 25 | public static function handle($message): bool 26 | { 27 | $nonce = $message['d']['nonce'] ?? ''; 28 | $messageType = $message['t'] ?? ''; 29 | $messageId = $message['d']['id'] ?? ''; 30 | $modalActions = [ 31 | Task::ACITON_ZOOMOUT_CUSTOM => 'MJ::OutpaintCustomZoomModal::prompt', 32 | Task::ACTION_PIC_READER => 'MJ::Picreader::Modal::PromptField', 33 | ]; 34 | if ($messageType === Discord::INTERACTION_MODAL_CREATE && $nonce) { 35 | foreach ($modalActions as $action => $componentsCustomId) { 36 | if ($task = Discord::getRunningTaskByCondition((new TaskCondition())->action($action)->nonce($nonce))) { 37 | $params = $task->params(); 38 | $params['messageId'] = $messageId; 39 | $params['customId'] = $message['d']['custom_id']; 40 | $params['componentsCustomId'] = $message['d']['components'][0]['components'][0]['custom_id'] ?? $componentsCustomId; 41 | $params[Discord::INTERACTION_MODAL_CREATE] = true; 42 | $task->params($params); 43 | $task->nonce(Discord::uniqId()); 44 | $task->save(); 45 | $task->removeFromList(Discord::getRunningListName($task->discordId())); 46 | Discord::execute($task); 47 | return true; 48 | } 49 | } 50 | } 51 | return false; 52 | } 53 | } -------------------------------------------------------------------------------- /src/MessageHandler/Progress.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class Progress extends Base 24 | { 25 | public static function handle($message): bool 26 | { 27 | $content = $message['d']['content'] ?? ''; 28 | $messageType = $message['t'] ?? ''; 29 | $finalPrompt = static::parseContent($content); 30 | $messageId = $message['d']['id'] ?? ''; 31 | if ($messageType === Discord::MESSAGE_UPDATE && $finalPrompt) { 32 | if (!$task = Discord::getRunningTaskByCondition((new TaskCondition())->messageId($messageId))) { 33 | Log::debug("MessageHandler Progress no task found messageId={$messageId}"); 34 | return false; 35 | } 36 | $task->status(Task::STATUS_RUNNING)->finalPrompt($finalPrompt); 37 | if (preg_match('/ \((\d+\%)\) \(.*?\)$/', $content, $matches)) { 38 | $task->progress($matches[1]); 39 | } 40 | $imageUrl = $message['d']['attachments'][0]['url'] ?? ''; 41 | $task->imageUrl(Discord::replaceImageCdn($imageUrl)); 42 | $task->imageRawUrl($imageUrl); 43 | $task->messageHash(Discord::getMessageHash($message)); 44 | $task->buttons(Discord::getButtons($message)); 45 | $task->save(); 46 | Discord::notify($task); 47 | return true; 48 | } 49 | return false; 50 | } 51 | } -------------------------------------------------------------------------------- /src/MessageHandler/Start.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class Start extends Base 24 | { 25 | public static function handle($message): bool 26 | { 27 | $nonce = $message['d']['nonce'] ?? ''; 28 | $messageType = $message['t'] ?? ''; 29 | $messageId = $message['d']['id'] ?? ''; 30 | if ($messageType === Discord::MESSAGE_CREATE && $nonce) { 31 | if (!$task = Discord::getRunningTaskByCondition((new TaskCondition())->nonce($nonce))) { 32 | Log::debug("MessageHandler Start no task found nonce=$nonce messageId=$messageId"); 33 | return false; 34 | } 35 | $task->messageId($message['d']['id']); 36 | $task->status(Task::STATUS_RUNNING); 37 | if ($messageHash = Discord::getMessageHash($message)) { 38 | $task->messageHash($messageHash); 39 | } 40 | $task->buttons(Discord::getButtons($message)); 41 | $task->save(); 42 | Discord::notify($task); 43 | return true; 44 | } 45 | return false; 46 | } 47 | } -------------------------------------------------------------------------------- /src/MessageHandler/Success.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class Success extends Base 24 | { 25 | 26 | public static function handle($message): bool 27 | { 28 | $nonce = $message['d']['nonce'] ?? ''; 29 | $messageType = $message['t'] ?? ''; 30 | $messageHash = Discord::getMessageHash($message); 31 | $finalPrompt = static::parseContent($message['d']['content'] ?? ''); 32 | $messageId = $message['d']['id'] ?? ''; 33 | if ($messageType === Discord::MESSAGE_CREATE && $finalPrompt && $messageHash) { 34 | if (!$task = Discord::getRunningTaskByCondition((new TaskCondition())->messageHash($messageHash))) { 35 | Log::debug("MessageHandler Success no task found messageHash=$messageHash messageId=$messageId nonce=$nonce and try to find InteractionFailure task"); 36 | $task = Discord::getRunningTaskByCondition((new TaskCondition())->prompt($finalPrompt)->params([Discord::INTERACTION_FAILURE => true])); 37 | if (!$task) { 38 | Log::debug("MessageHandler Success no task found messageHash=$messageHash messageId=$messageId nonce=$nonce and no InteractionFailure task found"); 39 | $task = Discord::getRunningTaskByCondition((new TaskCondition())->prompt($finalPrompt)) ?: Discord::getRunningTaskByCondition((new TaskCondition())->finalPrompt($finalPrompt)); 40 | if (!$task) { 41 | Log::debug("MessageHandler Success no task found messageHash=$messageHash messageId=$messageId nonce=$nonce prompt=$finalPrompt and no task found"); 42 | return false; 43 | } 44 | } 45 | } 46 | $imageUrl = $message['d']['attachments'][0]['url'] ?? ''; 47 | $task->messageId($messageId); 48 | $task->imageUrl(Discord::replaceImageCdn($imageUrl)); 49 | $task->imageRawUrl($imageUrl); 50 | $task->finalPrompt($finalPrompt); 51 | if (!$task->prompt()) { 52 | $task->prompt($finalPrompt); 53 | } 54 | $task->buttons(Discord::getButtons($message)); 55 | $task->save(); 56 | Discord::finished($task); 57 | return true; 58 | } 59 | return false; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/MessageHandler/UpscaleSuccess.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class UpscaleSuccess extends Base 24 | { 25 | protected static $regex = [ 26 | '/\*\*(.*?)\*\* - Image #(\d) <@\d+>/' 27 | ]; 28 | 29 | public static function handle($message): bool 30 | { 31 | $messageType = $message['t'] ?? ''; 32 | [$finalPrompt, $index] = static::parseContents($message['d']['content'] ?? ''); 33 | $referenceMessageId = $message['d']['message_reference']['message_id'] ?? ''; 34 | $messageId = $message['d']['id'] ?? ''; 35 | if ($messageType === Discord::MESSAGE_CREATE && Discord::hasImage($message) && $finalPrompt && $referenceMessageId) { 36 | $task = Discord::getRunningTaskByCondition((new TaskCondition())->action(Task::ACTION_UPSCALE)->params([ 37 | 'messageId' => $referenceMessageId, 38 | 'index' => $index 39 | ])); 40 | if (!$task) { 41 | Log::debug("UpscaleSuccess no task found referenceMessageId={$referenceMessageId} index={$index} messageId={$messageId}"); 42 | return false; 43 | } 44 | $imageUrl = $message['d']['attachments'][0]['url'] ?? ''; 45 | $task->messageId($messageId); 46 | $task->imageUrl(Discord::replaceImageCdn($imageUrl)); 47 | $task->imageRawUrl($imageUrl); 48 | $task->messageHash(Discord::getMessageHash($message)); 49 | $task->finalPrompt($finalPrompt); 50 | $task->buttons(Discord::getButtons($message)); 51 | $task->save(); 52 | Discord::finished($task); 53 | return true; 54 | } 55 | return false; 56 | } 57 | 58 | public static function parseContents($content): array 59 | { 60 | if ($content === '') { 61 | return ['', 0]; 62 | } 63 | foreach (static::$regex as $preg) { 64 | if (preg_match($preg, $content, $matches)) { 65 | return [$matches[1], (int)$matches[2]]; 66 | } 67 | } 68 | return ['', 0]; 69 | } 70 | 71 | } -------------------------------------------------------------------------------- /src/MessageHandler/VaryRegionProgress.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class VaryRegionProgress extends Base 24 | { 25 | public static function handle($message): bool 26 | { 27 | $messageType = $message['t'] ?? ''; 28 | $interactionId = $message['d']['interaction_metadata']['id'] ?? ''; 29 | $messageId = $message['d']['id'] ?? ''; 30 | if ($messageType === Discord::MESSAGE_CREATE && $interactionId) { 31 | if (!$task = Discord::getRunningTaskByCondition((new TaskCondition())->action(Task::ACTION_VARIATION_REGION)->params([ 32 | 'interactionId' => $interactionId 33 | ]))) { 34 | Log::debug("VaryRegionProgress no task found interactionId=$interactionId messageId=$messageId"); 35 | return false; 36 | } 37 | $task->messageId($messageId); 38 | if ($messageHash = Discord::getMessageHash($message)) { 39 | $task->messageHash($messageHash); 40 | } 41 | $task->buttons(Discord::getButtons($message)); 42 | $task->save(); 43 | return true; 44 | } 45 | return false; 46 | } 47 | } -------------------------------------------------------------------------------- /src/MessageHandler/VaryRegionStart.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\MessageHandler; 17 | 18 | use Webman\Midjourney\Discord; 19 | use Webman\Midjourney\Log; 20 | use Webman\Midjourney\Task; 21 | use Webman\Midjourney\TaskCondition; 22 | 23 | class VaryRegionStart extends Base 24 | { 25 | public static function handle($message): bool 26 | { 27 | $nonce = $message['d']['nonce'] ?? ''; 28 | $messageType = $message['t'] ?? ''; 29 | $messageId = $message['d']['id'] ?? ''; 30 | if ($messageType === Discord::INTERACTION_IFRAME_MODAL_CREATE && $nonce) { 31 | if (!$task = Discord::getRunningTaskByCondition((new TaskCondition())->nonce($nonce)->action(Task::ACTION_VARIATION_REGION))) { 32 | Log::debug("VaryRegionStart no task found nonce=$nonce messageId=$messageId"); 33 | return false; 34 | } 35 | if ($task->params()[Discord::INTERACTION_IFRAME_MODAL_CREATE] ?? '') { 36 | Log::debug("VaryRegionStart already handled nonce=$nonce messageId=$messageId"); 37 | return false; 38 | } 39 | if (!$customId = $message['d']['custom_id'] ?? '') { 40 | Log::debug("VaryRegionStart no custom_id nonce=$nonce messageId=$messageId"); 41 | return false; 42 | } 43 | if (!$interactionId = $message['d']['id'] ?? '') { 44 | Log::debug("VaryRegionStart no interaction_id nonce=$nonce messageId=$messageId"); 45 | return false; 46 | } 47 | $customId = substr($customId, strrpos($customId, ':') + 1); 48 | $params = $task->params(); 49 | $params['customId'] = $customId; 50 | $params['interactionId'] = $interactionId; 51 | $params[Discord::INTERACTION_IFRAME_MODAL_CREATE] = true; 52 | $task->params($params); 53 | $task->nonce(Discord::uniqId()); 54 | $task->buttons(Discord::getButtons($message)); 55 | $task->save(); 56 | $task->removeFromList(Discord::getRunningListName($task->discordId())); 57 | Discord::execute($task); 58 | return true; 59 | } 60 | return false; 61 | } 62 | } -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | use Throwable; 19 | use Webman\Midjourney\Enum\WebsocketCode; 20 | use Workerman\Connection\AsyncTcpConnection; 21 | use Workerman\Connection\TcpConnection; 22 | use Workerman\Protocols\Http\Request; 23 | use Workerman\Protocols\Http\Response; 24 | 25 | class Server 26 | { 27 | 28 | protected $apiPrefix = ''; 29 | 30 | /** 31 | * @param $config 32 | */ 33 | public function __construct($config) 34 | { 35 | Config::init($config); 36 | Task::init($config['store']); 37 | $this->apiPrefix = trim($config['settings']['apiPrefix'] ?? '', ' /') ?? ''; 38 | } 39 | 40 | public function onWorkerStart() 41 | { 42 | foreach (Config::get('accounts') as $account) { 43 | if (isset($account['enable']) && !$account['enable']) { 44 | continue; 45 | } 46 | foreach ($account as $key => $value) { 47 | if (empty($value)) { 48 | Log::error("Discord account config error $key is empty"); 49 | continue 2; 50 | } 51 | } 52 | new Discord($account); 53 | } 54 | } 55 | 56 | public function onMessage(TcpConnection $connection, Request $request) 57 | { 58 | $path = $request->path(); 59 | if ($this->apiPrefix && strpos($path, "/$this->apiPrefix") !== 0) { 60 | $connection->send($this->notfound()); 61 | return; 62 | } 63 | $path = trim(substr($path, strlen($this->apiPrefix) + 1), '/'); 64 | if (!strpos($path, '/')) { 65 | $connection->send($this->notfound()); 66 | return null; 67 | } 68 | [$controller, $action] = explode('/', $path, 2); 69 | $controller = '\\Webman\\Midjourney\\Controller\\' . ucfirst($controller); 70 | if (!class_exists($controller) || !method_exists($controller, $action)) { 71 | $connection->send($this->notfound()); 72 | return null; 73 | } 74 | $headerSecret = $request->header('mj-api-secret'); 75 | $secret = Config::get('settings.secret'); 76 | if ($secret && $headerSecret !== $secret) { 77 | $response = new Response(200, ['Content-Type' => 'application/json'], json_encode([ 78 | 'code' => 403, 79 | 'msg' => '403 Api Secret 错误', 80 | 'taskId' => null, 81 | 'data' => [] 82 | ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 83 | $connection->send($response); 84 | return null; 85 | } 86 | try { 87 | $response = (new $controller)->$action($request); 88 | } catch (Throwable $e) { 89 | Log::error($e); 90 | $response = new Response(200, ['Content-Type' => 'application/json'], json_encode([ 91 | 'code' => 500, 92 | 'msg' => $e->getMessage(), 93 | 'taskId' => null, 94 | 'data' => [], 95 | 'ban-words' => $e->banWord ?? '' 96 | ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 97 | } 98 | $connection->send($response); 99 | } 100 | 101 | protected function notfound(): Response 102 | { 103 | return new Response(200, ['Content-Type' => 'application/json'], json_encode([ 104 | 'code' => 404, 105 | 'msg' => 'API 404 Not Found', 106 | 'taskId' => null, 107 | 'data' => [] 108 | ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Service/Attachment.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\Service; 17 | 18 | use Throwable; 19 | use Webman\Midjourney\Config; 20 | use Webman\Midjourney\Discord; 21 | use Webman\Midjourney\Log; 22 | use Webman\Midjourney\Task; 23 | use Workerman\Http\Client; 24 | use Workerman\Http\Response; 25 | 26 | class Attachment 27 | { 28 | 29 | /** 30 | * @var Client 31 | */ 32 | protected static $httpClient; 33 | 34 | /** 35 | * @param Task $task 36 | * @return void 37 | */ 38 | public static function upload(Task $task) 39 | { 40 | static::tryInitHttpClient(); 41 | foreach ($task->images() as $index => $url) { 42 | Log::debug("TASK:{$task->id()} Attachment download from $url"); 43 | static::$httpClient->get($url, function (Response $response) use ($task, $url, $index) { 44 | $content = (string)$response->getBody(); 45 | $statusCode = $response->getStatusCode(); 46 | if ($statusCode !== 200) { 47 | Discord::failed($task, "TASK:{$task->id()} Failed to download image from $url statusCode:$statusCode body:$content"); 48 | return; 49 | } 50 | $filename = md5($url); 51 | $path = Config::get('settings.tmpPath'); 52 | if (!$path) { 53 | Discord::failed($task, "TASK:{$task->id()} tmpPath not found"); 54 | return; 55 | } 56 | if (!is_dir($path)) { 57 | mkdir($path, 0777, true); 58 | } 59 | $path .= DIRECTORY_SEPARATOR . $filename; 60 | Log::debug("TASK:{$task->id()} Save image from $url to $path"); 61 | $ret = file_put_contents($path, $response->getBody()); 62 | if ($ret === false) { 63 | Discord::failed($task, "Failed to save image from $url to $path"); 64 | return; 65 | } 66 | if (!getimagesize($path)) { 67 | Discord::failed($task, "Invalid image from $url"); 68 | static::unlink($path); 69 | } 70 | $discord = Discord::get($task->discordId()); 71 | if (!$discord) { 72 | Discord::failed($task, "Discord instance(" . $task->discordId() . ") not found"); 73 | static::unlink($path); 74 | return null; 75 | } 76 | $uploadUrl = Discord::getServer() . "/api/v9/channels/" . $discord->channelId() . "/attachments"; 77 | Log::debug("TASK:{$task->id()} send attachments info to $uploadUrl"); 78 | static::$httpClient->request($uploadUrl, [ 79 | 'method' => 'POST', 80 | 'data' => json_encode(['files' => [[ 81 | 'filename' => 'image.png', 82 | 'file_size' => filesize($path), 83 | 'id' => 0, 84 | 'is_clip' => false 85 | ]]]), 86 | 'headers' => [ 87 | 'Authorization' => Discord::get($task->discordId())->token(), 88 | 'User-Agent' => $discord->userAgent(), 89 | 'Content-Type' => 'application/json' 90 | ], 91 | 'success' => function (Response $response) use ($task, $path, $index, $discord, $url, $uploadUrl) { 92 | $content = (string)$response->getBody(); 93 | $result = json_decode($content, true); 94 | if (!$putUrl = $result['attachments'][0]['upload_url'] ?? '') { 95 | Discord::failed($task, "Failed to send attachments info $uploadUrl, invalid response $content"); 96 | static::unlink($path); 97 | return; 98 | } 99 | $putUrl = Discord::replaceUploadUrl($putUrl); 100 | Log::debug("TASK:{$task->id()} Try upload image to $putUrl"); 101 | static::$httpClient->request($putUrl, [ 102 | 'method' => 'PUT', 103 | 'data' => file_get_contents($path), 104 | 'headers' => [ 105 | 'User-Agent' => $discord->userAgent(), 106 | 'Content-Type' => 'application/octet-stream', 107 | ], 108 | 'success' => function (Response $response) use ($task, $path, $index, $result, $putUrl, $discord, $url) { 109 | if ($response->getStatusCode() !== 200) { 110 | Discord::failed($task, "Failed to upload image to $putUrl " . $response->getBody()); 111 | static::unlink($path); 112 | return; 113 | } 114 | $data = [ 115 | 'url' => $url, 116 | 'filename' => basename($result['attachments'][0]['upload_filename']), 117 | 'upload_filename' => $result['attachments'][0]['upload_filename'], 118 | ]; 119 | $task->addAttachment($index, $data); 120 | $task->save(); 121 | if ($task->attachmentsReady()) { 122 | if ($task->action() === Task::ACTION_IMAGINE) { 123 | static::sendAttachmentMessage($task, $discord, function () use ($task) { 124 | $prompt = $task->prompt(); 125 | foreach (array_reverse($task->attachments()) as $attachment) { 126 | $prompt = "<{$attachment['cdn_url']}> " . $prompt; 127 | } 128 | $task->prompt($prompt); 129 | Log::info("TASK:{$task->id()} Send attachments message ready and execute"); 130 | Discord::execute($task); 131 | }); 132 | } else { 133 | Log::info("TASK:{$task->id()} Attachments ready and execute"); 134 | Discord::execute($task); 135 | } 136 | } 137 | static::unlink($path); 138 | }, 139 | 'error' => function (Throwable $e) use ($task, $path, $putUrl) { 140 | Discord::failed($task, "Failed to upload image to $putUrl " . $e->getMessage()); 141 | static::unlink($path); 142 | } 143 | ]); 144 | static::unlink($path); 145 | }, 146 | 'error' => function (Throwable $e) use ($task, $path) { 147 | Discord::failed($task, "Failed to upload image from $path " . $e->getMessage()); 148 | static::unlink($path); 149 | } 150 | ]); 151 | }, function(Throwable $e) use ($task, $url) { 152 | Discord::failed($task, "Failed to download image from $url " . $e->getMessage()); 153 | }); 154 | } 155 | } 156 | 157 | protected static function unlink($path) 158 | { 159 | try { 160 | if (file_exists($path)) { 161 | unlink($path); 162 | } 163 | } catch (Throwable $e) {} 164 | } 165 | 166 | protected static function tryInitHttpClient() 167 | { 168 | static::$httpClient = new Client([ 169 | 'max_conn_per_addr' => 8, 170 | 'timeout' => 120 171 | ]); 172 | } 173 | 174 | public static function sendAttachmentMessage(Task $task, Discord $discord, callable $cb) 175 | { 176 | $attachments = []; 177 | foreach ($task->attachments() as $index => $attachment) { 178 | $attachments[] = [ 179 | 'id' => (string)$index, 180 | 'filename' => $attachment['filename'], 181 | 'uploaded_filename' => $attachment['upload_filename'] 182 | ]; 183 | } 184 | $url = Discord::getServer() . '/api/v9/channels/' . $discord->channelId() . '/messages'; 185 | Log::debug("TASK:{$task->id()} Send attachment message to $url"); 186 | static::$httpClient->request($url, [ 187 | 'method' => 'POST', 188 | 'headers' => [ 189 | 'Authorization' => $discord->token(), 190 | 'Content-Type' => 'application/json' 191 | ], 192 | 'data' => json_encode([ 193 | 'content' => '', 194 | 'nonce' => Discord::uniqId(), 195 | 'channel_id' => $discord->channelId(), 196 | 'type' => 0, 197 | 'sticker_ids' => [], 198 | 'attachments' => $attachments 199 | ]), 200 | 'success' => function ($response) use ($task, $discord, $cb) { 201 | $content = (string)$response->getBody(); 202 | $json = $content ? json_decode($content, true) : null; 203 | if (!$responseAttachments = $json['attachments'] ?? null) { 204 | Discord::failed($task, "Failed to send attachment message, invalid response $content"); 205 | return; 206 | } 207 | try { 208 | $attachments = $task->attachments(); 209 | foreach ($responseAttachments as $index => $responseAttachment) { 210 | $attachments[$index]['cdn_url'] = $responseAttachment['url']; 211 | } 212 | $task->attachments($attachments); 213 | $task->save(); 214 | Log::debug("TASK:{$task->id()} Send attachment message success and try call cb"); 215 | $cb(); 216 | } catch (Throwable $e) { 217 | Discord::failed($task, "Failed to send attachment message, " . $e->getMessage()); 218 | } 219 | }, 220 | 'error' => function ($error) use ($task, $discord) { 221 | Discord::failed($task, $error->getMessage()); 222 | } 223 | ]); 224 | } 225 | 226 | } -------------------------------------------------------------------------------- /src/Service/Image.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney\Service; 17 | 18 | use Throwable; 19 | use Webman\Midjourney\Config; 20 | use Webman\Midjourney\Discord; 21 | use Webman\Midjourney\Task; 22 | use Workerman\Http\Client; 23 | 24 | class Image 25 | { 26 | 27 | public static function imagine(Task $task, Discord $discord) 28 | { 29 | if (!$task->attachmentsReady()) { 30 | Attachment::upload($task); 31 | return; 32 | } 33 | $params = static::getParams($task, $discord); 34 | $client = new Client(); 35 | $client->request(trim(Config::get('proxy.server'), ' /') . '/api/v9/interactions', static::payloadJson($task, $discord, $params)); 36 | } 37 | 38 | protected static function payloadJson(Task $task, Discord $discord, array $params) 39 | { 40 | return [ 41 | 'method' => 'POST', 42 | 'headers' => [ 43 | 'Authorization' => $discord->token(), 44 | 'User-Agent' => $discord->useragent(), 45 | ], 46 | 'data' => [ 47 | 'payload_json' => json_encode($params) 48 | ], 49 | 'success' => function ($response) use ($task, $discord) { 50 | static::successCallback($response, $task); 51 | }, 52 | 'error' => function ($error) use ($task, $discord) { 53 | Discord::failed($task, $error->getMessage()); 54 | } 55 | ]; 56 | } 57 | 58 | protected static function json(Task $task, Discord $discord, array $params) 59 | { 60 | return [ 61 | 'method' => 'POST', 62 | 'headers' => [ 63 | 'Authorization' => $discord->token(), 64 | 'Content-Type' => 'application/json', 65 | 'User-Agent' => $discord->useragent(), 66 | ], 67 | 'data' => json_encode($params), 68 | 'success' => function ($response) use ($task, $discord) { 69 | static::successCallback($response, $task); 70 | }, 71 | 'error' => function ($error) use ($task, $discord) { 72 | Discord::failed($task, $error->getMessage()); 73 | } 74 | ]; 75 | } 76 | 77 | protected static function successCallback($response, Task $task) 78 | { 79 | try { 80 | $content = (string)$response->getBody(); 81 | $json = $content ? json_decode($content, true) : null; 82 | if ($content === '' || ($json['retry_after'] ?? 0)) { 83 | $task->status(Task::STATUS_SUBMITTED)->save(); 84 | Discord::notify($task); 85 | return; 86 | } 87 | Discord::failed($task, $json ? json_encode($json, JSON_UNESCAPED_UNICODE) : $content); 88 | } catch (Throwable $e) { 89 | Discord::failed($task, (string)$e); 90 | } 91 | } 92 | 93 | public static function change(Task $task, Discord $discord) 94 | { 95 | $params = static::getParams($task, $discord); 96 | $client = new Client(); 97 | $client->request(trim(Config::get('proxy.server'), ' /') . '/api/v9/interactions', static::json($task, $discord, $params)); 98 | } 99 | 100 | public static function describe(Task $task, Discord $discord) 101 | { 102 | if (!$task->attachmentsReady()) { 103 | Attachment::upload($task); 104 | return; 105 | } 106 | $params = static::getParams($task, $discord); 107 | $client = new Client(); 108 | $client->request(trim(Config::get('proxy.server'), ' /') . '/api/v9/interactions', static::payloadJson($task, $discord, $params)); 109 | } 110 | 111 | public static function blend(Task $task, Discord $discord) 112 | { 113 | if (!$task->attachmentsReady()) { 114 | Attachment::upload($task); 115 | return; 116 | } 117 | $params = static::getParams($task, $discord); 118 | $client = new Client(); 119 | $client->request(trim(Config::get('proxy.server'), ' /') . '/api/v9/interactions', static::payloadJson($task, $discord, $params)); 120 | } 121 | 122 | public static function varyRegion(Task $task, Discord $discord) 123 | { 124 | $params = static::getParams($task, $discord); 125 | $client = new Client(); 126 | if (!($task->params()[Discord::INTERACTION_IFRAME_MODAL_CREATE] ?? '')) { 127 | $url = trim(Config::get('proxy.server'), ' /') . '/api/v9/interactions'; 128 | $client->request($url, static::payloadJson($task, $discord, $params)); 129 | return; 130 | } 131 | $url = 'https://' . DisCord::APPLICATION_ID . '.discordsays.com/inpaint/api/submit-job'; 132 | $client->request($url, [ 133 | 'method' => 'POST', 134 | 'headers' => [ 135 | 'Authorization' => $discord->token(), 136 | 'Content-Type' => 'application/json', 137 | 'User-Agent' => $discord->useragent(), 138 | ], 139 | 'data' => json_encode($params), 140 | 'success' => function ($response) use ($task, $discord) { 141 | $content = (string)$response->getBody(); 142 | if (!strpos(strtolower($content), 'success')) { 143 | Discord::failed($task, $content); 144 | } 145 | }, 146 | 'error' => function ($error) use ($task, $discord) { 147 | Discord::failed($task, $error->getMessage()); 148 | } 149 | ]); 150 | } 151 | 152 | public static function cancelJob(Task $task, Discord $discord) 153 | { 154 | $params = static::getParams($task, $discord); 155 | $client = new Client(); 156 | $client->request(trim(Config::get('proxy.server'), ' /') . '/api/v9/interactions', [ 157 | 'method' => 'POST', 158 | 'headers' => [ 159 | 'Authorization' => $discord->token(), 160 | 'Content-Type' => 'application/json', 161 | 'User-Agent' => $discord->useragent(), 162 | ], 163 | 'data' => json_encode($params), 164 | 'success' => function ($response) use ($task, $discord) { 165 | static::successCallback($response, $task); 166 | }, 167 | 'error' => function ($error) use ($task, $discord) { 168 | Discord::failed($task, $error->getMessage()); 169 | } 170 | ]); 171 | } 172 | 173 | public static function getParams(Task $task, Discord $discord) 174 | { 175 | switch ($task->action()) { 176 | case Task::ACTION_IMAGINE: 177 | return [ 178 | 'type' => 2, 179 | 'guild_id' => $discord->guildId(), 180 | 'channel_id' => $discord->channelId(), 181 | 'application_id' => Discord::APPLICATION_ID, 182 | 'session_id' => $discord->sessionId(), 183 | 'nonce' => $task->nonce(), 184 | 'data' => [ 185 | 'version' => Discord::IMAGINE_COMMAND_VERSION, 186 | 'id' => Discord::IMAGINE_COMMAND_ID, 187 | 'name' => 'imagine', 188 | 'type' => 1, 189 | 'options' => [[ 190 | 'type' => 3, 191 | 'name' => 'prompt', 192 | 'value' => $task->prompt(), 193 | ]], 194 | ], 195 | ]; 196 | case Task::ACTION_DESCRIBE: 197 | return [ 198 | 'type' => 2, 199 | 'guild_id' => $discord->guildId(), 200 | 'channel_id' => $discord->channelId(), 201 | 'application_id' => Discord::APPLICATION_ID, 202 | 'session_id' => $discord->sessionId(), 203 | 'nonce' => $task->nonce(), 204 | 'data' => [ 205 | 'version' => Discord::DESCRIBE_COMMAND_VERSION, 206 | 'id' => Discord::DESCRIBE_COMMAND_ID, 207 | 'name' => 'describe', 208 | 'type' => 1, 209 | 'options' => [[ 210 | 'type' => 11, 211 | 'name' => 'image', 212 | 'value' => 0, 213 | ]], 214 | 'attachments' => [[ 215 | 'id' => "0", 216 | 'filename' => $task->attachments()[0]['filename'], 217 | 'uploaded_filename' => $task->attachments()[0]['upload_filename'], 218 | ]], 219 | ], 220 | ]; 221 | case Task::ACTION_BLEND: 222 | $params = [ 223 | 'type' => 2, 224 | 'guild_id' => $discord->guildId(), 225 | 'channel_id' => $discord->channelId(), 226 | 'application_id' => Discord::APPLICATION_ID, 227 | 'session_id' => $discord->sessionId(), 228 | 'nonce' => $task->nonce(), 229 | 'data' => [ 230 | 'version' => Discord::BLEND_COMMAND_VERSION, 231 | 'id' => Discord::BLEND_COMMAND_ID, 232 | 'name' => 'blend', 233 | 'type' => 1, 234 | 'options' => [ 235 | ], 236 | 'attachments' => [ 237 | ], 238 | ], 239 | ]; 240 | foreach ($task->attachments() as $index => $attachment) { 241 | $params['data']['options'][] = [ 242 | 'type' => 11, 243 | 'name' => "image" . ($index + 1), 244 | 'value' => $index, 245 | ]; 246 | $params['data']['attachments'][] = [ 247 | 'id' => "$index", 248 | 'filename' => $attachment['filename'], 249 | 'uploaded_filename' => $attachment['upload_filename'], 250 | ]; 251 | } 252 | if ($task->params()['dimensions'] ?? '') { 253 | $params['data']['options'][] = [ 254 | 'type' => 3, 255 | 'name' => 'dimensions', 256 | 'value' => $task->params()['dimensions'], 257 | ]; 258 | } 259 | return $params; 260 | case Task::ACITON_ZOOMOUT_CUSTOM: 261 | case TAsk::ACTION_PIC_READER: 262 | if (!($task->params()[Discord::INTERACTION_MODAL_CREATE] ?? false)) { 263 | return [ 264 | 'type' => 3, 265 | 'message_id' => $task->params()['messageId'], 266 | 'application_id' => Discord::APPLICATION_ID, 267 | 'channel_id' => $discord->channelId(), 268 | 'guild_id' => $discord->guildId(), 269 | 'message_flags' => 0, 270 | 'session_id' => $discord->sessionId(), 271 | 'nonce' => $task->nonce(), 272 | 'data' => [ 273 | 'component_type' => 2, 274 | 'custom_id' => $task->params()['customId'], 275 | ], 276 | ]; 277 | } 278 | return [ 279 | 'type' => 5, 280 | 'application_id' => Discord::APPLICATION_ID, 281 | 'channel_id' => $discord->channelId(), 282 | 'guild_id' => $discord->guildId(), 283 | 'data' => [ 284 | 'id' => $task->params()['messageId'], 285 | 'custom_id' => $task->params()['customId'], 286 | 'components' => [[ 287 | 'type' => 1, 288 | 'components' => [[ 289 | 'type' => 4, 290 | 'custom_id' => $task->params()['componentsCustomId'], 291 | 'value' => $task->prompt(), 292 | ]] 293 | ]] 294 | ], 295 | 'session_id' => $discord->sessionId(), 296 | 'nonce' => $task->nonce(), 297 | ]; 298 | case Task::ACTION_VARIATION_REGION: 299 | if ($task->params()[Discord::INTERACTION_IFRAME_MODAL_CREATE] ?? '') { 300 | return [ 301 | 'username' => '0', 302 | 'userId' => '0', 303 | 'customId' => $task->params()['customId'], 304 | 'mask' => $task->params()['mask'], 305 | 'prompt' => $task->prompt(), 306 | 'full_prompt' => null, 307 | ]; 308 | } 309 | return [ 310 | 'type' => 3, 311 | 'guild_id' => $discord->guildId(), 312 | 'channel_id' => $discord->channelId(), 313 | 'message_id' => $task->params()['messageId'], 314 | 'application_id' => Discord::APPLICATION_ID, 315 | 'session_id' => $discord->sessionId(), 316 | 'nonce' => $task->nonce(), 317 | 'message_flags' => 0, 318 | 'data' => [ 319 | 'component_type' => 2, 320 | 'custom_id' => $task->params()['customId'], 321 | ], 322 | ]; 323 | case Task::ACTION_CANCEL_JOB: 324 | return [ 325 | 'type' => 3, 326 | 'guild_id' => $discord->guildId(), 327 | 'channel_id' => $discord->channelId(), 328 | 'message_id' => $task->params()['messageId'], 329 | 'application_id' => Discord::APPLICATION_ID, 330 | 'session_id' => $discord->sessionId(), 331 | 'nonce' => $task->nonce(), 332 | 'message_flags' => 64, 333 | 'data' => [ 334 | 'component_type' => 2, 335 | 'custom_id' => $task->params()['customId'], 336 | ], 337 | ]; 338 | default: 339 | return [ 340 | 'type' => 3, 341 | 'guild_id' => $discord->guildId(), 342 | 'channel_id' => $discord->channelId(), 343 | 'message_id' => $task->params()['messageId'], 344 | 'application_id' => Discord::APPLICATION_ID, 345 | 'session_id' => $discord->sessionId(), 346 | 'nonce' => $task->nonce(), 347 | 'message_flags' => 0, 348 | 'data' => [ 349 | 'component_type' => 2, 350 | 'custom_id' => $task->params()['customId'], 351 | ], 352 | ]; 353 | } 354 | } 355 | 356 | } -------------------------------------------------------------------------------- /src/Task.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | use Throwable; 19 | use Webman\Midjourney\Service\Image; 20 | 21 | class Task 22 | { 23 | const STATUS_PENDING = 'PENDING'; 24 | const STATUS_STARTED = 'STARTED'; 25 | const STATUS_SUBMITTED = 'SUBMITTED'; 26 | const STATUS_RUNNING = 'RUNNING'; 27 | const STATUS_FINISHED = 'FINISHED'; 28 | const STATUS_FAILED = 'FAILED'; 29 | 30 | const ACTION_IMAGINE = 'IMAGINE'; 31 | const ACTION_UPSCALE = 'UPSCALE'; 32 | const ACTION_VARIATION = 'VARIATION'; 33 | const ACTION_REROLL = 'REROLL'; 34 | const ACTION_DESCRIBE = 'DESCRIBE'; 35 | const ACTION_BLEND = 'BLEND'; 36 | const ACTION_PANLEFT = 'PANLEFT'; 37 | const ACTION_PANRIGHT = 'PANRIGHT'; 38 | const ACTION_PANUP = 'PANUP'; 39 | const ACTION_PANDOWN = 'PANDOWN'; 40 | const ACTION_MAKE_SQUARE = 'MAKE_SQUARE'; 41 | const ACTION_ZOOMOUT = 'ZOOMOUT'; 42 | const ACITON_ZOOMOUT_CUSTOM= 'ZOOMOUT_CUSTOM'; 43 | const ACTION_PIC_READER = 'PIC_READER'; 44 | const ACTION_CANCEL_JOB = 'CANCEL_JOB'; 45 | 46 | const ACTION_UPSCALE_V5_2X = 'UPSCALE_V5_2X'; 47 | const ACTION_UPSCALE_V5_4X = 'UPSCALE_V5_4X'; 48 | const ACTION_UPSCALE_V6_2X_CREATIVE = 'UPSCALE_V6_2X_CREATIVE'; 49 | const ACTION_UPSCALE_V6_2X_SUBTLE = 'UPSCALE_V6_2X_SUBTLE'; 50 | 51 | const ACTION_VARIATION_STRONG = 'VARIATION_STRONG'; 52 | const ACTION_VARIATION_SUBTLE = 'VARIATION_SUBTLE'; 53 | const ACTION_VARIATION_REGION = 'VARIATION_REGION'; 54 | 55 | 56 | /** 57 | * @var string 58 | */ 59 | protected $id; 60 | 61 | /** 62 | * @var string 63 | */ 64 | protected $action; 65 | 66 | /** 67 | * @var string 68 | */ 69 | protected $status = self::STATUS_PENDING; 70 | 71 | /** 72 | * @var string 73 | */ 74 | protected $nonce; 75 | 76 | /** 77 | * @var string 78 | */ 79 | protected $prompt; 80 | 81 | /** 82 | * @var string 83 | */ 84 | protected $finalPrompt; 85 | 86 | /** 87 | * @var string 88 | */ 89 | protected $notifyUrl; 90 | 91 | /** 92 | * @var int 93 | */ 94 | protected $submitTime; 95 | 96 | /** 97 | * @var int 98 | */ 99 | protected $startTime; 100 | 101 | /** 102 | * @var int 103 | */ 104 | protected $finishTime; 105 | 106 | /** 107 | * @var string 108 | */ 109 | protected $progress; 110 | 111 | /** 112 | * @var string 113 | */ 114 | protected $imageUrl; 115 | 116 | /** 117 | * @var string 118 | */ 119 | protected $imageRawUrl; 120 | 121 | /** 122 | * @var string 123 | */ 124 | protected $description; 125 | 126 | /** 127 | * @var string 128 | */ 129 | protected $failReason; 130 | 131 | /** 132 | * @var string 133 | */ 134 | protected $messageId; 135 | 136 | /** 137 | * @var string 138 | */ 139 | protected $messageHash; 140 | 141 | /** 142 | * @var array 143 | */ 144 | protected $params = []; 145 | 146 | /** 147 | * @var array 148 | */ 149 | protected $images = []; 150 | 151 | /** 152 | * @var array 153 | */ 154 | protected $attachments = []; 155 | 156 | /** 157 | * @var array 158 | */ 159 | protected $buttons = []; 160 | 161 | /** 162 | * @var bool 163 | */ 164 | protected $deleted = false; 165 | 166 | /** 167 | * @var string 168 | */ 169 | protected $discordId; 170 | 171 | /** 172 | * @var array 173 | */ 174 | protected $data = []; 175 | 176 | /** 177 | * @var TaskStore\TaskStoreInterface 178 | */ 179 | protected static $store; 180 | 181 | 182 | /** 183 | * @param array $config 184 | * @return void 185 | */ 186 | public static function init(array $config = []) 187 | { 188 | static::$store = new $config['handler']($config[$config['handler']], $config['expiredDates']); 189 | } 190 | 191 | /** 192 | * @param $action 193 | */ 194 | public function __construct($action) 195 | { 196 | $this->action = $action; 197 | $this->submitTime = time(); 198 | $this->id = $this->nonce = Discord::uniqId(); 199 | $this->notifyUrl = Config::get('settings.notifyUrl'); 200 | } 201 | 202 | /** 203 | * @param $id 204 | * @return $this|string 205 | */ 206 | public function id($id = null) 207 | { 208 | if ($id) { 209 | $this->id = $id; 210 | return $this; 211 | } 212 | return $this->id; 213 | } 214 | 215 | /** 216 | * @param string|null $action 217 | * @return $this|string 218 | */ 219 | public function action(?string $action = null): string 220 | { 221 | if ($action) { 222 | $this->action = $action; 223 | return $this; 224 | } 225 | return $this->action; 226 | } 227 | 228 | /** 229 | * @param $nonce 230 | * @return $this|string 231 | */ 232 | public function nonce($nonce = null) 233 | { 234 | if ($nonce) { 235 | $this->nonce = $nonce; 236 | return $this; 237 | } 238 | return $this->nonce; 239 | } 240 | 241 | /** 242 | * @param $prompt 243 | * @return $this|string 244 | */ 245 | public function prompt($prompt = null) 246 | { 247 | if ($prompt) { 248 | $this->prompt = $prompt; 249 | return $this; 250 | } 251 | return $this->prompt; 252 | } 253 | 254 | /** 255 | * @param $finalPrompt 256 | * @return $this|string 257 | */ 258 | public function finalPrompt($finalPrompt = null) 259 | { 260 | if ($finalPrompt) { 261 | $this->finalPrompt = $finalPrompt; 262 | return $this; 263 | } 264 | return $this->finalPrompt; 265 | } 266 | 267 | /** 268 | * @param $progress 269 | * @return $this|string 270 | */ 271 | public function progress($progress = null) 272 | { 273 | if ($progress) { 274 | $this->progress = $progress; 275 | return $this; 276 | } 277 | return $this->progress; 278 | } 279 | 280 | /** 281 | * @param $description 282 | * @return $this|string 283 | */ 284 | public function description($description = null) 285 | { 286 | if ($description) { 287 | $this->description = $description; 288 | return $this; 289 | } 290 | return $this->description; 291 | } 292 | 293 | /** 294 | * @param $messageId 295 | * @return $this|string 296 | */ 297 | public function messageId($messageId = null) 298 | { 299 | if ($messageId) { 300 | $this->messageId = $messageId; 301 | return $this; 302 | } 303 | return $this->messageId; 304 | } 305 | 306 | /** 307 | * @param $messageHash 308 | * @return $this|string 309 | */ 310 | public function messageHash($messageHash = null) 311 | { 312 | if ($messageHash) { 313 | $this->messageHash = $messageHash; 314 | return $this; 315 | } 316 | return $this->messageHash; 317 | } 318 | 319 | 320 | public function params($params = null) 321 | { 322 | if ($params) { 323 | $this->params = $params; 324 | return $this; 325 | } 326 | return $this->params; 327 | } 328 | 329 | public function data($data = null) 330 | { 331 | if ($data !== null) { 332 | $this->data = $data; 333 | return $this; 334 | } 335 | return $this->data; 336 | } 337 | 338 | public function buttons(?array $buttons = null) 339 | { 340 | if ($buttons !== null) { 341 | $this->buttons = $buttons; 342 | return $this; 343 | } 344 | return $this->buttons; 345 | } 346 | 347 | public function failReason($reason = null) 348 | { 349 | if ($reason) { 350 | $this->failReason = $reason; 351 | return $this; 352 | } 353 | return $this->failReason; 354 | } 355 | 356 | public function submitTime($time = null) 357 | { 358 | if ($time) { 359 | $this->submitTime = $time; 360 | return $this; 361 | } 362 | return $this->submitTime; 363 | } 364 | 365 | public function startTime($time = null) 366 | { 367 | if ($time) { 368 | $this->startTime = $time; 369 | return $this; 370 | } 371 | return $this->startTime; 372 | } 373 | 374 | public function finishTime($time = null) 375 | { 376 | if ($time) { 377 | $this->finishTime = $time; 378 | return $this; 379 | } 380 | return $this->finishTime; 381 | } 382 | 383 | public function notifyUrl($url = null) 384 | { 385 | if ($url) { 386 | $this->notifyUrl = $url; 387 | return $this; 388 | } 389 | return $this->notifyUrl; 390 | } 391 | 392 | public function status($status = null) 393 | { 394 | if ($status) { 395 | $this->status = $status; 396 | return $this; 397 | } 398 | return $this->status; 399 | } 400 | 401 | public function imageUrl($url = null) 402 | { 403 | if ($url) { 404 | $this->imageUrl = $url; 405 | return $this; 406 | } 407 | return $this->imageUrl; 408 | } 409 | 410 | public function imageRawUrl($url = null) 411 | { 412 | if ($url) { 413 | $this->imageRawUrl = $url; 414 | return $this; 415 | } 416 | return $this->imageRawUrl; 417 | } 418 | 419 | public function images(array $images = []) 420 | { 421 | if ($images) { 422 | $this->images = $images; 423 | return $this; 424 | } 425 | return $this->images; 426 | } 427 | 428 | public function attachments(array $images = []) 429 | { 430 | if ($images) { 431 | $this->attachments = $images; 432 | return $this; 433 | } 434 | return $this->attachments; 435 | } 436 | 437 | public function discordId(?string $discordId = null) 438 | { 439 | if ($discordId) { 440 | $this->discordId = $discordId; 441 | return $this; 442 | } 443 | return $this->discordId; 444 | } 445 | 446 | /** 447 | * @return array 448 | */ 449 | public function toArray(): array 450 | { 451 | return [ 452 | 'id' => $this->id, 453 | 'action' => $this->action, 454 | 'status' => $this->status, 455 | 'submitTime' => $this->submitTime, 456 | 'startTime' => $this->startTime, 457 | 'finishTime' => $this->finishTime, 458 | 'progress' => $this->progress, 459 | 'imageUrl' => $this->imageUrl, 460 | 'imageRawUrl' => $this->imageRawUrl, 461 | 'prompt' => $this->prompt, 462 | 'finalPrompt' => $this->finalPrompt, 463 | 'params' => $this->params, 464 | 'images' => $this->images, 465 | 'attachments' => $this->attachments, 466 | 'description' => $this->description, 467 | 'failReason' => $this->failReason, 468 | 'messageHash' => $this->messageHash, 469 | 'nonce' => $this->nonce, 470 | 'messageId' => $this->messageId, 471 | 'discordId' => $this->discordId, 472 | 'data' => $this->data, 473 | 'buttons' => $this->buttons, 474 | ]; 475 | } 476 | 477 | public function save() 478 | { 479 | if ($this->deleted) { 480 | return $this; 481 | } 482 | static::$store->save($this); 483 | return $this; 484 | } 485 | 486 | public function delete() 487 | { 488 | $this->deleted = true; 489 | static::$store->delete($this->id); 490 | } 491 | 492 | public static function get($taskId) 493 | { 494 | return static::$store->get($taskId); 495 | } 496 | 497 | public static function getList($listName): array 498 | { 499 | $list = static::$store->getList($listName); 500 | $tasks = []; 501 | foreach ($list as $taskId) { 502 | $task = static::$store->get($taskId); 503 | if ($task) { 504 | $tasks[$taskId] = $task; 505 | } else { 506 | static::$store->removeFromList($listName, $taskId); 507 | unset($list[$taskId]); 508 | } 509 | } 510 | return $tasks; 511 | } 512 | 513 | public function addToList($listName): Task 514 | { 515 | static::$store->addTolist($listName, $this->id()); 516 | return $this; 517 | } 518 | 519 | public function removeFromList($listName): Task 520 | { 521 | static::$store->removeFromList($listName, $this->id()); 522 | return $this; 523 | } 524 | 525 | public function addAttachment($index, array $attachment): Task 526 | { 527 | $this->attachments[$index] = $attachment; 528 | ksort($this->attachments); 529 | return $this; 530 | } 531 | 532 | public function attachmentsReady(): bool 533 | { 534 | return count($this->attachments) === count($this->images); 535 | } 536 | 537 | /** 538 | * @return false|string 539 | */ 540 | public function __toString() 541 | { 542 | return json_encode(array_merge($this->toArray(), [ 543 | 'notifyUrl' => $this->notifyUrl, 544 | ]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); 545 | } 546 | 547 | 548 | /** 549 | * @param string $jsonString 550 | * @return Task | null 551 | */ 552 | public static function unserialize(string $jsonString): ?Task 553 | { 554 | $data = json_decode($jsonString, true); 555 | if (!$data) { 556 | return null; 557 | } 558 | $task = new static($data['action']); 559 | $task->id($data['id']); 560 | $task->status($data['status']); 561 | $task->submitTime($data['submitTime']); 562 | $task->startTime($data['startTime']); 563 | $task->finishTime($data['finishTime']); 564 | $task->progress($data['progress']); 565 | $task->imageUrl($data['imageUrl']); 566 | $task->imageRawUrl($data['imageRawUrl']); 567 | $task->failReason($data['failReason']); 568 | $task->prompt($data['prompt']); 569 | $task->finalPrompt($data['finalPrompt']); 570 | $task->params($data['params']); 571 | $task->messageHash($data['messageHash']); 572 | $task->nonce($data['nonce']); 573 | $task->messageId($data['messageId']); 574 | $task->discordId($data['discordId']); 575 | $task->images($data['images'] ?? []); 576 | $task->attachments($data['attachments'] ?? []); 577 | $task->notifyUrl($data['notifyUrl']); 578 | $task->description($data['description'] ?? null); 579 | $task->buttons($data['buttons'] ?? []); 580 | $task->data($data['data'] ?? []); 581 | return $task; 582 | } 583 | } -------------------------------------------------------------------------------- /src/TaskCondition.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright walkor 12 | * @link http://www.workerman.net/ 13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | 16 | namespace Webman\Midjourney; 17 | 18 | use RuntimeException; 19 | 20 | class TaskCondition 21 | { 22 | protected $nonce; 23 | 24 | protected $action; 25 | 26 | protected $prompt; 27 | 28 | protected $finalPrompt; 29 | 30 | protected $messageId; 31 | 32 | protected $messageHash; 33 | 34 | protected $params = []; 35 | 36 | /** 37 | * @param $nonce 38 | * @return $this 39 | */ 40 | public function nonce($nonce): TaskCondition 41 | { 42 | $this->nonce = $nonce; 43 | return $this; 44 | } 45 | 46 | /** 47 | * @param $action 48 | * @return $this 49 | */ 50 | public function action($action): TaskCondition 51 | { 52 | $this->action = $action; 53 | return $this; 54 | } 55 | 56 | /** 57 | * @param $prompt 58 | * @return $this 59 | */ 60 | public function prompt($prompt): TaskCondition 61 | { 62 | $this->prompt = $prompt; 63 | return $this; 64 | } 65 | 66 | /** 67 | * @param $prompt 68 | * @return $this 69 | */ 70 | public function finalPrompt($prompt): TaskCondition 71 | { 72 | $this->finalPrompt = $prompt; 73 | return $this; 74 | } 75 | 76 | /** 77 | * @param $messageId 78 | * @return $this 79 | */ 80 | public function messageId($messageId): TaskCondition 81 | { 82 | $this->messageId = $messageId; 83 | return $this; 84 | } 85 | 86 | /** 87 | * @param $messageHash 88 | * @return $this 89 | */ 90 | public function messageHash($messageHash): TaskCondition 91 | { 92 | $this->messageHash = $messageHash; 93 | return $this; 94 | } 95 | 96 | /** 97 | * @param $data 98 | * @return $this 99 | */ 100 | public function params($data): TaskCondition 101 | { 102 | $this->params = $data; 103 | return $this; 104 | } 105 | 106 | /** 107 | * @param Task $task 108 | * @return bool 109 | */ 110 | public function match(Task $task): bool 111 | { 112 | if ($this->nonce === null && $this->action === null && $this->prompt === null && $this->finalPrompt === null && $this->messageId === null && $this->messageHash === null && empty($this->params)) { 113 | Log::error(new RuntimeException('TaskCondition is empty')); 114 | return false; 115 | } 116 | if ($this->nonce !== null && $this->nonce !== $task->nonce()) { 117 | return false; 118 | } 119 | if ($this->action !== null && $this->action !== $task->action()) { 120 | return false; 121 | } 122 | if ($this->prompt !== null && $this->prompt !== $task->prompt()) { 123 | return false; 124 | } 125 | if ($this->finalPrompt !== null && $this->finalPrompt !== $task->finalPrompt()) { 126 | return false; 127 | } 128 | if ($this->messageId !== null && $this->messageId !== $task->messageId()) { 129 | return false; 130 | } 131 | if ($this->messageHash !== null && $this->messageHash !== $task->messageHash()) { 132 | return false; 133 | } 134 | // 只有prompt条件时只查找messageHash为空的任务 135 | if ($this->prompt !== null && $this->nonce === null && $this->messageId === null && $this->messageHash === null && $task->messageHash()) { 136 | return false; 137 | } 138 | // 只有finalPrompt条件时只查找messageHash为空的任务 139 | if ($this->finalPrompt !== null && $this->nonce === null && $this->messageId === null && $this->messageHash === null && $task->messageHash()) { 140 | return false; 141 | } 142 | $params = $task->params(); 143 | foreach ($this->params as $key => $value) { 144 | if (!isset($params[$key]) || $params[$key] != $value) { 145 | return false; 146 | } 147 | } 148 | return true; 149 | } 150 | } -------------------------------------------------------------------------------- /src/TaskStore/File.php: -------------------------------------------------------------------------------- 1 | taskPath = $path . DIRECTORY_SEPARATOR . 'tasks'; 40 | $this->listPath = $path . DIRECTORY_SEPARATOR . 'lists'; 41 | $this->cacheSize = $config['cacheSize'] ?? $this->cacheSize; 42 | $this->mkdir(); 43 | $this->expiredDates = $expiredDates ?: $this->expiredDates; 44 | $this->createClearTaskTimer(); 45 | } 46 | 47 | protected function mkdir() 48 | { 49 | $paths = [$this->taskPath, $this->listPath]; 50 | foreach ($paths as $path) { 51 | if (!is_dir($path) && !mkdir($path, 0777, true)) { 52 | throw new BusinessException("Make dir $path fail"); 53 | } 54 | } 55 | } 56 | 57 | protected function createClearTaskTimer() 58 | { 59 | // 找到第二天凌晨1点的时间戳 60 | $seconds = strtotime(date('Y-m-d', time() + 86400)) - time() + 3600; 61 | Timer::add($seconds, function () { 62 | $this->clearExpiredTasks($this->expiredDates); 63 | }, [], false); 64 | } 65 | 66 | protected function clearExpiredTasks($expiredDates) { 67 | $this->createClearTaskTimer(); 68 | $dirs = scandir($this->taskPath); 69 | try { 70 | foreach ($dirs as $dir) { 71 | if ($dir === '.' || $dir === '..') { 72 | continue; 73 | } 74 | $path = $this->taskPath . DIRECTORY_SEPARATOR . $dir; 75 | if (is_dir($path)) { 76 | $date = strtotime($dir); 77 | if ($date < time() - $expiredDates * 86400) { 78 | // 删除这个目录的所有文件 79 | $files = scandir($path); 80 | foreach ($files as $file) { 81 | if ($file === '.' || $file === '..') { 82 | continue; 83 | } 84 | $file = $path . DIRECTORY_SEPARATOR . $file; 85 | if (is_file($file)) { 86 | unlink($file); 87 | } 88 | } 89 | remove_dir($path); 90 | } 91 | } 92 | } 93 | } catch (Throwable $e) { 94 | Log::error($e); 95 | } 96 | } 97 | 98 | 99 | public function get(string $taskId): ?Task 100 | { 101 | if (isset($this->taskCaches[$taskId])) { 102 | return $this->taskCaches[$taskId]; 103 | } 104 | $file = $this->getTaskFilePath($taskId); 105 | if (!$file || !is_file($file)) { 106 | return null; 107 | } 108 | $data = file_get_contents($file); 109 | $task = $data ? Task::unserialize($data) : null; 110 | if ($task) { 111 | $this->cacheTask($task); 112 | } 113 | return $task; 114 | } 115 | 116 | public function save(Task $task) 117 | { 118 | $file = $this->getTaskFilePath($task->id()); 119 | if (!$file) { 120 | return; 121 | } 122 | file_put_contents($file, $task); 123 | $this->cacheTask($task); 124 | } 125 | 126 | 127 | public function delete(string $taskId) 128 | { 129 | unset($this->taskCaches[$taskId]); 130 | $file = $this->getTaskFilePath($taskId); 131 | if (!$file) { 132 | return; 133 | } 134 | if (is_file($file)) { 135 | unlink($file); 136 | } 137 | } 138 | 139 | public function getList($listName): array 140 | { 141 | if (isset($this->listCaches[$listName])) { 142 | return $this->listCaches[$listName]; 143 | } 144 | $file = $this->getListFilePath($listName); 145 | if (is_file($file) && $content = file_get_contents($file)) { 146 | return json_decode($content, true) ?: []; 147 | } 148 | return []; 149 | } 150 | 151 | public function addToList($listName, $taskId): array 152 | { 153 | $list = $this->getList($listName); 154 | if (in_array($taskId, $list)) { 155 | return $list; 156 | } 157 | $list[] = $taskId; 158 | $this->saveList($listName, $list); 159 | return $list; 160 | } 161 | 162 | public function removeFromList($listName, $taskId): array 163 | { 164 | $list = $this->getList($listName); 165 | foreach ($list as $key => $item) { 166 | if ($taskId === $item) { 167 | unset($list[$key]); 168 | } 169 | } 170 | $list = array_values($list); 171 | $this->saveList($listName, $list); 172 | return $list; 173 | } 174 | 175 | protected function saveList($listName, array $list) 176 | { 177 | $this->listCaches[$listName] = $list; 178 | $this->cacheList($listName, $list); 179 | $file = $this->getListFilePath($listName); 180 | file_put_contents($file, json_encode($list)); 181 | } 182 | 183 | /** 184 | * @param $taskId 185 | * @return string 186 | */ 187 | protected function getTaskFilePath($taskId): string 188 | { 189 | if (!$this->checkTaskId($taskId)) { 190 | return ''; 191 | } 192 | $time = substr($taskId, 0, 10); 193 | $date = date('Y-m-d', $time); 194 | $path = $this->taskPath . DIRECTORY_SEPARATOR . $date; 195 | if (!is_dir($path) && !mkdir($path, 0777, true)) { 196 | Log::error(new \Exception("Make dir $path fail")); 197 | return ''; 198 | } 199 | return $path . DIRECTORY_SEPARATOR . "task-$taskId"; 200 | } 201 | 202 | /** 203 | * @param $listName 204 | * @return string 205 | * @throws BusinessException 206 | */ 207 | protected function getListFilePath($listName): string 208 | { 209 | $this->checkListId($listName); 210 | return $this->listPath . DIRECTORY_SEPARATOR . $listName; 211 | } 212 | 213 | /** 214 | * @param Task $task 215 | * @return void 216 | */ 217 | protected function cacheTask(Task $task) 218 | { 219 | $this->taskCaches[$task->id()] = $task; 220 | if (count($this->taskCaches) > $this->cacheSize) { 221 | reset($this->taskCaches); 222 | unset($this->taskCaches[key($this->taskCaches)]); 223 | } 224 | } 225 | 226 | /** 227 | * @param $listName 228 | * @param $list 229 | * @return void 230 | */ 231 | protected function cacheList($listName, $list) 232 | { 233 | $this->listCaches[$listName] = $list; 234 | if (count($this->listCaches) > $this->cacheSize) { 235 | reset($this->listCaches); 236 | unset($this->listCaches[key($this->listCaches)]); 237 | } 238 | } 239 | 240 | /** 241 | * 判断taskId是否合法 242 | * @param $taskId 243 | * @return bool 244 | */ 245 | protected function checkTaskId($taskId): bool 246 | { 247 | if (!preg_match('/^\d+$/', $taskId)) { 248 | Log::error(new \Exception('Bad taskId ' . var_export($taskId, true))); 249 | return false; 250 | } 251 | return true; 252 | } 253 | 254 | /** 255 | * 判断taskId是否合法 256 | * @param $listId 257 | * @return void 258 | * @throws BusinessException 259 | */ 260 | protected function checkListId($listId) 261 | { 262 | if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $listId)) { 263 | throw new BusinessException('Bad listId ' . var_export($listId, true)); 264 | } 265 | } 266 | } -------------------------------------------------------------------------------- /src/TaskStore/TaskStoreInterface.php: -------------------------------------------------------------------------------- 1 | true, 4 | ]; -------------------------------------------------------------------------------- /src/config/plugin/webman/midjourney/banned-words.txt: -------------------------------------------------------------------------------- 1 | making love 2 | no clothes 3 | no shirt 4 | bare 5 | barely dressed 6 | nude 7 | bra 8 | wearing nothing 9 | with no shirt 10 | naked 11 | without clothes on 12 | negligee 13 | zero clothes -------------------------------------------------------------------------------- /src/config/plugin/webman/midjourney/log.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | return [ 16 | 'error' => [ 17 | 'handlers' => [ 18 | [ 19 | 'class' => Monolog\Handler\RotatingFileHandler::class, 20 | 'constructor' => [ 21 | runtime_path() . '/logs/midjourney/midjourney.error.log', 22 | 7, //$maxFiles 23 | Monolog\Logger::DEBUG, 24 | ], 25 | 'formatter' => [ 26 | 'class' => Monolog\Formatter\LineFormatter::class, 27 | 'constructor' => [null, 'Y-m-d H:i:s', true], 28 | ], 29 | ] 30 | ], 31 | ], 32 | 'info' => [ 33 | 'handlers' => [ 34 | [ 35 | 'class' => Monolog\Handler\RotatingFileHandler::class, 36 | 'constructor' => [ 37 | runtime_path() . '/logs/midjourney/midjourney.info.log', 38 | 5, //$maxFiles 39 | Monolog\Logger::DEBUG, 40 | ], 41 | 'formatter' => [ 42 | 'class' => Monolog\Formatter\LineFormatter::class, 43 | 'constructor' => [null, 'Y-m-d H:i:s', true], 44 | ], 45 | ] 46 | ], 47 | ], 48 | 'debug' => [ 49 | 'handlers' => [ 50 | [ 51 | 'class' => Monolog\Handler\RotatingFileHandler::class, 52 | 'constructor' => [ 53 | runtime_path() . '/logs/midjourney/midjourney.debug.log', 54 | 1, //$maxFiles 55 | Monolog\Logger::DEBUG, 56 | ], 57 | 'formatter' => [ 58 | 'class' => Monolog\Formatter\LineFormatter::class, 59 | 'constructor' => [null, 'Y-m-d H:i:s', true], 60 | ], 61 | ] 62 | ], 63 | ], 64 | ]; 65 | -------------------------------------------------------------------------------- /src/config/plugin/webman/midjourney/process.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'handler' => Webman\Midjourney\Server::class, 8 | 'listen' => 'http://0.0.0.0:8686', 9 | 'reloadable' => false, 10 | 'constructor' => [ 11 | 'config' => [ 12 | 'accounts' => [ 13 | [ 14 | 'enable' => true, 15 | 'token' => '', 16 | 'guild_id' => '', 17 | 'channel_id' => '', 18 | 'useragent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.30 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.30', 19 | 'concurrency' => 3, // 并发数 20 | 'timeoutMinutes' => 10, // 10分钟后超时 21 | ] 22 | ], 23 | 'proxy' => [ 24 | 'server' => 'https://discord.com', // 国内需要代理 25 | 'cdn' => 'https://cdn.discordapp.com', // 国内需要代理 26 | 'gateway' => 'wss://gateway.discord.gg', // 国内需要代理 27 | 'upload' => 'https://discord-attachments-uploads-prd.storage.googleapis.com', // 国内需要代理 28 | ], 29 | 'store' => [ 30 | 'handler' => File::class, 31 | 'expiredDates' => 30, // 30天后过期 32 | File::class => [ 33 | 'dataPath' => runtime_path() . '/data/midjourney', 34 | ] 35 | ], 36 | 'settings' => [ 37 | 'debug' => false, // 调试模式会显示更多信息在终端 38 | 'secret' => '', // 接口密钥,不为空时需要在请求头 mj-api-secret 中传递 39 | 'notifyUrl' => '', // webman ai项目请留空 40 | 'apiPrefix' => '', // 接口前缀 41 | 'tmpPath' => runtime_path() . '/tmp/midjourney' // 上传文件临时目录 42 | ] 43 | ] 44 | ] 45 | ] 46 | ]; 47 | --------------------------------------------------------------------------------