360 lines
13 KiB
PHP
360 lines
13 KiB
PHP
|
<?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;
|
||
|
}
|
||
|
}
|