<?php /** * * AI Labs extension * * @copyright (c) 2023, privet.fun, https://privet.fun * @license GNU General Public License, version 2 (GPL-2.0) * */ namespace privet\ailabs\controller; use privet\ailabs\includes\GenericCurl; use privet\ailabs\includes\GenericController; use privet\ailabs\includes\resultSubmit; use privet\ailabs\includes\resultParse; use Symfony\Component\HttpFoundation\JsonResponse; /* // How to get api token and configure Discord // https://useapi.net/docs/start-here config: { "api_key": "<useapi.net api token>", "url_imagine": "https://api.useapi.net/v1/jobs/imagine", "url_button": "https://api.useapi.net/v1/jobs/button", "discord": "<Discord token, required>", "server": "<Discord server id, required>", "channel": "<Discord channel id, required>", "maxJobs": "<Midjourney subscription plan Maximum Concurrent Jobs, optional, default 3>", "retryCount": "<Maximum attempts to submit request, optional, default 80>", "timeoutBeforeRetrySec": "<Time to wait before next retry, optional, default 15>", } template: [quote={poster_name} post_id={post_id} user_id={poster_id}]{request}[/quote] {response} {images} {info} */ class midjourney extends GenericController { /** * @return \Symfony\Component\HttpFoundation\Response A Symfony Response object */ public function callback($job_id, $ref, $action) { $this->job_id = $job_id; $this->load_job(); if (empty($this->job)) return new JsonResponse('job_id ' . $job_id . ' not found in the database'); if ($this->job['ref'] !== $ref) return new JsonResponse('wrong reference ' . $ref); if (in_array($this->job['status'], ['ok', 'failed'])) return new JsonResponse('job_id ' . $job_id . ' already has final status ' . $this->job['status']); $this->log = json_decode($this->job['log'], true); // POST body as json $data = json_decode(file_get_contents('php://input'), true); $json = null; switch ($action) { case 'posted': $response_codes = null; // Store entire posted response into log foreach ($data as $key => $value) { $this->log[$key] = $value; if ($key === 'response.json') $json = $value; if ($key === 'response.codes') { $response_codes = $value; // We may get no response body at all in some cases if (!in_array(200, $response_codes)) $this->job['status'] = 'failed'; } } $response_message_id = $this->process_response_message_id($json); // https://useapi.net/docs/api-v1/jobs-button // HTTP 409 Conflict // Button <U1 | U2 | U3 | U4> already executed by job <jobid> if (!empty($response_message_id) && !empty($response_codes) && in_array(409, $response_codes)) { $sql = 'SELECT j.response_post_id FROM ' . $this->jobs_table . ' j WHERE ' . $this->db->sql_build_array('SELECT', ['response_message_id' => $response_message_id]); $result = $this->db->sql_query($sql); $row = $this->db->sql_fetchrow($result); $this->db->sql_freeresult($result); if (!empty($row)) { $viewtopic = "{$this->root_path}viewtopic.{$this->php_ext}"; $json['response'] = $this->language->lang('AILABS_MJ_BUTTON_ALREADY_USED', $json['button'], $viewtopic, $row['response_post_id']); } } break; case 'reply': // Raw response from useapi.net /imagine or /button API endpoints $json = $data; // Upscale buttons U1..U4 may create race condition, let's rely on .../posted to process response if (!empty($json) && !empty($json['code']) && $json['code'] === 409) return new JsonResponse('Skipping 409'); $this->process_response_message_id($json); $this->log['response.json'] = $json; $this->log['response.time'] = date('Y-m-d H:i:s'); break; } // Assume the worst $this->job['status'] = 'failed'; $this->job['response'] = $this->language->lang('AILABS_ERROR_CHECK_LOGS'); if (!empty($json)) { if (!empty($json['status'])) switch ($json['status']) { case 'created': case 'started': case 'progress': $this->job['status'] = 'exec'; break; case 'completed': $this->job['status'] = 'ok'; break; } if (!empty($json['code'])) switch ($json['code']) { case 200: // HTTP OK $this->job['response'] = preg_replace('/<@(\d+)>/', '', $json['content']); break; case 422: // HTTP 422 Unprocessable Content - Moderated $this->job['response'] = $this->language->lang('AILABS_MJ_MODERATED'); break; } } if (!empty($json) && in_array($this->job['status'], ['ok', 'failed'])) { $resultParse = new resultParse(); $resultParse->message = $this->job['response']; // Only attach successfully generated images, seems like all other images will be deleted from Discord CDN if (($this->job['status'] == 'ok') && !empty($json['attachments'])) { $url_adjusted = (string) $json['attachments'][0]['url']; $url_adjusted = preg_replace('/\?.*$/', '', $url_adjusted); $resultParse->images = array($url_adjusted); } if (!empty($json['buttons'])) $resultParse->info = $this->language->lang('AILABS_MJ_BUTTONS') . implode(" • ", $json['buttons']); $response = $this->replace_vars($this->job, $resultParse); $data = $this->post_response($this->job, $response); $this->job['response_post_id'] = $data['post_id']; } $set = [ 'status' => $this->job['status'], 'response' => utf8_encode_ucr($this->job['response']), 'response_time' => time(), 'response_post_id' => $this->job['response_post_id'], 'log' => json_encode($this->log) ]; $this->job_update($set); $this->post_update($this->job); return new JsonResponse($this->log); } protected function prepare($opts) { $pattern = '/<QUOTE\sauthor="' . $this->job['ailabs_username'] . '"\spost_id="(.*)"\stime="(.*)"\suser_id="' . $this->job['ailabs_user_id'] . '">/'; $parent_job = null; $matches = null; preg_match_all( $pattern, $this->job['post_text'], $matches ); if (!empty($matches) && !empty($matches[1][0])) { $response_post_id = (int) $matches[1][0]; $sql = 'SELECT j.job_id, j.response_post_id, j.log, j.response ' . 'FROM ' . $this->jobs_table . ' j ' . 'WHERE ' . $this->db->sql_build_array('SELECT', ['response_post_id' => $response_post_id]); $result = $this->db->sql_query($sql); $parent_job = $this->db->sql_fetchrow($result); $this->db->sql_freeresult($result); // Remove quoted content from the quoted post $post_text = sprintf( '<r><QUOTE author="%1$s" post_id="%2$s" time="%3$s" user_id="%4$s"><s>[quote=%1$s post_id=%2$s time=%3$s user_id=%4$s]</s>%6$s<e>[/quote]</e></QUOTE>%5$s</r>', $this->job['ailabs_username'], (string) $response_post_id, (string) $this->job['post_time'], (string) $this->job['ailabs_user_id'], $this->job['request'], $parent_job ? utf8_decode_ncr($parent_job['response']) : '...' ); $sql = 'UPDATE ' . POSTS_TABLE . ' SET ' . $this->db->sql_build_array('UPDATE', ['post_text' => utf8_encode_ucr($post_text)]) . ' WHERE post_id = ' . (int) $this->job['post_id']; $result = $this->db->sql_query($sql); $this->db->sql_freeresult($result); } $maxJobs = empty($this->cfg->maxJobs) ? 3 : $this->cfg->maxJobs; $url_callback = generate_board_url(true) . $this->helper->route( 'privet_midjourney_callback', [ 'job_id' => $this->job_id, 'ref' => $this->job['ref'], 'action' => 'reply' ] ); $request = $this->job['request']; $payload = null; if (!empty($parent_job)) { $log = json_decode($parent_job['log'], true); // https://useapi.net/docs/api-v1/jobs-button if ( !empty($log) && !empty($log['response.json']) && !empty($log['response.json']['jobid']) && !empty($log['response.json']['buttons']) && in_array($request, $log['response.json']['buttons'], true) ) { $payload = [ 'jobid' => $log['response.json']['jobid'], 'button' => $request, 'discord' => $this->cfg->discord, 'maxJobs' => $maxJobs, 'replyUrl' => $url_callback, 'replyRef' => $this->job_id, ]; } } // https://useapi.net/docs/api-v1/jobs-imagine if (empty($payload)) { $payload = [ 'prompt' => $request, 'discord' => $this->cfg->discord, 'server' => $this->cfg->server, 'channel' => $this->cfg->channel, 'maxJobs' => $maxJobs, 'replyUrl' => $url_callback, 'replyRef' => $this->job_id, ]; } array_push($this->redactOpts, 'discord'); return $payload; } protected function submit($opts): resultSubmit { $this->job['status'] = 'query'; $this->job_update(['status' => $this->job['status']]); $this->post_update($this->job); $api = new GenericCurl($this->cfg->api_key, 0); $this->cfg->api_key = null; $retryCount = empty($this->cfg->retryCount) ? 80 : $this->cfg->retryCount; $timeoutBeforeRetrySec = empty($this->cfg->timeoutBeforeRetrySec) ? 15 : $this->cfg->timeoutBeforeRetrySec; $count = 0; $response = null; // https://useapi.net/docs/api-v1/jobs-imagine // https://useapi.net/docs/api-v1/jobs-button $url = empty($opts['jobid']) ? $this->cfg->url_imagine : $this->cfg->url_button; // Attempt to submit request for (retryCount * timeoutBeforeRetrySec) seconds. // Required for cases where multiple users simultaneously submitting requests or Midjourney query is full. do { $count++; $response = $api->sendRequest($url, 'POST', $opts); } while ( // 429: Maximum of xx jobs executing in parallel supported // 504: Unable to lock Discord after xx attempts (in_array(429, $api->responseCodes) || in_array(504, $api->responseCodes)) && $count < $retryCount && sleep($timeoutBeforeRetrySec) !== false ); $data = [ 'request.time' => date('Y-m-d H:i:s'), 'request.config.retryCount' => $retryCount, 'request.config.timeoutBeforeRetrySec' => $timeoutBeforeRetrySec, 'request.attempts' => $count, 'response.codes' => $api->responseCodes, 'response.length' => strlen($response), 'response.json' => json_decode($response) ]; $url_callback = generate_board_url(true) . $this->helper->route( 'privet_midjourney_callback', [ 'job_id' => $this->job_id, 'ref' => $this->job['ref'], 'action' => 'posted' ] ); $api->sendRequest($url_callback, 'POST', $data); $result = new resultSubmit(); $result->ignore = true; return $result; } protected function process_response_message_id($json) { $response_message_id = null; if (!empty($json) && !empty($json['jobid'])) $response_message_id = $json['jobid']; if (!empty($response_message_id) && empty($this->job['response_message_id'])) $this->job_update(['response_message_id' => $response_message_id]); return $response_message_id; } }