├── docs ├── img_10.png ├── img_8.png ├── img_9.png ├── wxcode.png ├── params_user.png ├── receipt-code.png ├── manager-qrcode.png ├── railway_img_1.png ├── railway_img_10.png ├── railway_img_11.png ├── railway_img_12.png ├── railway_img_2.png ├── railway_img_3.png ├── railway_img_4.png ├── railway_img_5.png ├── railway_img_6.png ├── railway_img_7.png ├── railway_img_8.png ├── railway_img_9.png ├── discord-params.md ├── docker-start.md ├── railway-start.md ├── zeabur-start.md ├── api.md └── config.md ├── src └── main │ ├── resources │ ├── application.yml │ ├── api-params │ │ ├── message.json │ │ ├── move.json │ │ ├── reroll.json │ │ ├── vary.json │ │ ├── upscale.json │ │ ├── variation.json │ │ ├── blend.json │ │ ├── upscale2.json │ │ ├── upscale4.json │ │ ├── zoomout.json │ │ ├── settings.json │ │ ├── imagine.json │ │ ├── describe.json │ │ ├── info.json │ │ └── list-settings.json │ ├── banner.txt │ ├── config │ │ └── application.yml │ ├── mime.types │ └── banned-words.txt │ └── java │ ├── com │ └── github │ │ └── novicezk │ │ └── midjourney │ │ ├── wss │ │ ├── WebSocketStarter.java │ │ ├── user │ │ │ ├── FailureCallback.java │ │ │ ├── SuccessCallback.java │ │ │ ├── UserMessageListener.java │ │ │ ├── SpringUserWebSocketStarter.java │ │ │ └── SpringWebSocketHandler.java │ │ └── handle │ │ │ ├── ImagineSuccessHandler.java │ │ │ ├── BlendSuccessHandler.java │ │ │ ├── ZoomSuccessHandler.java │ │ │ ├── MoveSuccessHandler.java │ │ │ ├── VariationSuccessHandler.java │ │ │ ├── RerollSuccessHandler.java │ │ │ ├── StartAndProgressHandler.java │ │ │ ├── UpscaleSuccessHandler.java │ │ │ ├── DescribeSuccessHandler.java │ │ │ ├── AccountInfoHandler.java │ │ │ ├── ErrorMessageHandler.java │ │ │ └── MessageHandler.java │ │ ├── util │ │ ├── ContentParseData.java │ │ ├── UVContentParseData.java │ │ ├── TaskChangeParams.java │ │ ├── SettingsContants.java │ │ ├── MimeTypeUtils.java │ │ ├── BannedPromptUtils.java │ │ ├── AsyncLockUtils.java │ │ ├── ConvertUtils.java │ │ └── SnowFlake.java │ │ ├── exception │ │ ├── BannedPromptException.java │ │ └── SnowFlakeException.java │ │ ├── service │ │ ├── NotifyService.java │ │ ├── TranslateService.java │ │ ├── translate │ │ │ ├── NoTranslateServiceImpl.java │ │ │ ├── BaiduTranslateServiceImpl.java │ │ │ └── GPTTranslateServiceImpl.java │ │ ├── TaskStoreService.java │ │ ├── TaskService.java │ │ ├── DiscordService.java │ │ ├── store │ │ │ ├── InMemoryTaskStoreServiceImpl.java │ │ │ └── RedisTaskStoreServiceImpl.java │ │ └── NotifyServiceImpl.java │ │ ├── enums │ │ ├── TranslateWay.java │ │ ├── BlendDimensions.java │ │ ├── MessageType.java │ │ ├── TaskStatus.java │ │ ├── EnumConstant.java │ │ ├── TaskAction.java │ │ └── SettingsEnum.java │ │ ├── dto │ │ ├── TaskConditionDTO.java │ │ ├── BaseSubmitDTO.java │ │ ├── SubmitDescribeDTO.java │ │ ├── SettingsDTO.java │ │ ├── SubmitSimpleChangeDTO.java │ │ ├── SubmitZoomDTO.java │ │ ├── SubmitVaryDTO.java │ │ ├── SubmitImagineDTO.java │ │ ├── SubmitMoveDTO.java │ │ ├── SubmitBlendDTO.java │ │ └── SubmitChangeDTO.java │ │ ├── loadbalancer │ │ ├── rule │ │ │ ├── IRule.java │ │ │ ├── RoundRobinRule.java │ │ │ └── BestWaitIdleRule.java │ │ ├── DiscordInstance.java │ │ ├── DiscordLoadBalancer.java │ │ └── DiscordInstanceImpl.java │ │ ├── ProxyApplication.java │ │ ├── ReturnCode.java │ │ ├── support │ │ ├── SpringContextHolder.java │ │ ├── ApiAuthorizeInterceptor.java │ │ ├── TaskTimeoutSchedule.java │ │ ├── Task.java │ │ ├── DiscordAccountHelper.java │ │ ├── TaskCondition.java │ │ ├── DiscordAccountInitializer.java │ │ └── DiscordHelper.java │ │ ├── Constants.java │ │ ├── domain │ │ ├── DiscordAccount.java │ │ └── DomainObject.java │ │ ├── result │ │ ├── Message.java │ │ └── SubmitResultVO.java │ │ ├── controller │ │ ├── AccountController.java │ │ └── TaskController.java │ │ └── ProxyProperties.java │ └── spring │ └── config │ ├── WebMvcConfig.java │ └── BeanConfig.java ├── .dockerignore ├── .gitignore ├── docker ├── build-manifest.sh ├── build-image.sh └── Dockerfile ├── Dockerfile ├── README.md ├── .github └── workflows │ └── docker-image.yml ├── README_CN.md └── pom.xml /docs/img_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/img_10.png -------------------------------------------------------------------------------- /docs/img_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/img_8.png -------------------------------------------------------------------------------- /docs/img_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/img_9.png -------------------------------------------------------------------------------- /docs/wxcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/wxcode.png -------------------------------------------------------------------------------- /docs/params_user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/params_user.png -------------------------------------------------------------------------------- /docs/receipt-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/receipt-code.png -------------------------------------------------------------------------------- /docs/manager-qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/manager-qrcode.png -------------------------------------------------------------------------------- /docs/railway_img_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_1.png -------------------------------------------------------------------------------- /docs/railway_img_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_10.png -------------------------------------------------------------------------------- /docs/railway_img_11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_11.png -------------------------------------------------------------------------------- /docs/railway_img_12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_12.png -------------------------------------------------------------------------------- /docs/railway_img_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_2.png -------------------------------------------------------------------------------- /docs/railway_img_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_3.png -------------------------------------------------------------------------------- /docs/railway_img_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_4.png -------------------------------------------------------------------------------- /docs/railway_img_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_5.png -------------------------------------------------------------------------------- /docs/railway_img_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_6.png -------------------------------------------------------------------------------- /docs/railway_img_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_7.png -------------------------------------------------------------------------------- /docs/railway_img_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_8.png -------------------------------------------------------------------------------- /docs/railway_img_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imkratos/midjourney-proxy/HEAD/docs/railway_img_9.png -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | mj: 2 | task-store: 3 | type: in_memory 4 | timeout: 30d 5 | translate-way: null 6 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/WebSocketStarter.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss; 2 | 3 | 4 | public interface WebSocketStarter { 5 | 6 | void start() throws Exception; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/user/FailureCallback.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.user; 2 | 3 | 4 | public interface FailureCallback { 5 | void onFailure(int code, String reason); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/user/SuccessCallback.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.user; 2 | 3 | 4 | public interface SuccessCallback { 5 | 6 | void onSuccess(String sessionId, Object sequence, String resumeGatewayUrl); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/ContentParseData.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ContentParseData { 7 | protected String prompt; 8 | protected String status; 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/exception/BannedPromptException.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.exception; 2 | 3 | public class BannedPromptException extends Exception { 4 | 5 | public BannedPromptException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/NotifyService.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service; 2 | 3 | 4 | import com.github.novicezk.midjourney.support.Task; 5 | 6 | public interface NotifyService { 7 | 8 | void notifyTaskChange(Task task); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | ### IntelliJ IDEA ### 4 | .idea 5 | *.iws 6 | *.iml 7 | *.ipr 8 | 9 | ### VS Code ### 10 | .vscode/ 11 | 12 | ### Macos ### 13 | .DS_Store 14 | 15 | ### application config # 16 | config/application.yml 17 | 18 | .git 19 | .gitignore 20 | docker 21 | docs 22 | README.md -------------------------------------------------------------------------------- /docs/discord-params.md: -------------------------------------------------------------------------------- 1 | ## 获取discord配置参数 2 | 3 | ### 1. 获取用户Token 4 | 进入频道,打开network,刷新页面,找到 `messages` 的请求,这里的 authorization 即用户Token,后续设置到 `mj.discord.user-token` 5 | 6 | ![User Token](img_8.png) 7 | 8 | ### 2. 获取服务器ID、频道ID 9 | 10 | 频道的url里取出 服务器ID、频道ID,后续设置到配置项 11 | ![Guild Channel ID](img_9.png) 12 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/TranslateWay.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | 4 | public enum TranslateWay { 5 | /** 6 | * 百度翻译. 7 | */ 8 | BAIDU, 9 | /** 10 | * GPT翻译. 11 | */ 12 | GPT, 13 | /** 14 | * 不翻译. 15 | */ 16 | NULL 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/api-params/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "content":"$content", 3 | "channel_id":"$channel_id", 4 | "type":0, 5 | "sticker_ids":[], 6 | "attachments":[ 7 | { 8 | "id":"0", 9 | "filename": "$file_name", 10 | "uploaded_filename": "$final_file_name" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/UVContentParseData.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | 6 | @Data 7 | @EqualsAndHashCode(callSuper = true) 8 | public class UVContentParseData extends ContentParseData { 9 | protected Integer index; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/TaskConditionDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import io.swagger.annotations.ApiModel; 4 | import lombok.Data; 5 | 6 | import java.util.List; 7 | 8 | @Data 9 | @ApiModel("任务查询参数") 10 | public class TaskConditionDTO { 11 | 12 | private List ids; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/loadbalancer/rule/IRule.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.loadbalancer.rule; 2 | 3 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 4 | 5 | import java.util.List; 6 | 7 | public interface IRule { 8 | 9 | DiscordInstance choose(List instances); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/TaskChangeParams.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import com.github.novicezk.midjourney.enums.TaskAction; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class TaskChangeParams { 8 | private String id; 9 | private TaskAction action; 10 | private Integer index; 11 | } 12 | -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | , /) , 3 | ___ _(/ ___ __ __ _ __ __ _____/ 4 | // (__(_(_(_ /_(_)(_(_/ (_/ (__(/_(_/_ /_)_/ (_(_) /(__(_/_ 5 | .-/ .-/ .-/ / .-/ 6 | (_/ (_/ (_/ (_/ 7 | 8 | :: MidJourney Proxy :: v2.6.2 9 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/TranslateService.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | public interface TranslateService { 6 | 7 | String translateToEnglish(String prompt); 8 | 9 | default boolean containsChinese(String prompt) { 10 | return Pattern.compile("[\u4e00-\u9fa5]").matcher(prompt).find(); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/resources/api-params/move.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "nonce": "$nonce", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "message_flags": 0, 7 | "message_id": "$message_id", 8 | "application_id": "936929561302675456", 9 | "session_id": "$session_id", 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::$move::1::$message_hash::SOLO" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/reroll.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "guild_id": "$guild_id", 4 | "channel_id": "$channel_id", 5 | "message_id": "$message_id", 6 | "application_id": "936929561302675456", 7 | "session_id": "$session_id", 8 | "nonce": "$nonce", 9 | "message_flags": 0, 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::reroll::0::$message_hash::SOLO" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/vary.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "nonce": "$nonce", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "message_flags": 0, 7 | "message_id": "$message_id", 8 | "application_id": "936929561302675456", 9 | "session_id": "$session_id", 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::$vary::1::$message_hash::SOLO" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/upscale.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "guild_id": "$guild_id", 4 | "channel_id": "$channel_id", 5 | "message_id": "$message_id", 6 | "application_id": "936929561302675456", 7 | "session_id": "$session_id", 8 | "nonce": "$nonce", 9 | "message_flags": 0, 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::upsample::$index::$message_hash" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/variation.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "guild_id": "$guild_id", 4 | "channel_id": "$channel_id", 5 | "message_id": "$message_id", 6 | "application_id": "936929561302675456", 7 | "session_id": "$session_id", 8 | "nonce": "$nonce", 9 | "message_flags": 0, 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::variation::$index::$message_hash" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/blend.json: -------------------------------------------------------------------------------- 1 | { 2 | "type":2, 3 | "guild_id": "$guild_id", 4 | "channel_id": "$channel_id", 5 | "application_id":"936929561302675456", 6 | "session_id":"$session_id", 7 | "nonce": "$nonce", 8 | "data":{ 9 | "version":"1237876415471554624", 10 | "id":"1062880104792997970", 11 | "name":"blend", 12 | "type":1, 13 | "options":[], 14 | "attachments":[] 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/upscale2.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "nonce": "$nonce", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "message_flags": 0, 7 | "message_id": "$message_id", 8 | "application_id": "936929561302675456", 9 | "session_id": "$session_id", 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::$upscale_param::1::$message_hash::SOLO" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/upscale4.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "nonce": "$nonce", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "message_flags": 0, 7 | "message_id": "$message_id", 8 | "application_id": "936929561302675456", 9 | "session_id": "$session_id", 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::JOB::upsample_v5_4x::1::$message_hash::SOLO" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/zoomout.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "nonce": "$nonce", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "message_flags": 0, 7 | "message_id": "$message_id", 8 | "application_id": "936929561302675456", 9 | "session_id": "$session_id", 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::Outpaint::$zoom_out::1::$message_hash::SOLO" 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/api-params/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 3, 3 | "nonce": "$nonce", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "message_flags": 64, 7 | "message_id": "1174275851265781770", 8 | "application_id": "936929561302675456", 9 | "session_id": "$session_id", 10 | "data": { 11 | "component_type": 2, 12 | "custom_id": "MJ::Settings::$set_value" 13 | } 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/BlendDimensions.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | 4 | public enum BlendDimensions { 5 | 6 | PORTRAIT("2:3"), 7 | 8 | SQUARE("1:1"), 9 | 10 | LANDSCAPE("3:2"); 11 | 12 | private final String value; 13 | 14 | BlendDimensions(String value) { 15 | this.value = value; 16 | } 17 | 18 | public String getValue() { 19 | return this.value; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/BaseSubmitDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import io.swagger.annotations.ApiModelProperty; 4 | import lombok.Getter; 5 | import lombok.Setter; 6 | 7 | @Getter 8 | @Setter 9 | public abstract class BaseSubmitDTO { 10 | 11 | @ApiModelProperty("自定义参数") 12 | protected String state; 13 | 14 | @ApiModelProperty("回调地址, 为空时使用全局notifyHook") 15 | protected String notifyHook; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/translate/NoTranslateServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service.translate; 2 | 3 | 4 | import com.github.novicezk.midjourney.service.TranslateService; 5 | import lombok.extern.slf4j.Slf4j; 6 | 7 | @Slf4j 8 | public class NoTranslateServiceImpl implements TranslateService { 9 | 10 | @Override 11 | public String translateToEnglish(String prompt) { 12 | return prompt; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/exception/SnowFlakeException.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.exception; 2 | 3 | public class SnowFlakeException extends RuntimeException { 4 | 5 | public SnowFlakeException(String message) { 6 | super(message); 7 | } 8 | 9 | public SnowFlakeException(String message, Throwable cause) { 10 | super(message, cause); 11 | } 12 | 13 | public SnowFlakeException(Throwable cause) { 14 | super(cause); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/resources/api-params/imagine.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 2, 3 | "guild_id": "$guild_id", 4 | "channel_id": "$channel_id", 5 | "application_id": "936929561302675456", 6 | "session_id": "$session_id", 7 | "nonce": "$nonce", 8 | "data": { 9 | "version": "1237876415471554623", 10 | "id": "938956540159881230", 11 | "name": "imagine", 12 | "type": 1, 13 | "options": [ 14 | { 15 | "type": 3, 16 | "name": "prompt", 17 | "value": "$prompt" 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/MessageType.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | 4 | public enum MessageType { 5 | /** 6 | * 创建. 7 | */ 8 | CREATE, 9 | /** 10 | * 修改. 11 | */ 12 | UPDATE, 13 | /** 14 | * 删除. 15 | */ 16 | DELETE; 17 | 18 | public static MessageType of(String type) { 19 | return switch (type) { 20 | case "MESSAGE_CREATE" -> CREATE; 21 | case "MESSAGE_UPDATE" -> UPDATE; 22 | case "MESSAGE_DELETE" -> DELETE; 23 | default -> null; 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitDescribeDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import io.swagger.annotations.ApiModel; 4 | import io.swagger.annotations.ApiModelProperty; 5 | import lombok.Data; 6 | import lombok.EqualsAndHashCode; 7 | 8 | @Data 9 | @ApiModel("Describe提交参数") 10 | @EqualsAndHashCode(callSuper = true) 11 | public class SubmitDescribeDTO extends BaseSubmitDTO { 12 | 13 | @ApiModelProperty(value = "图片base64", required = true, example = "data:image/png;base64,xxx") 14 | private String base64; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/TaskStatus.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | 4 | import lombok.Getter; 5 | 6 | public enum TaskStatus { 7 | /** 8 | * 未启动. 9 | */ 10 | NOT_START(0), 11 | /** 12 | * 已提交. 13 | */ 14 | SUBMITTED(1), 15 | /** 16 | * 执行中. 17 | */ 18 | IN_PROGRESS(3), 19 | /** 20 | * 失败. 21 | */ 22 | FAILURE(4), 23 | /** 24 | * 成功. 25 | */ 26 | SUCCESS(4); 27 | 28 | @Getter 29 | private final int order; 30 | 31 | TaskStatus(int order) { 32 | this.order = order; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/TaskStoreService.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service; 2 | 3 | 4 | import com.github.novicezk.midjourney.support.Task; 5 | import com.github.novicezk.midjourney.support.TaskCondition; 6 | 7 | import java.util.List; 8 | 9 | public interface TaskStoreService { 10 | 11 | void save(Task task); 12 | 13 | void delete(String id); 14 | 15 | Task get(String id); 16 | 17 | List list(); 18 | 19 | List list(TaskCondition condition); 20 | 21 | Task findOne(TaskCondition condition); 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SettingsDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import com.github.novicezk.midjourney.enums.SettingsEnum; 4 | import io.swagger.annotations.ApiModel; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Data; 7 | 8 | 9 | @Data 10 | @ApiModel("设置属性") 11 | public class SettingsDTO extends BaseSubmitDTO{ 12 | 13 | 14 | @ApiModelProperty(value = "REMIX", required = true, 15 | allowableValues = "REMIX", example = "REMIX") 16 | private SettingsEnum attr; 17 | 18 | 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitSimpleChangeDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import io.swagger.annotations.ApiModel; 4 | import io.swagger.annotations.ApiModelProperty; 5 | import lombok.Data; 6 | import lombok.EqualsAndHashCode; 7 | 8 | 9 | @Data 10 | @ApiModel("变化任务提交参数-simple") 11 | @EqualsAndHashCode(callSuper = true) 12 | public class SubmitSimpleChangeDTO extends BaseSubmitDTO { 13 | 14 | @ApiModelProperty(value = "变化描述: ID $action$index", required = true, example = "1320098173412546 U2") 15 | private String content; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/** 4 | !**/src/test/** 5 | bin/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | 30 | ### VS Code ### 31 | .vscode/ 32 | 33 | ### Macos ### 34 | .DS_Store 35 | 36 | ### application config # 37 | config/application.yml 38 | 39 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 40 | hs_err_pid* -------------------------------------------------------------------------------- /src/main/resources/api-params/describe.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 2, 3 | "guild_id": "$guild_id", 4 | "channel_id": "$channel_id", 5 | "application_id": "936929561302675456", 6 | "session_id": "$session_id", 7 | "nonce": "$nonce", 8 | "data": { 9 | "version": "1237876415471554625", 10 | "id": "1092492867185950852", 11 | "name": "describe", 12 | "type": 1, 13 | "options": [ 14 | { 15 | "type": 11, 16 | "name": "image", 17 | "value": 0 18 | } 19 | ], 20 | "attachments": [ 21 | { 22 | "id": "0", 23 | "filename": "$file_name", 24 | "uploaded_filename": "$final_file_name" 25 | } 26 | ] 27 | } 28 | } -------------------------------------------------------------------------------- /src/main/resources/config/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | servlet: 4 | context-path: /mj 5 | logging: 6 | level: 7 | ROOT: info 8 | com.github.novicezk.midjourney: debug 9 | knife4j: 10 | enable: true 11 | openapi: 12 | title: Midjourney Proxy API文档 13 | description: 代理 MidJourney 的discord频道,实现api形式调用AI绘图 14 | concat: novicezk 15 | url: https://github.com/novicezk/midjourney-proxy 16 | version: v2.6.2 17 | terms-of-service-url: https://github.com/novicezk/midjourney-proxy 18 | group: 19 | api: 20 | group-name: API 21 | api-rule: package 22 | api-rule-resources: 23 | - com.github.novicezk.midjourney.controller -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/EnumConstant.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | import java.util.EnumSet; 4 | 5 | public class EnumConstant { 6 | 7 | public static EnumSet ACTION_RANGE = EnumSet.of(TaskAction.IMAGINE, TaskAction.VARIATION, TaskAction.REROLL, TaskAction.BLEND, 8 | 9 | TaskAction.ZOOM_1,TaskAction.ZOOM_2,TaskAction.MOVE_UP,TaskAction.MOVE_DOWN,TaskAction.MOVE_LEFT,TaskAction.MOVE_RIGHT, 10 | TaskAction.VARY_HIGH,TaskAction.VARY_LOW 11 | ); 12 | public static EnumSet MOVE_RANGE = EnumSet.of(TaskAction.MOVE_UP,TaskAction.MOVE_DOWN,TaskAction.MOVE_LEFT,TaskAction.MOVE_RIGHT); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/ProxyApplication.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.context.annotation.Import; 6 | import org.springframework.scheduling.annotation.EnableScheduling; 7 | import spring.config.BeanConfig; 8 | import spring.config.WebMvcConfig; 9 | 10 | @EnableScheduling 11 | @SpringBootApplication 12 | @Import({BeanConfig.class, WebMvcConfig.class}) 13 | public class ProxyApplication { 14 | 15 | public static void main(String[] args) { 16 | SpringApplication.run(ProxyApplication.class, args); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitZoomDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import com.github.novicezk.midjourney.enums.TaskAction; 4 | import io.swagger.annotations.ApiModel; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Data; 7 | 8 | @Data 9 | @ApiModel("Zoom提交参数") 10 | public class SubmitZoomDTO extends BaseSubmitDTO{ 11 | 12 | @ApiModelProperty(value = "任务ID", required = true, example = "\"1320098173412546\"") 13 | private String taskId; 14 | 15 | @ApiModelProperty(value = "ZOOM_1(缩放1.5X) ; ZOOM_2(缩放2)", required = true, 16 | allowableValues = "ZOOM_1,ZOOM_2", example = "ZOOM_1") 17 | private TaskAction action; 18 | 19 | 20 | 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitVaryDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import com.github.novicezk.midjourney.enums.TaskAction; 4 | import io.swagger.annotations.ApiModel; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Data; 7 | 8 | @Data 9 | @ApiModel("Zoom提交参数") 10 | public class SubmitVaryDTO extends BaseSubmitDTO{ 11 | 12 | @ApiModelProperty(value = "任务ID", required = true, example = "\"1320098173412546\"") 13 | private String taskId; 14 | 15 | @ApiModelProperty(value = "VARY_HIGH(强) ; VARY_LOW(弱)", required = true, 16 | allowableValues = "VARY_HIGH,VARY_LOW", example = "VARY_LOW") 17 | private TaskAction action; 18 | 19 | 20 | 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitImagineDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import io.swagger.annotations.ApiModel; 4 | import io.swagger.annotations.ApiModelProperty; 5 | import lombok.Data; 6 | import lombok.EqualsAndHashCode; 7 | 8 | import java.util.List; 9 | 10 | 11 | @Data 12 | @ApiModel("Imagine提交参数") 13 | @EqualsAndHashCode(callSuper = true) 14 | public class SubmitImagineDTO extends BaseSubmitDTO { 15 | 16 | @ApiModelProperty(value = "提示词", required = true, example = "Cat") 17 | private String prompt; 18 | 19 | @ApiModelProperty(value = "垫图base64数组") 20 | private List base64Array; 21 | 22 | @ApiModelProperty(hidden = true) 23 | @Deprecated(since = "3.0", forRemoval = true) 24 | private String base64; 25 | 26 | } 27 | -------------------------------------------------------------------------------- /docker/build-manifest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -u -o pipefail 3 | 4 | if [ $# -lt 1 ]; then 5 | echo 'version is required' 6 | exit 1 7 | fi 8 | 9 | VERSION=$1 10 | 11 | echo "create manifest..." 12 | docker manifest create novicezk/midjourney-proxy:${VERSION} novicezk/midjourney-proxy-amd64:${VERSION} novicezk/midjourney-proxy-arm64v8:${VERSION} 13 | 14 | echo "annotate amd64..." 15 | docker manifest annotate novicezk/midjourney-proxy:${VERSION} novicezk/midjourney-proxy-amd64:${VERSION} --os linux --arch amd64 16 | 17 | echo "annotate arm64v8..." 18 | docker manifest annotate novicezk/midjourney-proxy:${VERSION} novicezk/midjourney-proxy-arm64v8:${VERSION} --os linux --arch arm64 --variant v8 19 | 20 | echo "push manifest..." 21 | docker manifest push novicezk/midjourney-proxy:${VERSION} -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitMoveDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import com.github.novicezk.midjourney.enums.TaskAction; 4 | import io.swagger.annotations.ApiModel; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Data; 7 | 8 | @Data 9 | @ApiModel("Zoom提交参数") 10 | public class SubmitMoveDTO extends BaseSubmitDTO{ 11 | 12 | @ApiModelProperty(value = "任务ID", required = true, example = "\"1320098173412546\"") 13 | private String taskId; 14 | 15 | @ApiModelProperty(value = "MOVE_UP(上) ; MOVE_DOWN(下);MOVE_LEFT(左);MOVE_RIGHT(右)", required = true, 16 | allowableValues = "MOVE_UP,MOVE_DOWN,MOVE_LEFT,MOVE_RIGHT", example = "MOVE_RIGHT") 17 | private TaskAction action; 18 | 19 | 20 | 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/TaskAction.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | 4 | public enum TaskAction { 5 | /** 6 | * 生成图片. 7 | */ 8 | IMAGINE, 9 | /** 10 | * 选中放大. 11 | */ 12 | UPSCALE, 13 | /** 14 | * 选中其中的一张图,生成四张相似的. 15 | */ 16 | VARIATION, 17 | /** 18 | * 重新执行. 19 | */ 20 | REROLL, 21 | /** 22 | * 图转prompt. 23 | */ 24 | DESCRIBE, 25 | /** 26 | * 多图混合. 27 | */ 28 | BLEND, 29 | 30 | /** 31 | * 放大1.5X 32 | */ 33 | ZOOM_1, 34 | /** 35 | * 放大2 36 | */ 37 | ZOOM_2, 38 | 39 | /** 40 | * high_variation 41 | */ 42 | VARY_HIGH, 43 | /** 44 | * low_variation 45 | */ 46 | VARY_LOW, 47 | 48 | /** 49 | * up down left right 50 | */ 51 | MOVE_UP, 52 | MOVE_DOWN, 53 | MOVE_LEFT, 54 | MOVE_RIGHT, 55 | 56 | UP2, 57 | UP4 58 | 59 | } 60 | -------------------------------------------------------------------------------- /docs/docker-start.md: -------------------------------------------------------------------------------- 1 | ## Docker 部署教程 2 | 3 | 1. /xxx/xxx/config目录下创建 application.yml(mj配置项)、banned-words.txt(可选,覆盖默认的敏感词文件);参考src/main/resources下的文件 4 | 2. 启动容器,映射config目录 5 | ```shell 6 | docker run -d --name midjourney-proxy \ 7 | -p 8080:8080 \ 8 | -v /xxx/xxx/config:/home/spring/config \ 9 | <<<<<<< HEAD 10 | kratos1937/midjourney-proxy:v1.1.0 11 | ======= 12 | novicezk/midjourney-proxy:2.6.2 13 | >>>>>>> upstream/main 14 | ``` 15 | 3. 访问 `http://ip:port/mj` 查看API文档 16 | 17 | 附: 不映射config目录方式,直接在启动命令中设置参数 18 | ```shell 19 | docker run -d --name midjourney-proxy \ 20 | -p 8080:8080 \ 21 | -e mj.discord.guild-id=xxx \ 22 | -e mj.discord.channel-id=xxx \ 23 | -e mj.discord.user-token=xxx \ 24 | <<<<<<< HEAD 25 | kratos1937/midjourney-proxy:v1.1.0 26 | ======= 27 | novicezk/midjourney-proxy:2.6.2 28 | >>>>>>> upstream/main 29 | ``` 30 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/enums/SettingsEnum.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.enums; 2 | 3 | public enum SettingsEnum { 4 | TURBO("TurboMode"), 5 | FAST("FastMode"), 6 | RELAX("RelaxMode"), 7 | RESET("ResetSettings"), 8 | RAW("Style::raw"), 9 | STYLIZE("Stylization::50"), 10 | MED("Stylization::100"), 11 | HIGH("Stylization::250"), 12 | VERY_HIGH("Stylization::750"), 13 | PUBLIC_MODE("PrivateMode::off"), 14 | REMIX("RemixMode"), 15 | HIGH_VARIATION("HighVariabilityMode::1"), 16 | LOW_VARIATION("HighVariabilityMode::0"), 17 | 18 | 19 | ; 20 | 21 | 22 | private String value; 23 | 24 | private SettingsEnum(String value) { 25 | this.value = value; 26 | } 27 | 28 | public String getValue() { 29 | return value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/ReturnCode.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney; 2 | 3 | import lombok.experimental.UtilityClass; 4 | 5 | @UtilityClass 6 | public final class ReturnCode { 7 | /** 8 | * 成功. 9 | */ 10 | public static final int SUCCESS = 1; 11 | /** 12 | * 数据未找到. 13 | */ 14 | public static final int NOT_FOUND = 3; 15 | /** 16 | * 校验错误. 17 | */ 18 | public static final int VALIDATION_ERROR = 4; 19 | /** 20 | * 系统异常. 21 | */ 22 | public static final int FAILURE = 9; 23 | 24 | /** 25 | * 已存在. 26 | */ 27 | public static final int EXISTED = 21; 28 | /** 29 | * 排队中. 30 | */ 31 | public static final int IN_QUEUE = 22; 32 | /** 33 | * 队列已满. 34 | */ 35 | public static final int QUEUE_REJECTED = 23; 36 | /** 37 | * prompt包含敏感词. 38 | */ 39 | public static final int BANNED_PROMPT = 24; 40 | 41 | 42 | } -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitBlendDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import com.github.novicezk.midjourney.enums.BlendDimensions; 4 | import io.swagger.annotations.ApiModel; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Data; 7 | import lombok.EqualsAndHashCode; 8 | 9 | import java.util.List; 10 | 11 | @Data 12 | @ApiModel("Blend提交参数") 13 | @EqualsAndHashCode(callSuper = true) 14 | public class SubmitBlendDTO extends BaseSubmitDTO { 15 | 16 | @ApiModelProperty(value = "图片base64数组", required = true, example = "[\"data:image/png;base64,xxx1\", \"data:image/png;base64,xxx2\"]") 17 | private List base64Array; 18 | 19 | @ApiModelProperty(value = "比例: PORTRAIT(2:3); SQUARE(1:1); LANDSCAPE(3:2)", example = "SQUARE") 20 | private BlendDimensions dimensions = BlendDimensions.SQUARE; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/api-params/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 2, 3 | "application_id": "936929561302675456", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "session_id": "$session_id", 7 | "data": { 8 | "version": "1118961510123847776", 9 | "id": "972289487818334209", 10 | "name": "info", 11 | "type": 1, 12 | "options": [], 13 | "application_command": { 14 | "id": "972289487818334209", 15 | "application_id": "936929561302675456", 16 | "version": "1118961510123847776", 17 | "default_member_permissions": null, 18 | "type": 1, 19 | "nsfw": false, 20 | "name": "info", 21 | "description": "View information about your profile.", 22 | "dm_permission": true, 23 | "contexts": [0, 1, 2], 24 | "integration_types": [0] 25 | }, 26 | "attachments": [] 27 | }, 28 | "nonce": "$nonce" 29 | } -------------------------------------------------------------------------------- /docker/build-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -u -o pipefail 3 | 4 | if [ $# -lt 1 ]; then 5 | echo 'version is required' 6 | exit 1 7 | fi 8 | 9 | VERSION=$1 10 | ARCH=amd64 11 | 12 | if [ $# -ge 2 ]; then 13 | ARCH=$2 14 | fi 15 | 16 | JAR_FILE_COUNT=$(find "../target/" -maxdepth 1 -name '*.jar' | wc -l) 17 | if [ $JAR_FILE_COUNT == 0 ]; then 18 | echo "jar file not found, please execute: mvn clean package" 19 | exit 1 20 | fi 21 | 22 | JAR_FILE_NAME=$(ls ../target/*.jar|grep -v source) 23 | echo ${JAR_FILE_NAME} 24 | 25 | cp ${JAR_FILE_NAME} ./app.jar 26 | 27 | java -Djarmode=layertools -jar app.jar extract 28 | 29 | docker build . -t midjourney-proxy:${VERSION} 30 | 31 | rm -rf application dependencies snapshot-dependencies spring-boot-loader app.jar 32 | 33 | docker tag midjourney-proxy:${VERSION} novicezk/midjourney-proxy-${ARCH}:${VERSION} 34 | docker push novicezk/midjourney-proxy-${ARCH}:${VERSION} -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/SpringContextHolder.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | import org.springframework.beans.BeansException; 4 | import org.springframework.context.ApplicationContext; 5 | import org.springframework.context.ApplicationContextAware; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | public class SpringContextHolder implements ApplicationContextAware { 10 | private static ApplicationContext APPLICATION_CONTEXT; 11 | 12 | @Override 13 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 14 | APPLICATION_CONTEXT = applicationContext; 15 | } 16 | 17 | public static ApplicationContext getApplicationContext() { 18 | if (APPLICATION_CONTEXT == null) { 19 | throw new IllegalStateException("SpringContextHolder is not ready."); 20 | } 21 | return APPLICATION_CONTEXT; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/SettingsContants.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | public class SettingsContants{ 4 | 5 | public static String TURBO = "TurboMode"; 6 | public static String FAST = "FastMode"; 7 | public static String RELAX = "RelaxMode"; 8 | public static String RESET = "ResetSettings"; 9 | public static String RAW = "Style::raw"; 10 | public static String STYLIZE = "Stylization::50"; //low 11 | public static String MED = "Stylization::100"; //med 12 | public static String HIGH = "Stylization::250"; // high 13 | public static String VERY_HIGH = "Stylization::750"; //very high 14 | public static String PUBLIC_MODE = "PrivateMode::off"; //PUBLIC_MODE 15 | public static String REMIX = "RemixMode"; //REMIX 16 | public static String HIGH_VARIATION = "HighVariabilityMode::1"; // 17 | public static String LOW_VARIATION = "HighVariabilityMode::0"; // 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/dto/SubmitChangeDTO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.dto; 2 | 3 | import com.github.novicezk.midjourney.enums.TaskAction; 4 | import io.swagger.annotations.ApiModel; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Data; 7 | import lombok.EqualsAndHashCode; 8 | 9 | 10 | @Data 11 | @ApiModel("变化任务提交参数") 12 | @EqualsAndHashCode(callSuper = true) 13 | public class SubmitChangeDTO extends BaseSubmitDTO { 14 | 15 | @ApiModelProperty(value = "任务ID", required = true, example = "\"1320098173412546\"") 16 | private String taskId; 17 | 18 | @ApiModelProperty(value = "UPSCALE(放大); VARIATION(变换); REROLL(重新生成)", required = true, 19 | allowableValues = "UPSCALE, VARIATION, REROLL", example = "UPSCALE") 20 | private TaskAction action; 21 | 22 | @ApiModelProperty(value = "序号(1~4), action为UPSCALE,VARIATION时必传", allowableValues = "range[1, 4]", example = "1") 23 | private Integer index; 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/loadbalancer/rule/RoundRobinRule.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.loadbalancer.rule; 2 | 3 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 4 | 5 | import java.util.List; 6 | import java.util.concurrent.atomic.AtomicInteger; 7 | 8 | /** 9 | * 轮询. 10 | */ 11 | public class RoundRobinRule implements IRule { 12 | private final AtomicInteger position = new AtomicInteger(0); 13 | 14 | @Override 15 | public DiscordInstance choose(List instances) { 16 | if (instances.isEmpty()) { 17 | return null; 18 | } 19 | int pos = incrementAndGet(); 20 | return instances.get(pos % instances.size()); 21 | } 22 | 23 | private int incrementAndGet() { 24 | int current; 25 | int next; 26 | do { 27 | current = this.position.get(); 28 | next = current == Integer.MAX_VALUE ? 0 : current + 1; 29 | } while (!this.position.compareAndSet(current, next)); 30 | return next; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/resources/api-params/list-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": 2, 3 | "application_id": "936929561302675456", 4 | "guild_id": "$guild_id", 5 | "channel_id": "$channel_id", 6 | "session_id": "12b3d9d8dbc0db4536a0fc4664ab7bad", 7 | "data": { 8 | "version": "1166847114609958943", 9 | "id": "1000850743479255081", 10 | "name": "settings", 11 | "type": 1, 12 | "options": [], 13 | "application_command": { 14 | "id": "1000850743479255081", 15 | "application_id": "936929561302675456", 16 | "version": "1166847114609958943", 17 | "default_member_permissions": null, 18 | "type": 1, 19 | "nsfw": false, 20 | "name": "settings", 21 | "description": "View and adjust your personal settings.", 22 | "dm_permission": true, 23 | "contexts": null, 24 | "integration_types": [ 25 | 0 26 | ] 27 | }, 28 | "attachments": [] 29 | }, 30 | "nonce": "$nonce", 31 | "analytics_location": "slash_ui" 32 | } -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/loadbalancer/rule/BestWaitIdleRule.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.loadbalancer.rule; 2 | 3 | import cn.hutool.core.util.RandomUtil; 4 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 5 | 6 | import java.util.Comparator; 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.stream.Collectors; 10 | 11 | /** 12 | * 最少等待空闲. 13 | * 选择等待数最少的实例,如果都不需要等待,则随机选择 14 | */ 15 | public class BestWaitIdleRule implements IRule { 16 | 17 | @Override 18 | public DiscordInstance choose(List instances) { 19 | if (instances.isEmpty()) { 20 | return null; 21 | } 22 | Map> map = instances.stream() 23 | .collect(Collectors.groupingBy(i -> { 24 | int wait = i.getRunningFutures().size() - i.account().getCoreSize(); 25 | return wait >= 0 ? wait : -1; 26 | })); 27 | List instanceList = map.entrySet().stream().min(Comparator.comparingInt(Map.Entry::getKey)).orElseThrow().getValue(); 28 | return RandomUtil.randomEle(instanceList); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/Constants.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney; 2 | 3 | import lombok.experimental.UtilityClass; 4 | 5 | @UtilityClass 6 | public final class Constants { 7 | // 任务扩展属性 start 8 | public static final String TASK_PROPERTY_NOTIFY_HOOK = "notifyHook"; 9 | public static final String TASK_PROPERTY_FINAL_PROMPT = "finalPrompt"; 10 | public static final String TASK_PROPERTY_MESSAGE_ID = "messageId"; 11 | public static final String TASK_PROPERTY_MESSAGE_HASH = "messageHash"; 12 | public static final String TASK_PROPERTY_PROGRESS_MESSAGE_ID = "progressMessageId"; 13 | public static final String TASK_PROPERTY_FLAGS = "flags"; 14 | public static final String TASK_PROPERTY_NONCE = "nonce"; 15 | public static final String TASK_PROPERTY_DISCORD_INSTANCE_ID = "discordInstanceId"; 16 | // 任务扩展属性 end 17 | 18 | public static final String API_SECRET_HEADER_NAME = "mj-api-secret"; 19 | public static final String DEFAULT_DISCORD_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"; 20 | 21 | public static final String MJ_MESSAGE_HANDLED = "mj_proxy_handled"; 22 | } 23 | -------------------------------------------------------------------------------- /docs/railway-start.md: -------------------------------------------------------------------------------- 1 | ## Railway 部署教程 2 | 3 | Railway是一个提供弹性部署方案的平台,服务器在海外,方便MidJourney的调用。 4 | 5 | **Railway 提供 5 美元,500 个小时/月的免费额度** 6 | 7 | ### 1. Fork本仓库 8 | ### 2. Railway使用github账号登录 9 | 进入 [railway官网](https://railway.app) 选择 `Login` -> `Github`,登录github账号 10 | 11 | ### 3. [New Project](https://railway.app/new) 添加对fork仓库的授权 12 | ![railway_img_1](./railway_img_1.png) 13 | ![railway_img_2](./railway_img_2.png) 14 | ![railway_img_3](./railway_img_3.png) 15 | 16 | ### 4. 选择该fork仓库,新建项目,设置环境变量 17 | ![railway_img_4](./railway_img_4.png) 18 | ![railway_img_5](./railway_img_5.png) 19 | ![railway_img_6](./railway_img_6.png) 20 | ![railway_img_7](./railway_img_7.png) 21 | 此处配置项参考 [Wiki / 配置项](https://github.com/novicezk/midjourney-proxy/wiki/%E9%85%8D%E7%BD%AE%E9%A1%B9) ,建议配置api密钥启用鉴权,接口调用时需添加请求头 `mj-api-secret` 22 | 23 | ### 5. 启动服务 24 | 进入刚才的Project,它应该已经在自动部署了,后续更新配置之后会自动重新部署 25 | ![railway_img_8](./railway_img_8.png) 26 | 27 | 若部署启动失败请查看日志,检查配置项 28 | ![railway_img_9](./railway_img_9.png) 29 | ![railway_img_10](./railway_img_10.png) 30 | 31 | ### 6. 开始使用 32 | 等待部署成功后,生成随机域名 33 | ![railway_img_11](./railway_img_11.png) 34 | ![railway_img_12](./railway_img_12.png) 35 | 36 | 访问 `https://midjourney-proxy-***.app/mj` 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.8.5-openjdk-17 2 | 3 | ARG user=spring 4 | ARG group=spring 5 | 6 | ENV SPRING_HOME=/home/spring 7 | 8 | RUN groupadd -g 1000 ${group} \ 9 | && useradd -d "$SPRING_HOME" -u 1000 -g 1000 -m -s /bin/bash ${user} \ 10 | && mkdir -p $SPRING_HOME/config \ 11 | && mkdir -p $SPRING_HOME/logs \ 12 | && chown -R ${user}:${group} $SPRING_HOME/config $SPRING_HOME/logs 13 | 14 | # Railway 不支持使用 VOLUME, 本地需要构建时,取消下一行的注释 15 | # VOLUME ["$SPRING_HOME/config", "$SPRING_HOME/logs"] 16 | 17 | USER ${user} 18 | WORKDIR $SPRING_HOME 19 | 20 | COPY . . 21 | 22 | RUN mvn clean package \ 23 | && mv target/midjourney-proxy-*.jar ./app.jar \ 24 | && rm -rf target 25 | 26 | EXPOSE 8080 9876 27 | 28 | ENV JAVA_OPTS -XX:MaxRAMPercentage=85 -Djava.awt.headless=true -XX:+HeapDumpOnOutOfMemoryError \ 29 | -XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35 -Xlog:gc:file=/home/spring/logs/gc.log \ 30 | -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9876 -Dcom.sun.management.jmxremote.ssl=false \ 31 | -Dcom.sun.management.jmxremote.authenticate=false -Dlogging.file.path=/home/spring/logs \ 32 | -Dserver.port=8080 -Duser.timezone=Asia/Shanghai 33 | 34 | ENTRYPOINT ["bash","-c","java $JAVA_OPTS -jar app.jar"] 35 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/ApiAuthorizeInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.Constants; 6 | import com.github.novicezk.midjourney.ProxyProperties; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.web.servlet.HandlerInterceptor; 10 | 11 | import javax.servlet.http.HttpServletRequest; 12 | import javax.servlet.http.HttpServletResponse; 13 | 14 | @Component 15 | @RequiredArgsConstructor 16 | public class ApiAuthorizeInterceptor implements HandlerInterceptor { 17 | private final ProxyProperties properties; 18 | 19 | @Override 20 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 21 | if (CharSequenceUtil.isBlank(this.properties.getApiSecret())) { 22 | return true; 23 | } 24 | String apiSecret = request.getHeader(Constants.API_SECRET_HEADER_NAME); 25 | boolean authorized = CharSequenceUtil.equals(apiSecret, this.properties.getApiSecret()); 26 | if (!authorized) { 27 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 28 | } 29 | return authorized; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/spring/config/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | package spring.config; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.ProxyProperties; 5 | import com.github.novicezk.midjourney.support.ApiAuthorizeInterceptor; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 8 | import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 10 | 11 | import javax.annotation.Resource; 12 | 13 | @Configuration 14 | public class WebMvcConfig implements WebMvcConfigurer { 15 | @Resource 16 | private ApiAuthorizeInterceptor apiAuthorizeInterceptor; 17 | @Resource 18 | private ProxyProperties properties; 19 | 20 | @Override 21 | public void addViewControllers(ViewControllerRegistry registry) { 22 | registry.addViewController("/").setViewName("redirect:doc.html"); 23 | } 24 | 25 | @Override 26 | public void addInterceptors(InterceptorRegistry registry) { 27 | if (CharSequenceUtil.isNotBlank(this.properties.getApiSecret())) { 28 | registry.addInterceptor(this.apiAuthorizeInterceptor) 29 | .addPathPatterns("/submit/**", "/task/**", "/account/**"); 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /docs/zeabur-start.md: -------------------------------------------------------------------------------- 1 | ## Zeabur 部署教程 2 | 3 | ### Zeabur 优势 4 | 1. 新注册的 `Github` 账号可能无法使用 `Railway`,但是能用 `Zeabur` 5 | 2. 通过 `Railway` 部署的项目会自动生成一个域名,然而因为某些原因,形如 `*.up.railway.app` 的域名在国内无法访问 6 | 3. `Zeabur` 服务器运行在国外,但是其生成的域名 `*.zeabur.app` 没有被污染,国内可直接访问 7 | 8 | ### 开始部署 9 | 10 | 1. 打开网址 https://zeabur.com/zh-CN 11 | 2. 点击现在开始 12 | 3. 点击 `Sign in with GitHub` 13 | 4. 登陆你的 `Github` 账号 14 | 5. 点击 `Authorize zeabur` 授权 15 | 6. 点击 `创建项目` 并输入一个项目名称,点击 `创建` 16 | 7. 点击 `+` 添加服务,选择 `Git-Deploy service from source code in GitHub repository.` 17 | 8. 点击 `Configure GitHub` 根据需要选择 `All repositories` 或者 `Only select repositories` 18 | 9. 点击 `install`,之后自动跳转,最好再刷新一下页面 19 | 10. 点击 你 fork 的 `midjourney-proxy` 项目 20 | 11. 点击环境变量,点击编辑原始环境变量,添加你需要的环境变量 21 | 12. 关于环境变量,与 `Railway` 稍有不同,需要把 `.` 和 `-` 全部换成 `_`,例如如下格式 22 | ```properties 23 | PORT=8080 24 | mj_discord_guild_id=xxx 25 | mj_discord_channel_id=xxx 26 | mj_discord_user_token=xxx 27 | mj_api_secret=*** 28 | ``` 29 | 此处配置项参考 [Wiki / 配置项](https://github.com/novicezk/midjourney-proxy/wiki/%E9%85%8D%E7%BD%AE%E9%A1%B9) ,建议配置api密钥启用鉴权,接口调用时需添加请求头 `mj-api-secret` 30 | 13. 然后取消 `Building`,点击 `Redeploy` (此做法是为了让环境变量生效) 31 | 14. 部署 `midjourney-proxy` 大概需要 `2` 分钟,此时你可以做的是:配置域名 32 | 15. 点击下方的域名,点击生成域名,输入前缀,例如 `midjourney-proxy-demo`,点击保存;或者添加自定义域名,之后加上 `CNAME` 解析 33 | 16. 等待部署成功,访问 `https://midjourney-proxy-demo.zeabur.app/mj` -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/TaskService.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service; 2 | 3 | import com.github.novicezk.midjourney.enums.BlendDimensions; 4 | import com.github.novicezk.midjourney.result.SubmitResultVO; 5 | import com.github.novicezk.midjourney.support.Task; 6 | import eu.maxschuster.dataurl.DataUrl; 7 | 8 | import java.util.List; 9 | 10 | public interface TaskService { 11 | 12 | SubmitResultVO submitImagine(Task task, List dataUrls); 13 | 14 | SubmitResultVO submitUpscale(Task task, String targetMessageId, String targetMessageHash, int index, int messageFlags); 15 | 16 | SubmitResultVO submitVariation(Task task, String targetMessageId, String targetMessageHash, int index, int messageFlags); 17 | 18 | SubmitResultVO submitReroll(Task task, String targetMessageId, String targetMessageHash, int messageFlags); 19 | 20 | SubmitResultVO submitDescribe(Task task, DataUrl dataUrl); 21 | 22 | SubmitResultVO submitBlend(Task task, List dataUrls, BlendDimensions dimensions); 23 | 24 | SubmitResultVO submitZoom(Task task, String targetMessageId, String targetMessageHash,int messageFlags); 25 | 26 | SubmitResultVO submitVary(Task task, String targetMessageId, String targetMessageHash,int messageFlags); 27 | 28 | SubmitResultVO move(Task task, String targetMessageId, String targetMessageHash, int messageFlags); 29 | } -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:17.0 2 | 3 | ARG user=spring 4 | ARG group=spring 5 | 6 | ENV SPRING_HOME=/home/spring 7 | ENV APP_HOME=$SPRING_HOME/app 8 | 9 | ENV JAVA_OPTS -XX:MaxRAMPercentage=85 -Djava.awt.headless=true -XX:+HeapDumpOnOutOfMemoryError \ 10 | -XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35 -Xlog:gc:file=/home/spring/logs/gc.log \ 11 | -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9876 -Dcom.sun.management.jmxremote.ssl=false \ 12 | -Dcom.sun.management.jmxremote.authenticate=false -Dlogging.file.path=/home/spring/logs \ 13 | -Dserver.port=8080 -Duser.timezone=Asia/Shanghai 14 | 15 | RUN groupadd -g 1000 ${group} \ 16 | && useradd -d "$SPRING_HOME" -u 1000 -g 1000 -m -s /bin/bash ${user} \ 17 | && mkdir -p $SPRING_HOME/config \ 18 | && mkdir -p $SPRING_HOME/logs \ 19 | && mkdir -p $APP_HOME \ 20 | && chown -R ${user}:${group} $SPRING_HOME/config $SPRING_HOME/logs $APP_HOME 21 | 22 | VOLUME ["$SPRING_HOME/config", "$SPRING_HOME/logs"] 23 | 24 | USER ${user} 25 | 26 | WORKDIR $SPRING_HOME 27 | 28 | EXPOSE 8080 9876 29 | 30 | ENTRYPOINT ["bash","-c","java $JAVA_OPTS -cp ./app org.springframework.boot.loader.JarLauncher"] 31 | 32 | COPY --chown=${user}:${group} dependencies $APP_HOME/ 33 | COPY --chown=${user}:${group} spring-boot-loader $APP_HOME/ 34 | COPY --chown=${user}:${group} snapshot-dependencies $APP_HOME/ 35 | COPY --chown=${user}:${group} application $APP_HOME/ 36 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/MimeTypeUtils.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import cn.hutool.core.io.FileUtil; 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import lombok.experimental.UtilityClass; 6 | 7 | import java.nio.charset.StandardCharsets; 8 | import java.util.HashMap; 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | @UtilityClass 13 | public class MimeTypeUtils { 14 | private final Map> MIME_TYPE_MAP; 15 | 16 | static { 17 | MIME_TYPE_MAP = new HashMap<>(); 18 | var resource = MimeTypeUtils.class.getResource("/mime.types"); 19 | var lines = FileUtil.readLines(resource, StandardCharsets.UTF_8); 20 | for (var line : lines) { 21 | if (CharSequenceUtil.isBlank(line)) { 22 | continue; 23 | } 24 | var arr = line.split(":"); 25 | MIME_TYPE_MAP.put(arr[0], CharSequenceUtil.split(arr[1], ' ')); 26 | } 27 | } 28 | 29 | public static String guessFileSuffix(String mimeType) { 30 | if (CharSequenceUtil.isBlank(mimeType)) { 31 | return null; 32 | } 33 | String key = mimeType; 34 | if (!MIME_TYPE_MAP.containsKey(key)) { 35 | key = MIME_TYPE_MAP.keySet().stream().filter(k -> CharSequenceUtil.startWithIgnoreCase(mimeType, k)) 36 | .findFirst().orElse(null); 37 | } 38 | var suffixList = MIME_TYPE_MAP.get(key); 39 | if (suffixList == null || suffixList.isEmpty()) { 40 | return null; 41 | } 42 | return suffixList.iterator().next(); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/DiscordService.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service; 2 | 3 | 4 | import com.github.novicezk.midjourney.enums.BlendDimensions; 5 | import com.github.novicezk.midjourney.result.Message; 6 | import eu.maxschuster.dataurl.DataUrl; 7 | 8 | import java.util.List; 9 | 10 | public interface DiscordService { 11 | 12 | Message imagine(String prompt, String nonce); 13 | 14 | Message upscale(String messageId, int index, String messageHash, int messageFlags, String nonce); 15 | 16 | Message variation(String messageId, int index, String messageHash, int messageFlags, String nonce); 17 | 18 | Message reroll(String messageId, String messageHash, int messageFlags, String nonce); 19 | 20 | Message describe(String finalFileName, String nonce); 21 | 22 | Message blend(List finalFileNames, BlendDimensions dimensions, String nonce); 23 | 24 | Message upload(String fileName, DataUrl dataUrl); 25 | 26 | Message sendImageMessage(String content, String finalFileName); 27 | 28 | 29 | Message zoom(String messageId, String messageHash, String nonce,String zoomOut); 30 | 31 | Message upscale(String messageId, String messageHash, String nonce, String upscale); 32 | Message vary(String messageId, String messageHash, String nonce,String vary); 33 | 34 | Message move(String messageId, String messageHash, String nonce,String move); 35 | 36 | Message info(String nonce); 37 | 38 | Message settings(String nonce, String value); 39 | 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/TaskTimeoutSchedule.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | import com.github.novicezk.midjourney.enums.TaskStatus; 4 | import com.github.novicezk.midjourney.loadbalancer.DiscordLoadBalancer; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.scheduling.annotation.Scheduled; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.util.List; 11 | import java.util.Set; 12 | import java.util.concurrent.TimeUnit; 13 | 14 | @Slf4j 15 | @Component 16 | @RequiredArgsConstructor 17 | public class TaskTimeoutSchedule { 18 | private final DiscordLoadBalancer discordLoadBalancer; 19 | 20 | @Scheduled(fixedRate = 30000L) 21 | public void checkTasks() { 22 | this.discordLoadBalancer.getAliveInstances().forEach(instance -> { 23 | long timeout = TimeUnit.MINUTES.toMillis(instance.account().getTimeoutMinutes()); 24 | List tasks = instance.getRunningTasks().stream() 25 | .filter(t -> System.currentTimeMillis() - t.getStartTime() > timeout) 26 | .toList(); 27 | for (Task task : tasks) { 28 | if (Set.of(TaskStatus.FAILURE, TaskStatus.SUCCESS).contains(task.getStatus())) { 29 | log.warn("[{}] - task status is failure/success but is in the queue, end it. id: {}", instance.account().getDisplay(), task.getId()); 30 | } else { 31 | log.debug("[{}] - task timeout, id: {}", instance.account().getDisplay(), task.getId()); 32 | task.fail("任务超时"); 33 | } 34 | instance.exitTask(task); 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/ImagineSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | 4 | import com.github.novicezk.midjourney.enums.MessageType; 5 | import com.github.novicezk.midjourney.enums.TaskAction; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 7 | import com.github.novicezk.midjourney.support.TaskCondition; 8 | import com.github.novicezk.midjourney.util.ContentParseData; 9 | import com.github.novicezk.midjourney.util.ConvertUtils; 10 | import net.dv8tion.jda.api.utils.data.DataObject; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.Set; 14 | 15 | /** 16 | * imagine消息处理. 17 | * 完成(create): **cat** - <@1012983546824114217> (relaxed) 18 | */ 19 | @Component 20 | public class ImagineSuccessHandler extends MessageHandler { 21 | private static final String CONTENT_REGEX = "\\*\\*(.*?)\\*\\* - <@\\d+> \\((.*?)\\)"; 22 | 23 | @Override 24 | public int order() { 25 | return 101; 26 | } 27 | 28 | @Override 29 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 30 | String content = getMessageContent(message); 31 | ContentParseData parseData = ConvertUtils.parseContent(content, CONTENT_REGEX); 32 | if (MessageType.CREATE.equals(messageType) && parseData != null && hasImage(message)) { 33 | TaskCondition condition = new TaskCondition() 34 | .setActionSet(Set.of(TaskAction.IMAGINE)) 35 | .setFinalPromptEn(parseData.getPrompt()); 36 | findAndFinishImageTask(instance, condition, parseData.getPrompt(), message); 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/store/InMemoryTaskStoreServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service.store; 2 | 3 | import cn.hutool.cache.CacheUtil; 4 | import cn.hutool.cache.impl.TimedCache; 5 | import cn.hutool.core.collection.ListUtil; 6 | import cn.hutool.core.stream.StreamUtil; 7 | import com.github.novicezk.midjourney.service.TaskStoreService; 8 | import com.github.novicezk.midjourney.support.Task; 9 | import com.github.novicezk.midjourney.support.TaskCondition; 10 | 11 | import java.time.Duration; 12 | import java.util.List; 13 | 14 | 15 | public class InMemoryTaskStoreServiceImpl implements TaskStoreService { 16 | private final TimedCache taskMap; 17 | 18 | public InMemoryTaskStoreServiceImpl(Duration timeout) { 19 | this.taskMap = CacheUtil.newTimedCache(timeout.toMillis()); 20 | } 21 | 22 | @Override 23 | public void save(Task task) { 24 | this.taskMap.put(task.getId(), task); 25 | } 26 | 27 | @Override 28 | public void delete(String key) { 29 | this.taskMap.remove(key); 30 | } 31 | 32 | @Override 33 | public Task get(String key) { 34 | return this.taskMap.get(key); 35 | } 36 | 37 | @Override 38 | public List list() { 39 | return ListUtil.toList(this.taskMap.iterator()); 40 | } 41 | 42 | @Override 43 | public List list(TaskCondition condition) { 44 | return StreamUtil.of(this.taskMap.iterator()).filter(condition).toList(); 45 | } 46 | 47 | @Override 48 | public Task findOne(TaskCondition condition) { 49 | return StreamUtil.of(this.taskMap.iterator()).filter(condition).findFirst().orElse(null); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/BannedPromptUtils.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import cn.hutool.core.io.FileUtil; 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.exception.BannedPromptException; 6 | import lombok.experimental.UtilityClass; 7 | 8 | import java.io.File; 9 | import java.nio.charset.StandardCharsets; 10 | import java.util.List; 11 | import java.util.Locale; 12 | import java.util.regex.Matcher; 13 | import java.util.regex.Pattern; 14 | 15 | @UtilityClass 16 | public class BannedPromptUtils { 17 | private static final String BANNED_WORDS_FILE_PATH = "/home/spring/config/banned-words.txt"; 18 | private final List BANNED_WORDS; 19 | 20 | static { 21 | List lines; 22 | File file = new File(BANNED_WORDS_FILE_PATH); 23 | if (file.exists()) { 24 | lines = FileUtil.readLines(file, StandardCharsets.UTF_8); 25 | } else { 26 | var resource = BannedPromptUtils.class.getResource("/banned-words.txt"); 27 | lines = FileUtil.readLines(resource, StandardCharsets.UTF_8); 28 | } 29 | BANNED_WORDS = lines.stream().filter(CharSequenceUtil::isNotBlank).toList(); 30 | } 31 | 32 | public static void checkBanned(String promptEn) throws BannedPromptException { 33 | String finalPromptEn = promptEn.toLowerCase(Locale.ENGLISH); 34 | for (String word : BANNED_WORDS) { 35 | Matcher matcher = Pattern.compile("\\b" + word + "\\b").matcher(finalPromptEn); 36 | if (matcher.find()) { 37 | int index = CharSequenceUtil.indexOfIgnoreCase(promptEn, word); 38 | throw new BannedPromptException(promptEn.substring(index, index + word.length())); 39 | } 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/domain/DiscordAccount.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.domain; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.github.novicezk.midjourney.Constants; 5 | import io.swagger.annotations.ApiModel; 6 | import io.swagger.annotations.ApiModelProperty; 7 | import lombok.Data; 8 | import lombok.EqualsAndHashCode; 9 | 10 | @Data 11 | @EqualsAndHashCode(callSuper = true) 12 | @ApiModel("Discord账号") 13 | public class DiscordAccount extends DomainObject { 14 | 15 | @ApiModelProperty("服务器ID") 16 | private String guildId; 17 | @ApiModelProperty("频道ID") 18 | private String channelId; 19 | @ApiModelProperty("用户Token") 20 | private String userToken; 21 | @ApiModelProperty("用户UserAgent") 22 | private String userAgent = Constants.DEFAULT_DISCORD_USER_AGENT; 23 | 24 | @ApiModelProperty("是否可用") 25 | private boolean enable = true; 26 | 27 | @ApiModelProperty("并发数") 28 | private int coreSize = 3; 29 | @ApiModelProperty("等待队列长度") 30 | private int queueSize = 10; 31 | @ApiModelProperty("任务超时时间(分钟)") 32 | private int timeoutMinutes = 5; 33 | 34 | 35 | @ApiModelProperty("userid") 36 | private String userId; 37 | @ApiModelProperty("订阅计划剩余") 38 | private String subscription; 39 | 40 | @ApiModelProperty("快速图片剩余时间(小时)") 41 | private String fastTimeRemaining; 42 | @ApiModelProperty("总生成图片数量 (小时)") 43 | private String lifeTimeUsage; 44 | @ApiModelProperty("慢速图片数量 (小时)") 45 | private String relaxedUsage; 46 | @ApiModelProperty("快速任务") 47 | private String fastQueuedJobs; 48 | @ApiModelProperty("慢速任务") 49 | private String relaxQueuedJobs; 50 | @ApiModelProperty("运行中任务") 51 | private String runningJobs; 52 | 53 | 54 | @JsonIgnore 55 | public String getDisplay() { 56 | return this.channelId; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/result/Message.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.result; 2 | 3 | import com.github.novicezk.midjourney.ReturnCode; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class Message { 8 | private final int code; 9 | private final String description; 10 | private final T result; 11 | 12 | public static Message success() { 13 | return new Message<>(ReturnCode.SUCCESS, "成功"); 14 | } 15 | 16 | public static Message success(T result) { 17 | return new Message<>(ReturnCode.SUCCESS, "成功", result); 18 | } 19 | 20 | public static Message success(int code, String description, T result) { 21 | return new Message<>(code, description, result); 22 | } 23 | 24 | public static Message notFound() { 25 | return new Message<>(ReturnCode.NOT_FOUND, "数据未找到"); 26 | } 27 | 28 | public static Message validationError() { 29 | return new Message<>(ReturnCode.VALIDATION_ERROR, "校验错误"); 30 | } 31 | 32 | public static Message failure() { 33 | return new Message<>(ReturnCode.FAILURE, "系统异常"); 34 | } 35 | 36 | public static Message failure(String description) { 37 | return new Message<>(ReturnCode.FAILURE, description); 38 | } 39 | 40 | public static Message of(int code, String description) { 41 | return new Message<>(code, description); 42 | } 43 | 44 | public static Message of(int code, String description, T result) { 45 | return new Message<>(code, description, result); 46 | } 47 | 48 | private Message(int code, String description) { 49 | this(code, description, null); 50 | } 51 | 52 | private Message(int code, String description, T result) { 53 | this.code = code; 54 | this.description = description; 55 | this.result = result; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/loadbalancer/DiscordInstance.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.loadbalancer; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.domain.DiscordAccount; 6 | import com.github.novicezk.midjourney.result.Message; 7 | import com.github.novicezk.midjourney.result.SubmitResultVO; 8 | import com.github.novicezk.midjourney.service.DiscordService; 9 | import com.github.novicezk.midjourney.support.Task; 10 | import com.github.novicezk.midjourney.support.TaskCondition; 11 | 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.concurrent.Callable; 15 | import java.util.concurrent.Future; 16 | import java.util.stream.Stream; 17 | 18 | public interface DiscordInstance extends DiscordService { 19 | 20 | String getInstanceId(); 21 | 22 | DiscordAccount account(); 23 | 24 | boolean isAlive(); 25 | 26 | void startWss() throws Exception; 27 | 28 | List getRunningTasks(); 29 | 30 | List getQueueTasks(); 31 | 32 | void exitTask(Task task); 33 | 34 | Map> getRunningFutures(); 35 | 36 | SubmitResultVO submitTask(Task task, Callable> discordSubmit); 37 | 38 | default Stream findRunningTask(TaskCondition condition) { 39 | return getRunningTasks().stream().filter(condition); 40 | } 41 | 42 | default Task getRunningTask(String id) { 43 | return getRunningTasks().stream().filter(t -> id.equals(t.getId())).findFirst().orElse(null); 44 | } 45 | 46 | default Task getRunningTaskByNonce(String nonce) { 47 | if (CharSequenceUtil.isBlank(nonce)) { 48 | return null; 49 | } 50 | TaskCondition condition = new TaskCondition().setNonce(nonce); 51 | return findRunningTask(condition).findFirst().orElse(null); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/domain/DomainObject.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.domain; 2 | 3 | 4 | import com.fasterxml.jackson.annotation.JsonIgnore; 5 | import io.swagger.annotations.ApiModelProperty; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | 9 | import java.io.Serializable; 10 | import java.util.HashMap; 11 | import java.util.Map; 12 | 13 | 14 | public class DomainObject implements Serializable { 15 | @Getter 16 | @Setter 17 | @ApiModelProperty("ID") 18 | protected String id; 19 | 20 | @Setter 21 | protected Map properties; // 扩展属性,仅支持基本类型 22 | 23 | @JsonIgnore 24 | private final transient Object lock = new Object(); 25 | 26 | public void sleep() throws InterruptedException { 27 | synchronized (this.lock) { 28 | this.lock.wait(); 29 | } 30 | } 31 | 32 | public void awake() { 33 | synchronized (this.lock) { 34 | this.lock.notifyAll(); 35 | } 36 | } 37 | 38 | public DomainObject setProperty(String name, Object value) { 39 | getProperties().put(name, value); 40 | return this; 41 | } 42 | 43 | public DomainObject removeProperty(String name) { 44 | getProperties().remove(name); 45 | return this; 46 | } 47 | 48 | public Object getProperty(String name) { 49 | return getProperties().get(name); 50 | } 51 | 52 | @SuppressWarnings("unchecked") 53 | public T getPropertyGeneric(String name) { 54 | return (T) getProperty(name); 55 | } 56 | 57 | public T getProperty(String name, Class clz) { 58 | return getProperty(name, clz, null); 59 | } 60 | 61 | public T getProperty(String name, Class clz, T defaultValue) { 62 | Object value = getProperty(name); 63 | return value == null ? defaultValue : clz.cast(value); 64 | } 65 | 66 | public Map getProperties() { 67 | if (this.properties == null) { 68 | this.properties = new HashMap<>(); 69 | } 70 | return this.properties; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/result/SubmitResultVO.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.result; 2 | 3 | import io.swagger.annotations.ApiModel; 4 | import io.swagger.annotations.ApiModelProperty; 5 | import lombok.Data; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | @Data 11 | @ApiModel("提交结果") 12 | public class SubmitResultVO { 13 | 14 | @ApiModelProperty(value = "状态码: 1(提交成功), 21(已存在), 22(排队中), other(错误)", required = true, example = "1") 15 | private int code; 16 | 17 | @ApiModelProperty(value = "描述", required = true, example = "提交成功") 18 | private String description; 19 | 20 | @ApiModelProperty(value = "任务ID", example = "1320098173412546") 21 | private String result; 22 | 23 | @ApiModelProperty(value = "扩展字段") 24 | private Map properties = new HashMap<>(); 25 | 26 | public SubmitResultVO setProperty(String name, Object value) { 27 | this.properties.put(name, value); 28 | return this; 29 | } 30 | 31 | public SubmitResultVO removeProperty(String name) { 32 | this.properties.remove(name); 33 | return this; 34 | } 35 | 36 | public Object getProperty(String name) { 37 | return this.properties.get(name); 38 | } 39 | 40 | @SuppressWarnings("unchecked") 41 | public T getPropertyGeneric(String name) { 42 | return (T) getProperty(name); 43 | } 44 | 45 | public T getProperty(String name, Class clz) { 46 | return clz.cast(getProperty(name)); 47 | } 48 | 49 | public static SubmitResultVO of(int code, String description, String result) { 50 | return new SubmitResultVO(code, description, result); 51 | } 52 | 53 | public static SubmitResultVO fail(int code, String description) { 54 | return new SubmitResultVO(code, description, null); 55 | } 56 | 57 | private SubmitResultVO(int code, String description, String result) { 58 | this.code = code; 59 | this.description = description; 60 | this.result = result; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/BlendSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | 4 | import com.github.novicezk.midjourney.enums.MessageType; 5 | import com.github.novicezk.midjourney.enums.TaskAction; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 7 | import com.github.novicezk.midjourney.support.Task; 8 | import com.github.novicezk.midjourney.support.TaskCondition; 9 | import com.github.novicezk.midjourney.util.ContentParseData; 10 | import com.github.novicezk.midjourney.util.ConvertUtils; 11 | import net.dv8tion.jda.api.utils.data.DataObject; 12 | import org.springframework.stereotype.Component; 13 | 14 | import java.util.Set; 15 | 16 | /** 17 | * blend消息处理. 18 | * 完成(create): ** --v 5.1** - <@1012983546824114217> (relaxed) 19 | */ 20 | @Component 21 | public class BlendSuccessHandler extends MessageHandler { 22 | 23 | @Override 24 | public int order() { 25 | return 89; 26 | } 27 | 28 | @Override 29 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 30 | String content = getMessageContent(message); 31 | ContentParseData parseData = ConvertUtils.parseContent(content); 32 | if (parseData == null || !MessageType.CREATE.equals(messageType)) { 33 | return; 34 | } 35 | String interactionName = getInteractionName(message); 36 | if ("blend".equals(interactionName)) { 37 | // blend任务开始时,设置prompt 38 | Task task = instance.getRunningTaskByNonce(getMessageNonce(message)); 39 | if (task != null) { 40 | task.setPromptEn(parseData.getPrompt()); 41 | task.setPrompt(parseData.getPrompt()); 42 | } 43 | } 44 | if (hasImage(message)) { 45 | TaskCondition condition = new TaskCondition() 46 | .setActionSet(Set.of(TaskAction.BLEND)) 47 | .setFinalPromptEn(parseData.getPrompt()); 48 | findAndFinishImageTask(instance, condition, parseData.getPrompt(), message); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/AsyncLockUtils.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import cn.hutool.cache.CacheUtil; 4 | import cn.hutool.cache.impl.TimedCache; 5 | import cn.hutool.core.thread.ThreadUtil; 6 | import com.github.novicezk.midjourney.domain.DomainObject; 7 | import lombok.experimental.UtilityClass; 8 | 9 | import java.time.Duration; 10 | import java.util.concurrent.ExecutionException; 11 | import java.util.concurrent.Future; 12 | import java.util.concurrent.TimeUnit; 13 | import java.util.concurrent.TimeoutException; 14 | 15 | @UtilityClass 16 | public class AsyncLockUtils { 17 | private static final TimedCache LOCK_MAP = CacheUtil.newTimedCache(Duration.ofDays(1).toMillis()); 18 | 19 | public static synchronized LockObject getLock(String key) { 20 | return LOCK_MAP.get(key); 21 | } 22 | 23 | public static LockObject waitForLock(String key, Duration duration) throws TimeoutException { 24 | LockObject lockObject; 25 | synchronized (LOCK_MAP) { 26 | if (LOCK_MAP.containsKey(key)) { 27 | lockObject = LOCK_MAP.get(key); 28 | } else { 29 | lockObject = new LockObject(key); 30 | LOCK_MAP.put(key, lockObject); 31 | } 32 | } 33 | Future future = ThreadUtil.execAsync(() -> { 34 | try { 35 | lockObject.sleep(); 36 | } catch (InterruptedException e) { 37 | Thread.currentThread().interrupt(); 38 | } 39 | }); 40 | try { 41 | future.get(duration.toMillis(), TimeUnit.MILLISECONDS); 42 | } catch (InterruptedException e) { 43 | Thread.currentThread().interrupt(); 44 | } catch (ExecutionException e) { 45 | // do nothing 46 | } catch (TimeoutException e) { 47 | future.cancel(true); 48 | throw new TimeoutException("Wait Timeout"); 49 | } finally { 50 | LOCK_MAP.remove(lockObject.getId()); 51 | } 52 | return lockObject; 53 | } 54 | 55 | public static class LockObject extends DomainObject { 56 | 57 | public LockObject(String id) { 58 | this.id = id; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/Task.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | import com.github.novicezk.midjourney.domain.DomainObject; 4 | import com.github.novicezk.midjourney.enums.TaskAction; 5 | import com.github.novicezk.midjourney.enums.TaskStatus; 6 | import io.swagger.annotations.ApiModel; 7 | import io.swagger.annotations.ApiModelProperty; 8 | import lombok.Data; 9 | import lombok.EqualsAndHashCode; 10 | 11 | import java.io.Serial; 12 | 13 | @Data 14 | @EqualsAndHashCode(callSuper = true) 15 | @ApiModel("任务") 16 | public class Task extends DomainObject { 17 | @Serial 18 | private static final long serialVersionUID = -674915748204390789L; 19 | 20 | @ApiModelProperty("任务类型") 21 | private TaskAction action; 22 | @ApiModelProperty("任务状态") 23 | private TaskStatus status = TaskStatus.NOT_START; 24 | 25 | @ApiModelProperty("提示词") 26 | private String prompt; 27 | @ApiModelProperty("提示词-英文") 28 | private String promptEn; 29 | 30 | @ApiModelProperty("任务描述") 31 | private String description; 32 | @ApiModelProperty("自定义参数") 33 | private String state; 34 | 35 | @ApiModelProperty("提交时间") 36 | private Long submitTime; 37 | @ApiModelProperty("开始执行时间") 38 | private Long startTime; 39 | @ApiModelProperty("结束时间") 40 | private Long finishTime; 41 | 42 | @ApiModelProperty("图片url") 43 | private String imageUrl; 44 | 45 | @ApiModelProperty("任务进度") 46 | private String progress; 47 | @ApiModelProperty("失败原因") 48 | private String failReason; 49 | 50 | public void start() { 51 | this.startTime = System.currentTimeMillis(); 52 | this.status = TaskStatus.SUBMITTED; 53 | this.progress = "0%"; 54 | } 55 | 56 | public void success() { 57 | this.finishTime = System.currentTimeMillis(); 58 | this.status = TaskStatus.SUCCESS; 59 | this.progress = "100%"; 60 | } 61 | 62 | public void fail(String reason) { 63 | this.finishTime = System.currentTimeMillis(); 64 | this.status = TaskStatus.FAILURE; 65 | this.failReason = reason; 66 | this.progress = ""; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/ZoomSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import com.github.novicezk.midjourney.enums.MessageType; 4 | import com.github.novicezk.midjourney.enums.TaskAction; 5 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 6 | import com.github.novicezk.midjourney.support.TaskCondition; 7 | import com.github.novicezk.midjourney.util.ContentParseData; 8 | import com.github.novicezk.midjourney.util.ConvertUtils; 9 | import net.dv8tion.jda.api.utils.data.DataObject; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.Set; 13 | 14 | /** 15 | * variation消息处理. 16 | * 完成(create): **cat** - Zoom Out by <@1012983546824114217> (relaxed) 17 | * 完成(create): **cat** - Zoom Out by <@1012983546824114217> (relaxed) 18 | */ 19 | @Component 20 | public class ZoomSuccessHandler extends MessageHandler { 21 | private static final String CONTENT_REGEX_1 = "\\*\\*(.*?)\\*\\* - Zoom Out by <@\\d+> \\((.*?)\\)"; 22 | private static final String CONTENT_REGEX_2 = "\\*\\*(.*?)\\*\\* - Zoom Out \\(.*?\\) by <@\\d+> \\((.*?)\\)"; 23 | 24 | @Override 25 | public void handle(DiscordInstance instance,MessageType messageType, DataObject message) { 26 | String content = getMessageContent(message); 27 | ContentParseData parseData = getParseData(content); 28 | if (MessageType.CREATE.equals(messageType) && parseData != null && hasImage(message)) { 29 | TaskCondition condition = new TaskCondition() 30 | .setActionSet(Set.of(TaskAction.ZOOM_1,TaskAction.ZOOM_2)) 31 | .setFinalPromptEn(parseData.getPrompt()); 32 | findAndFinishImageTask(instance,condition, parseData.getPrompt(), message); 33 | } 34 | } 35 | 36 | private ContentParseData getParseData(String content) { 37 | ContentParseData parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_1); 38 | if (parseData == null) { 39 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_2); 40 | } 41 | return parseData; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/MoveSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import com.github.novicezk.midjourney.enums.MessageType; 4 | import com.github.novicezk.midjourney.enums.TaskAction; 5 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 6 | import com.github.novicezk.midjourney.support.TaskCondition; 7 | import com.github.novicezk.midjourney.util.ContentParseData; 8 | import com.github.novicezk.midjourney.util.ConvertUtils; 9 | import net.dv8tion.jda.api.utils.data.DataObject; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.Set; 13 | 14 | /** 15 | * variation消息处理. 16 | * 完成(create): **cat** - Pan Down by <@1012983546824114217> (relaxed) 17 | * 完成(create): **cat** - Variations by <@1012983546824114217> (relaxed) 18 | */ 19 | @Component 20 | public class MoveSuccessHandler extends MessageHandler { 21 | private static final String CONTENT_REGEX_1 = "\\*\\*(.*?)\\*\\* - Pan *.* by <@\\d+> \\((.*?)\\)"; 22 | private static final String CONTENT_REGEX_2 = "\\*\\*(.*?)\\*\\* - Pan \\(.*?\\) by <@\\d+> \\((.*?)\\)"; 23 | 24 | @Override 25 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 26 | String content = getMessageContent(message); 27 | ContentParseData parseData = getParseData(content); 28 | if (MessageType.CREATE.equals(messageType) && parseData != null && hasImage(message)) { 29 | TaskCondition condition = new TaskCondition() 30 | .setActionSet(Set.of(TaskAction.MOVE_UP,TaskAction.MOVE_DOWN, TaskAction.MOVE_LEFT,TaskAction.MOVE_RIGHT)) 31 | .setFinalPromptEn(parseData.getPrompt()); 32 | findAndFinishImageTask(instance, condition, parseData.getPrompt(), message); 33 | } 34 | } 35 | 36 | private ContentParseData getParseData(String content) { 37 | ContentParseData parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_1); 38 | if (parseData == null) { 39 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_2); 40 | } 41 | return parseData; 42 | } 43 | 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/VariationSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import com.github.novicezk.midjourney.enums.MessageType; 4 | import com.github.novicezk.midjourney.enums.TaskAction; 5 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 6 | import com.github.novicezk.midjourney.support.TaskCondition; 7 | import com.github.novicezk.midjourney.util.ContentParseData; 8 | import com.github.novicezk.midjourney.util.ConvertUtils; 9 | import net.dv8tion.jda.api.utils.data.DataObject; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.Set; 13 | 14 | /** 15 | * variation消息处理. 16 | * 完成(create): **cat** - Variations (Strong\Subtle\Region等) by <@1012983546824114217> (relaxed) 17 | * 完成(create): **cat** - Variations by <@1012983546824114217> (relaxed) 18 | */ 19 | @Component 20 | public class VariationSuccessHandler extends MessageHandler { 21 | private static final String CONTENT_REGEX_1 = "\\*\\*(.*?)\\*\\* - Variations by <@\\d+> \\((.*?)\\)"; 22 | private static final String CONTENT_REGEX_2 = "\\*\\*(.*?)\\*\\* - Variations \\(.*?\\) by <@\\d+> \\((.*?)\\)"; 23 | 24 | @Override 25 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 26 | String content = getMessageContent(message); 27 | ContentParseData parseData = getParseData(content); 28 | if (MessageType.CREATE.equals(messageType) && parseData != null && hasImage(message)) { 29 | TaskCondition condition = new TaskCondition() 30 | .setActionSet(Set.of(TaskAction.VARIATION,TaskAction.VARY_LOW,TaskAction.VARY_HIGH)) 31 | .setFinalPromptEn(parseData.getPrompt()); 32 | findAndFinishImageTask(instance, condition, parseData.getPrompt(), message); 33 | } 34 | } 35 | 36 | private ContentParseData getParseData(String content) { 37 | ContentParseData parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_1); 38 | if (parseData == null) { 39 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_2); 40 | } 41 | return parseData; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/user/UserMessageListener.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.user; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import cn.hutool.core.thread.ThreadUtil; 6 | import com.github.novicezk.midjourney.Constants; 7 | import com.github.novicezk.midjourney.enums.MessageType; 8 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 9 | import com.github.novicezk.midjourney.wss.handle.MessageHandler; 10 | import lombok.extern.slf4j.Slf4j; 11 | import net.dv8tion.jda.api.utils.data.DataObject; 12 | 13 | import java.util.List; 14 | 15 | @Slf4j 16 | public class UserMessageListener { 17 | private DiscordInstance instance; 18 | private final List messageHandlers; 19 | 20 | public UserMessageListener(List messageHandlers) { 21 | this.messageHandlers = messageHandlers; 22 | } 23 | 24 | public void setInstance(DiscordInstance instance) { 25 | this.instance = instance; 26 | } 27 | 28 | public void onMessage(DataObject raw) { 29 | MessageType messageType = MessageType.of(raw.getString("t")); 30 | if (messageType == null || MessageType.DELETE == messageType) { 31 | return; 32 | } 33 | DataObject data = raw.getObject("d"); 34 | if (ignoreAndLogMessage(data, messageType)) { 35 | return; 36 | } 37 | ThreadUtil.sleep(50); 38 | for (MessageHandler messageHandler : this.messageHandlers) { 39 | if (data.getBoolean(Constants.MJ_MESSAGE_HANDLED, false)) { 40 | return; 41 | } 42 | messageHandler.handle(this.instance, messageType, data); 43 | } 44 | } 45 | 46 | private boolean ignoreAndLogMessage(DataObject data, MessageType messageType) { 47 | String channelId = data.getString("channel_id"); 48 | if (!CharSequenceUtil.equals(channelId, this.instance.account().getChannelId())) { 49 | return true; 50 | } 51 | String authorName = data.optObject("author").map(a -> a.getString("username")).orElse("System"); 52 | log.debug("{} - {} - {}: {}", this.instance.account().getDisplay(), messageType.name(), authorName, data.opt("content").orElse("")); 53 | return false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/loadbalancer/DiscordLoadBalancer.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.loadbalancer; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.loadbalancer.rule.IRule; 6 | import com.github.novicezk.midjourney.support.Task; 7 | import com.github.novicezk.midjourney.support.TaskCondition; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.util.ArrayList; 12 | import java.util.Collections; 13 | import java.util.HashSet; 14 | import java.util.List; 15 | import java.util.Optional; 16 | import java.util.Set; 17 | import java.util.stream.Stream; 18 | 19 | @Component 20 | @RequiredArgsConstructor 21 | public class DiscordLoadBalancer { 22 | private final IRule rule; 23 | 24 | private final List instances = Collections.synchronizedList(new ArrayList<>()); 25 | 26 | public List getAllInstances() { 27 | return this.instances; 28 | } 29 | 30 | public List getAliveInstances() { 31 | return this.instances.stream().filter(DiscordInstance::isAlive).toList(); 32 | } 33 | 34 | public DiscordInstance chooseInstance() { 35 | return this.rule.choose(getAliveInstances()); 36 | } 37 | 38 | public DiscordInstance getDiscordInstance(String instanceId) { 39 | if (CharSequenceUtil.isBlank(instanceId)) { 40 | return null; 41 | } 42 | return this.instances.stream() 43 | .filter(instance -> CharSequenceUtil.equals(instanceId, instance.getInstanceId())) 44 | .findFirst().orElse(null); 45 | } 46 | 47 | public Set getQueueTaskIds() { 48 | Set taskIds = Collections.synchronizedSet(new HashSet<>()); 49 | for (DiscordInstance instance : getAliveInstances()) { 50 | taskIds.addAll(instance.getRunningFutures().keySet()); 51 | } 52 | return taskIds; 53 | } 54 | 55 | public List getQueueTasks() { 56 | List tasks = new ArrayList<>(); 57 | for (DiscordInstance instance : getAliveInstances()) { 58 | tasks.addAll(instance.getQueueTasks()); 59 | } 60 | return tasks; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/RerollSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | 4 | import com.github.novicezk.midjourney.enums.MessageType; 5 | import com.github.novicezk.midjourney.enums.TaskAction; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 7 | import com.github.novicezk.midjourney.support.TaskCondition; 8 | import com.github.novicezk.midjourney.util.ContentParseData; 9 | import com.github.novicezk.midjourney.util.ConvertUtils; 10 | import net.dv8tion.jda.api.utils.data.DataObject; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.Set; 14 | 15 | /** 16 | * reroll 消息处理. 17 | * 完成(create): **cat** - <@1012983546824114217> (relaxed) 18 | * 完成(create): **cat** - Variations by <@1012983546824114217> (relaxed) 19 | * 完成(create): **cat** - Variations (Strong或Subtle) by <@1012983546824114217> (relaxed) 20 | */ 21 | @Component 22 | public class RerollSuccessHandler extends MessageHandler { 23 | private static final String CONTENT_REGEX_1 = "\\*\\*(.*?)\\*\\* - <@\\d+> \\((.*?)\\)"; 24 | private static final String CONTENT_REGEX_2 = "\\*\\*(.*?)\\*\\* - Variations by <@\\d+> \\((.*?)\\)"; 25 | private static final String CONTENT_REGEX_3 = "\\*\\*(.*?)\\*\\* - Variations \\(.*?\\) by <@\\d+> \\((.*?)\\)"; 26 | 27 | @Override 28 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 29 | String content = getMessageContent(message); 30 | ContentParseData parseData = getParseData(content); 31 | if (MessageType.CREATE.equals(messageType) && parseData != null && hasImage(message)) { 32 | TaskCondition condition = new TaskCondition() 33 | .setActionSet(Set.of(TaskAction.REROLL)) 34 | .setFinalPromptEn(parseData.getPrompt()); 35 | findAndFinishImageTask(instance, condition, parseData.getPrompt(), message); 36 | } 37 | } 38 | 39 | private ContentParseData getParseData(String content) { 40 | ContentParseData parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_1); 41 | if (parseData == null) { 42 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_2); 43 | } 44 | if (parseData == null) { 45 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_3); 46 | } 47 | return parseData; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/DiscordAccountHelper.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.Constants; 6 | import com.github.novicezk.midjourney.ProxyProperties; 7 | import com.github.novicezk.midjourney.domain.DiscordAccount; 8 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 9 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstanceImpl; 10 | import com.github.novicezk.midjourney.service.NotifyService; 11 | import com.github.novicezk.midjourney.service.TaskStoreService; 12 | import com.github.novicezk.midjourney.wss.handle.MessageHandler; 13 | import com.github.novicezk.midjourney.wss.user.SpringUserWebSocketStarter; 14 | import com.github.novicezk.midjourney.wss.user.UserMessageListener; 15 | import lombok.RequiredArgsConstructor; 16 | import org.springframework.web.client.RestTemplate; 17 | 18 | import java.util.List; 19 | import java.util.Map; 20 | 21 | @RequiredArgsConstructor 22 | public class DiscordAccountHelper { 23 | private final DiscordHelper discordHelper; 24 | private final ProxyProperties properties; 25 | private final RestTemplate restTemplate; 26 | private final TaskStoreService taskStoreService; 27 | private final NotifyService notifyService; 28 | private final List messageHandlers; 29 | private final Map paramsMap; 30 | 31 | public DiscordInstance createDiscordInstance(DiscordAccount account) { 32 | if (!CharSequenceUtil.isAllNotBlank(account.getGuildId(), account.getChannelId(), account.getUserToken())) { 33 | throw new IllegalArgumentException("guildId, channelId, userToken must not be blank"); 34 | } 35 | if (CharSequenceUtil.isBlank(account.getUserAgent())) { 36 | account.setUserAgent(Constants.DEFAULT_DISCORD_USER_AGENT); 37 | } 38 | var messageListener = new UserMessageListener(this.messageHandlers); 39 | var webSocketStarter = new SpringUserWebSocketStarter(this.discordHelper.getWss(), this.discordHelper.getResumeWss(), account, messageListener); 40 | var discordInstance = new DiscordInstanceImpl(account, webSocketStarter, this.restTemplate, this.taskStoreService, this.notifyService, this.paramsMap); 41 | messageListener.setInstance(discordInstance); 42 | return discordInstance; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/resources/mime.types: -------------------------------------------------------------------------------- 1 | text/html:html htm shtml 2 | text/css:css 3 | text/xml:xml 4 | 5 | text/mathml:mml 6 | text/plain:txt 7 | text/vnd.sun.j2me.app-descriptor:jad 8 | text/vnd.wap.wml:wml 9 | text/x-component:htc 10 | 11 | image/gif:gif 12 | image/jpeg:jpg jpeg 13 | image/png:png 14 | image/tiff:tif tiff 15 | image/vnd.wap.wbmp:wbmp 16 | image/x-icon:ico 17 | image/x-jng:jng 18 | image/x-ms-bmp:bmp 19 | image/svg+xml:svg svgz 20 | image/webp:webp 21 | 22 | application/javascript:js 23 | application/x-javascript:js 24 | application/atom+xml:atom 25 | application/rss+xml:rss 26 | 27 | application/font-woff:woff 28 | application/java-archive:jar war ear 29 | application/json:json 30 | application/mac-binhex40:hqx 31 | application/msword:doc 32 | application/pdf:pdf 33 | application/postscript:ps eps ai 34 | application/rtf:rtf 35 | application/vnd.apple.mpegurl:m3u8 36 | application/vnd.ms-excel:xls 37 | application/vnd.ms-fontobject:eot 38 | application/vnd.ms-powerpoint:ppt 39 | application/vnd.wap.wmlc:wmlc 40 | application/vnd.google-earth.kml+xml:kml 41 | application/vnd.google-earth.kmz:kmz 42 | application/x-7z-compressed:7z 43 | application/x-cocoa:cco 44 | application/x-java-archive-diff:jardiff 45 | application/x-java-jnlp-file:jnlp 46 | application/x-makeself:run 47 | application/x-perl:pl pm 48 | application/x-pilot:prc pdb 49 | application/x-rar-compressed:rar 50 | application/x-redhat-package-manager:rpm 51 | application/x-sea:sea 52 | application/x-shockwave-flash:swf 53 | application/x-stuffit:sit 54 | application/x-tcl:tcl tk 55 | application/x-x509-ca-cert:der pem crt 56 | application/x-xpinstall:xpi 57 | application/xhtml+xml:xhtml 58 | application/xspf+xml:xspf 59 | application/zip:zip 60 | 61 | application/vnd.openxmlformats-officedocument.wordprocessingml.document:docx 62 | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:xlsx 63 | application/vnd.openxmlformats-officedocument.presentationml.presentation:pptx 64 | 65 | audio/midi:mid midi kar 66 | audio/mpeg:mp3 67 | audio/ogg:ogg 68 | audio/x-m4a:m4a 69 | audio/x-realaudio:ra 70 | 71 | video/3gpp:3gpp 3gp 72 | video/mp2t:ts 73 | video/mp4:mp4 74 | video/mpeg:mpeg mpg 75 | video/quicktime:mov 76 | video/webm:webm 77 | video/x-flv:flv 78 | video/x-m4v:m4v 79 | video/x-mng:mng 80 | video/x-ms-asf:asx asf 81 | video/x-ms-wmv:wmv 82 | video/x-msvideo:avi 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

midjourney-proxy

4 | 5 | [![GitHub release](https://img.shields.io/static/v1?label=release&message=v2.5.1&color=blue)](https://www.github.com/novicezk/midjourney-proxy) 6 | [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) 7 | 8 | ## 主要功能 9 | - [x] 支持 Imagine 指令和相关动作 10 | - [x] Imagine 时支持添加图片base64,作为垫图 11 | - [x] 支持 Blend(图片混合)、Describe(图生文) 指令 12 | - [x] 支持任务实时进度 13 | - [x] 支持中文prompt翻译,需配置百度翻译或gpt 14 | - [x] prompt 敏感词预检测,支持覆盖调整 15 | - [x] user-token 连接 wss,可以获取错误信息和完整功能 16 | - [x] 支持 discord域名(server、cdn、wss)反代,配置 mj.ng-discord 17 | - [x] 支持多账号配置,每个账号可设置对应的任务队列 18 | 19 | ## 以下为在原版基础增加的功能 20 | - [x] 支持U之后的所有相关动作:Zoom(图片变焦)、Pan(焦点移动) 等 21 | - [x] 支持U之后的所有相关动作:Vary(Strong)、Vary(Subtle) 等 22 | - [x] 支持帐户info功能 23 | - [x] 支持settings所有属性 24 | - [x] 支持Upscale(2x)、Upscale(4x) 25 | 26 | ## 使用前提 27 | 1. 注册并订阅 MidJourney,创建自己的频道,参考 https://docs.midjourney.com/docs/quick-start 28 | 2. 获取用户Token、服务器ID、频道ID:[获取方式](./docs/discord-params.md) 29 | 30 | ## 配置项 31 | - mj.accounts: 参考 [账号池配置](./docs/config.md#%E8%B4%A6%E5%8F%B7%E6%B1%A0%E9%85%8D%E7%BD%AE%E5%8F%82%E8%80%83) 32 | - mj.task-store.type: 任务存储方式,默认in_memory(内存\重启后丢失),可选redis 33 | - mj.task-store.timeout: 任务存储过期时间,过期后删除,默认30天 34 | - mj.api-secret: 接口密钥,为空不启用鉴权;调用接口时需要加请求头 mj-api-secret 35 | - mj.translate-way: 中文prompt翻译成英文的方式,可选null(默认)、baidu、gpt、deepl 36 | - 更多配置查看 [配置项](./docs/config.md) 37 | 38 | ## 相关文档 39 | 1. [API接口说明](./docs/api.md) 40 | 2. [版本更新记录](https://github.com/imkratos/midjourney-proxy/wiki/%E6%9B%B4%E6%96%B0%E8%AE%B0%E5%BD%95) 41 | 42 | ## 注意事项 43 | 1. 作图频繁等行为,可能会触发midjourney账号警告,请谨慎使用 44 | 2. 常见问题及解决办法见 [Wiki / FAQ](https://github.com/imkratos/midjourney-proxy/wiki/FAQ) 45 | 3. 在 [Issues](https://github.com/imkratos/midjourney-proxy/issues) 中提出其他问题或建议 46 | 47 | ## 应用项目 48 | 依赖此项目且开源的,欢迎联系作者,加到此处展示 49 | - [wechat-midjourney](https://github.com/novicezk/wechat-midjourney) : 代理微信客户端,接入MidJourney,仅示例应用场景,不再更新 50 | - [stable-diffusion-mobileui](https://github.com/yuanyuekeji/stable-diffusion-mobileui) : SDUI,基于本接口和SD,可一键打包生成H5和小程序 51 | - [ChatGPT-Midjourney](https://github.com/Licoy/ChatGPT-Midjourney) : 一键拥有你自己的 ChatGPT+Midjourney 网页服务 52 | - [MidJourney-Web](https://github.com/ConnectAI-E/MidJourney-Web) : 🍎 Supercharged Experience For MidJourney On Web UI 53 | 54 | ## 其它 55 | 如果觉得这个项目对你有所帮助,请帮忙点个star;也可以请作者喝杯茶~ 56 | 57 | 微信二维码 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: CI 10 | 11 | on: 12 | push: 13 | branches: 14 | - "main" 15 | tags: 16 | - 'v*' 17 | pull_request: 18 | branches: [ "main" ] 19 | 20 | env: 21 | # Use docker.io for Docker Hub if empty 22 | REGISTRY: 'docker.io' 23 | # github.repository as / 24 | #IMAGE_NAME: ${{ github.repository }} 25 | IMAGE_NAME: kratos1937/midjourney-proxy 26 | 27 | jobs: 28 | build: 29 | 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Set up JDK 17 35 | uses: actions/setup-java@v3 36 | with: 37 | java-version: '17' 38 | distribution: 'temurin' 39 | cache: maven 40 | - name: Build the Docker image 41 | run: docker build . --file Dockerfile --tag ${{env.IMAGE_NAME}} 42 | - name: echo 43 | run: echo $GITHUB_REF_NAME 44 | 45 | # 登录 46 | - name: Log into registry ${{ env.REGISTRY }} 47 | if: github.event_name != 'pull_request' 48 | uses: docker/login-action@v2 49 | with: 50 | registry: ${{ env.REGISTRY }} 51 | username: ${{ secrets.DOCKER_USERNAME }} 52 | password: ${{ secrets.DOCKER_PASSWORD }} 53 | # Extract metadata (tags, labels) for Docker 54 | # https://github.com/docker/metadata-action 55 | - name: Extract Docker metadata 56 | id: meta 57 | uses: docker/metadata-action@v4 58 | with: 59 | images: ${{ env.IMAGE_NAME }} 60 | tags: | 61 | # set latest tag for default branch 62 | type=raw,value=latest,enable={{is_default_branch}} 63 | # tag event 64 | type=ref,enable=true,priority=600,prefix=,suffix=,event=tag 65 | # 推送 66 | - name: Build and push Docker image 67 | uses: docker/build-push-action@v3 68 | with: 69 | context: . 70 | push: ${{ github.event_name != 'pull_request' }} 71 | tags: ${{ steps.meta.outputs.tags }} 72 | labels: ${{ steps.meta.outputs.labels }} 73 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/store/RedisTaskStoreServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service.store; 2 | 3 | import com.github.novicezk.midjourney.service.TaskStoreService; 4 | import com.github.novicezk.midjourney.support.Task; 5 | import com.github.novicezk.midjourney.support.TaskCondition; 6 | import org.springframework.data.redis.core.Cursor; 7 | import org.springframework.data.redis.core.RedisCallback; 8 | import org.springframework.data.redis.core.RedisTemplate; 9 | import org.springframework.data.redis.core.ScanOptions; 10 | import org.springframework.data.redis.core.ValueOperations; 11 | 12 | import java.time.Duration; 13 | import java.util.Collections; 14 | import java.util.List; 15 | import java.util.Objects; 16 | import java.util.Set; 17 | import java.util.stream.Collectors; 18 | 19 | public class RedisTaskStoreServiceImpl implements TaskStoreService { 20 | private static final String KEY_PREFIX = "mj-task-store::"; 21 | 22 | private final Duration timeout; 23 | private final RedisTemplate redisTemplate; 24 | 25 | public RedisTaskStoreServiceImpl(Duration timeout, RedisTemplate redisTemplate) { 26 | this.timeout = timeout; 27 | this.redisTemplate = redisTemplate; 28 | } 29 | 30 | @Override 31 | public void save(Task task) { 32 | this.redisTemplate.opsForValue().set(getRedisKey(task.getId()), task, this.timeout); 33 | } 34 | 35 | @Override 36 | public void delete(String id) { 37 | this.redisTemplate.delete(getRedisKey(id)); 38 | } 39 | 40 | @Override 41 | public Task get(String id) { 42 | return this.redisTemplate.opsForValue().get(getRedisKey(id)); 43 | } 44 | 45 | @Override 46 | public List list() { 47 | Set keys = this.redisTemplate.execute((RedisCallback>) connection -> { 48 | Cursor cursor = connection.scan(ScanOptions.scanOptions().match(KEY_PREFIX + "*").count(1000).build()); 49 | return cursor.stream().map(String::new).collect(Collectors.toSet()); 50 | }); 51 | if (keys == null || keys.isEmpty()) { 52 | return Collections.emptyList(); 53 | } 54 | ValueOperations operations = this.redisTemplate.opsForValue(); 55 | return keys.stream().map(operations::get) 56 | .filter(Objects::nonNull) 57 | .toList(); 58 | } 59 | 60 | @Override 61 | public List list(TaskCondition condition) { 62 | return list().stream().filter(condition).toList(); 63 | } 64 | 65 | @Override 66 | public Task findOne(TaskCondition condition) { 67 | return list().stream().filter(condition).findFirst().orElse(null); 68 | } 69 | 70 | private String getRedisKey(String id) { 71 | return KEY_PREFIX + id; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/TaskCondition.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.Constants; 5 | import com.github.novicezk.midjourney.enums.TaskAction; 6 | import com.github.novicezk.midjourney.enums.TaskStatus; 7 | import lombok.Data; 8 | import lombok.experimental.Accessors; 9 | 10 | import java.util.Set; 11 | import java.util.function.Predicate; 12 | 13 | 14 | @Data 15 | @Accessors(chain = true) 16 | public class TaskCondition implements Predicate { 17 | private String id; 18 | 19 | private Set statusSet; 20 | private Set actionSet; 21 | 22 | private String prompt; 23 | private String promptEn; 24 | private String description; 25 | 26 | private String finalPromptEn; 27 | private String messageId; 28 | private String messageHash; 29 | private String progressMessageId; 30 | private String nonce; 31 | 32 | @Override 33 | public boolean test(Task task) { 34 | if (task == null) { 35 | return false; 36 | } 37 | if (CharSequenceUtil.isNotBlank(this.id) && !this.id.equals(task.getId())) { 38 | return false; 39 | } 40 | if (this.statusSet != null && !this.statusSet.isEmpty() && !this.statusSet.contains(task.getStatus())) { 41 | return false; 42 | } 43 | if (this.actionSet != null && !this.actionSet.isEmpty() && !this.actionSet.contains(task.getAction())) { 44 | return false; 45 | } 46 | if (CharSequenceUtil.isNotBlank(this.prompt) && !this.prompt.equals(task.getPrompt())) { 47 | return false; 48 | } 49 | if (CharSequenceUtil.isNotBlank(this.promptEn) && !this.promptEn.equals(task.getPromptEn())) { 50 | return false; 51 | } 52 | if (CharSequenceUtil.isNotBlank(this.description) && !CharSequenceUtil.contains(task.getDescription(), this.description)) { 53 | return false; 54 | } 55 | 56 | if (CharSequenceUtil.isNotBlank(this.finalPromptEn) && !this.finalPromptEn.equals(task.getProperty(Constants.TASK_PROPERTY_FINAL_PROMPT))) { 57 | return false; 58 | } 59 | if (CharSequenceUtil.isNotBlank(this.messageId) && !this.messageId.equals(task.getProperty(Constants.TASK_PROPERTY_MESSAGE_ID))) { 60 | return false; 61 | } 62 | if (CharSequenceUtil.isNotBlank(this.messageHash) && !this.messageHash.equals(task.getProperty(Constants.TASK_PROPERTY_MESSAGE_HASH))) { 63 | return false; 64 | } 65 | if (CharSequenceUtil.isNotBlank(this.progressMessageId) && !this.progressMessageId.equals(task.getProperty(Constants.TASK_PROPERTY_PROGRESS_MESSAGE_ID))) { 66 | return false; 67 | } 68 | if (CharSequenceUtil.isNotBlank(this.nonce) && !this.nonce.equals(task.getProperty(Constants.TASK_PROPERTY_NONCE))) { 69 | return false; 70 | } 71 | return true; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/StartAndProgressHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.Constants; 6 | import com.github.novicezk.midjourney.enums.MessageType; 7 | import com.github.novicezk.midjourney.enums.TaskStatus; 8 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 9 | import com.github.novicezk.midjourney.support.Task; 10 | import com.github.novicezk.midjourney.support.TaskCondition; 11 | import com.github.novicezk.midjourney.util.ContentParseData; 12 | import com.github.novicezk.midjourney.util.ConvertUtils; 13 | import lombok.extern.slf4j.Slf4j; 14 | import net.dv8tion.jda.api.utils.data.DataObject; 15 | import org.springframework.stereotype.Component; 16 | 17 | import java.util.Set; 18 | 19 | @Slf4j 20 | @Component 21 | public class StartAndProgressHandler extends MessageHandler { 22 | 23 | @Override 24 | public int order() { 25 | return 90; 26 | } 27 | 28 | @Override 29 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 30 | String nonce = getMessageNonce(message); 31 | String content = getMessageContent(message); 32 | ContentParseData parseData = ConvertUtils.parseContent(content); 33 | if (MessageType.CREATE.equals(messageType) && CharSequenceUtil.isNotBlank(nonce)) { 34 | // 任务开始 35 | Task task = instance.getRunningTaskByNonce(nonce); 36 | if (task == null) { 37 | return; 38 | } 39 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 40 | task.setProperty(Constants.TASK_PROPERTY_PROGRESS_MESSAGE_ID, message.getString("id")); 41 | // 兼容少数content为空的场景 42 | if (parseData != null) { 43 | task.setProperty(Constants.TASK_PROPERTY_FINAL_PROMPT, parseData.getPrompt()); 44 | } 45 | task.setStatus(TaskStatus.IN_PROGRESS); 46 | task.awake(); 47 | } else if (MessageType.UPDATE.equals(messageType) && parseData != null) { 48 | // 任务进度 49 | if ("Stopped".equals(parseData.getStatus())) { 50 | return; 51 | } 52 | TaskCondition condition = new TaskCondition().setStatusSet(Set.of(TaskStatus.IN_PROGRESS)) 53 | .setProgressMessageId(message.getString("id")); 54 | Task task = instance.findRunningTask(condition).findFirst().orElse(null); 55 | if (task == null) { 56 | return; 57 | } 58 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 59 | task.setProperty(Constants.TASK_PROPERTY_FINAL_PROMPT, parseData.getPrompt()); 60 | task.setStatus(TaskStatus.IN_PROGRESS); 61 | task.setProgress(parseData.getStatus()); 62 | String imageUrl = getImageUrl(message); 63 | task.setImageUrl(imageUrl); 64 | task.setProperty(Constants.TASK_PROPERTY_MESSAGE_HASH, this.discordHelper.getMessageHash(imageUrl)); 65 | task.awake(); 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/UpscaleSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import com.github.novicezk.midjourney.enums.MessageType; 4 | import com.github.novicezk.midjourney.enums.TaskAction; 5 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 6 | import com.github.novicezk.midjourney.support.TaskCondition; 7 | import com.github.novicezk.midjourney.util.ContentParseData; 8 | import com.github.novicezk.midjourney.util.ConvertUtils; 9 | import net.dv8tion.jda.api.utils.data.DataObject; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.Set; 13 | import java.util.regex.Matcher; 14 | import java.util.regex.Pattern; 15 | 16 | /** 17 | * upscale消息处理. 18 | * 完成(create): **cat** - Upscaled (Beta\Light\Creative等) by <@1083152202048217169> (fast) 19 | * 完成(create): **cat** - Upscaled by <@1083152202048217169> (fast) 20 | * 完成(create): **cat** - Image #1 <@1012983546824114217> 21 | */ 22 | @Component 23 | public class UpscaleSuccessHandler extends MessageHandler { 24 | private static final String CONTENT_REGEX_1 = "\\*\\*(.*?)\\*\\* - Upscaled \\(.*?\\) by <@\\d+> \\((.*?)\\)"; 25 | private static final String CONTENT_REGEX_2 = "\\*\\*(.*?)\\*\\* - Upscaled by <@\\d+> \\((.*?)\\)"; 26 | private static final String CONTENT_REGEX_3 = "\\*\\*(.*?)\\*\\* - Image #\\d <@\\d+>"; 27 | private static final String CONTENT_REGEX_4 = "\\*\\*(.*?)\\*\\* - Upscaling \\(.*?\\) by <@\\d+> \\((.*?)\\)"; 28 | 29 | @Override 30 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 31 | String content = getMessageContent(message); 32 | ContentParseData parseData = getParseData(content); 33 | if (MessageType.CREATE.equals(messageType) && parseData != null && hasImage(message)) { 34 | TaskCondition condition = new TaskCondition() 35 | .setActionSet(Set.of(TaskAction.UPSCALE,TaskAction.UP2,TaskAction.UP4)) 36 | .setFinalPromptEn(parseData.getPrompt()); 37 | findAndFinishImageTask(instance, condition, parseData.getPrompt(), message); 38 | } 39 | } 40 | 41 | private ContentParseData getParseData(String content) { 42 | ContentParseData parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_1); 43 | if (parseData == null) { 44 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_2); 45 | //just try need modify 46 | if (parseData == null) { 47 | parseData = ConvertUtils.parseContent(content, CONTENT_REGEX_4); 48 | } 49 | } 50 | if (parseData != null) { 51 | return parseData; 52 | } 53 | Matcher matcher = Pattern.compile(CONTENT_REGEX_3).matcher(content); 54 | if (!matcher.find()) { 55 | return null; 56 | } 57 | parseData = new ContentParseData(); 58 | parseData.setPrompt(matcher.group(1)); 59 | parseData.setStatus("done"); 60 | return parseData; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/DescribeSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.Constants; 5 | import com.github.novicezk.midjourney.enums.MessageType; 6 | import com.github.novicezk.midjourney.enums.TaskAction; 7 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 8 | import com.github.novicezk.midjourney.support.Task; 9 | import com.github.novicezk.midjourney.support.TaskCondition; 10 | import net.dv8tion.jda.api.utils.data.DataArray; 11 | import net.dv8tion.jda.api.utils.data.DataObject; 12 | import org.springframework.stereotype.Component; 13 | 14 | import java.util.Optional; 15 | import java.util.Set; 16 | 17 | /** 18 | * describe消息处理. 19 | */ 20 | @Component 21 | public class DescribeSuccessHandler extends MessageHandler { 22 | 23 | @Override 24 | public int order() { 25 | return 10; 26 | } 27 | 28 | @Override 29 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 30 | String messageId = message.getString("id"); 31 | if (MessageType.CREATE.equals(messageType)) { 32 | String interactionName = getInteractionName(message); 33 | if (!"describe".equals(interactionName)) { 34 | return; 35 | } 36 | // 任务开始 37 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 38 | String nonce = getMessageNonce(message); 39 | Task task = instance.getRunningTaskByNonce(nonce); 40 | if (task == null) { 41 | return; 42 | } 43 | task.setProperty(Constants.TASK_PROPERTY_PROGRESS_MESSAGE_ID, messageId); 44 | } else if (MessageType.UPDATE.equals(messageType)) { 45 | finishDescribeTask(instance, message, messageId); 46 | } 47 | } 48 | 49 | private void finishDescribeTask(DiscordInstance instance, DataObject message, String progressMessageId) { 50 | DataArray embeds = message.getArray("embeds"); 51 | if (CharSequenceUtil.isBlank(progressMessageId) || embeds.isEmpty()) { 52 | return; 53 | } 54 | TaskCondition condition = new TaskCondition().setActionSet(Set.of(TaskAction.DESCRIBE)).setProgressMessageId(progressMessageId); 55 | Task task = instance.findRunningTask(condition).findFirst().orElse(null); 56 | if (task == null) { 57 | return; 58 | } 59 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 60 | String description = embeds.getObject(0).getString("description"); 61 | task.setPrompt(description); 62 | task.setPromptEn(description); 63 | task.setProperty(Constants.TASK_PROPERTY_FINAL_PROMPT, description); 64 | Optional imageOptional = embeds.getObject(0).optObject("image"); 65 | if (imageOptional.isPresent()) { 66 | String imageUrl = imageOptional.get().getString("url"); 67 | task.setImageUrl(replaceCdnUrl(imageUrl)); 68 | } 69 | finishTask(task, message); 70 | task.awake(); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/ConvertUtils.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.enums.TaskAction; 5 | import eu.maxschuster.dataurl.DataUrl; 6 | import eu.maxschuster.dataurl.DataUrlSerializer; 7 | import eu.maxschuster.dataurl.IDataUrlSerializer; 8 | import lombok.experimental.UtilityClass; 9 | 10 | import java.net.MalformedURLException; 11 | import java.util.ArrayList; 12 | import java.util.Collections; 13 | import java.util.List; 14 | import java.util.regex.Matcher; 15 | import java.util.regex.Pattern; 16 | 17 | @UtilityClass 18 | public class ConvertUtils { 19 | /** 20 | * content正则匹配prompt和进度. 21 | */ 22 | public static final String CONTENT_REGEX = ".*?\\*\\*(.*?)\\*\\*.+<@\\d+> \\((.*?)\\)"; 23 | 24 | public static ContentParseData parseContent(String content) { 25 | return parseContent(content, CONTENT_REGEX); 26 | } 27 | 28 | public static ContentParseData parseContent(String content, String regex) { 29 | if (CharSequenceUtil.isBlank(content)) { 30 | return null; 31 | } 32 | Matcher matcher = Pattern.compile(regex).matcher(content); 33 | if (!matcher.find()) { 34 | return null; 35 | } 36 | ContentParseData parseData = new ContentParseData(); 37 | parseData.setPrompt(matcher.group(1)); 38 | parseData.setStatus(matcher.group(2)); 39 | return parseData; 40 | } 41 | 42 | public static List convertBase64Array(List base64Array) throws MalformedURLException { 43 | if (base64Array == null || base64Array.isEmpty()) { 44 | return Collections.emptyList(); 45 | } 46 | IDataUrlSerializer serializer = new DataUrlSerializer(); 47 | List dataUrlList = new ArrayList<>(); 48 | for (String base64 : base64Array) { 49 | DataUrl dataUrl = serializer.unserialize(base64); 50 | dataUrlList.add(dataUrl); 51 | } 52 | return dataUrlList; 53 | } 54 | 55 | public static TaskChangeParams convertChangeParams(String content) { 56 | List split = CharSequenceUtil.split(content, " "); 57 | if (split.size() != 2) { 58 | return null; 59 | } 60 | String action = split.get(1).toLowerCase(); 61 | TaskChangeParams changeParams = new TaskChangeParams(); 62 | changeParams.setId(split.get(0)); 63 | if (action.charAt(0) == 'u') { 64 | changeParams.setAction(TaskAction.UPSCALE); 65 | } else if (action.charAt(0) == 'v') { 66 | changeParams.setAction(TaskAction.VARIATION); 67 | } else if (action.equals("r")) { 68 | changeParams.setAction(TaskAction.REROLL); 69 | return changeParams; 70 | } else { 71 | return null; 72 | } 73 | try { 74 | int index = Integer.parseInt(action.substring(1, 2)); 75 | if (index < 1 || index > 4) { 76 | return null; 77 | } 78 | changeParams.setIndex(index); 79 | } catch (Exception e) { 80 | return null; 81 | } 82 | return changeParams; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/resources/banned-words.txt: -------------------------------------------------------------------------------- 1 | blood 2 | twerk 3 | making love 4 | voluptuous 5 | naughty 6 | wincest 7 | orgy 8 | no clothes 9 | au naturel 10 | no shirt 11 | decapitate 12 | bare 13 | nude 14 | barely dressed 15 | nude 16 | bra 17 | risque 18 | scantily clad 19 | cleavage 20 | stripped 21 | infested 22 | full frontal 23 | unclothed 24 | invisible clothes 25 | wearing nothing 26 | lingerie 27 | with no shirt 28 | naked 29 | without clothes on 30 | negligee 31 | zero clothes 32 | gruesome 33 | fascist 34 | nazi 35 | prophet mohammed 36 | slave 37 | coon 38 | honkey 39 | cocaine 40 | heroin 41 | meth 42 | crack 43 | kill 44 | belle delphine 45 | hitler 46 | jinping 47 | lolita 48 | president xi 49 | torture 50 | disturbing 51 | farts 52 | fart 53 | poop 54 | infected 55 | warts 56 | shit 57 | brown pudding 58 | bunghole 59 | vomit 60 | voluptuous 61 | seductive 62 | sperm 63 | sexy 64 | sadist 65 | sensored 66 | censored 67 | silenced 68 | deepfake 69 | inappropriate 70 | waifu 71 | succubus 72 | slaughter 73 | surgery 74 | reproduce 75 | crucified 76 | seductively 77 | explicit 78 | inappropriate 79 | large bust 80 | explicit 81 | wang 82 | inappropriate 83 | teratoma 84 | intimate 85 | see through 86 | tryphophobia 87 | bloodbath 88 | wound 89 | cronenberg 90 | khorne 91 | cannibal 92 | cannibalism 93 | visceral 94 | guts 95 | bloodshot 96 | gory 97 | killing 98 | crucifixion 99 | surgery 100 | vivisection 101 | massacre 102 | hemoglobin 103 | suicide 104 | arse 105 | labia 106 | ass 107 | mammaries 108 | badonkers 109 | bloody 110 | minge 111 | big ass 112 | mommy milker 113 | booba 114 | nipple 115 | oppai 116 | booty 117 | organs 118 | bosom 119 | ovaries 120 | flesh 121 | breasts 122 | penis 123 | busty 124 | phallus 125 | clunge 126 | sexy female 127 | crotch 128 | skimpy 129 | dick 130 | thick 131 | bruises 132 | girth 133 | titty 134 | honkers 135 | vagina 136 | hooters 137 | veiny 138 | knob 139 | ahegao 140 | pinup 141 | ballgag 142 | car crash 143 | playboy 144 | bimbo 145 | pleasure 146 | bodily fluids 147 | pleasures 148 | boudoir 149 | rule34 150 | brothel 151 | seducing 152 | dominatrix 153 | corpse 154 | seductive 155 | erotic 156 | seductive 157 | fuck 158 | sensual 159 | hardcore 160 | sexy 161 | hentai 162 | shag 163 | horny 164 | crucified 165 | shibari 166 | incest 167 | smut 168 | jav 169 | succubus 170 | jerk off king at pic 171 | thot 172 | kinbaku 173 | legs spread 174 | sensuality 175 | belly button 176 | porn 177 | patriotic 178 | bleed 179 | excrement 180 | petite 181 | seduction 182 | mccurry 183 | provocative 184 | sultry 185 | erected 186 | camisole 187 | tight white 188 | arrest 189 | see-through 190 | feces 191 | anus 192 | revealing clothing 193 | vein 194 | loli 195 | -edge 196 | boobs 197 | -backed 198 | tied up 199 | zedong 200 | bathing 201 | jail 202 | reticulum 203 | rear end 204 | sakimichan 205 | behind bars 206 | shirtless 207 | sakimichan 208 | seductive 209 | sexi 210 | sexualiz 211 | sexual -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/AccountInfoHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import cn.hutool.core.collection.CollectionUtil; 4 | import cn.hutool.core.util.StrUtil; 5 | import com.github.novicezk.midjourney.enums.MessageType; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 7 | import com.github.novicezk.midjourney.loadbalancer.DiscordLoadBalancer; 8 | import com.github.novicezk.midjourney.util.AsyncLockUtils; 9 | import lombok.RequiredArgsConstructor; 10 | import net.dv8tion.jda.api.utils.data.DataObject; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.Objects; 14 | 15 | 16 | //@Component 17 | @RequiredArgsConstructor 18 | public class AccountInfoHandler{ 19 | 20 | public static final String ACCOUNT_INFO_LOCK_KEY = "mp:account:info:lock:"; 21 | 22 | private final DiscordLoadBalancer loadBalancer; 23 | 24 | // @Override 25 | public void handle(DiscordInstance instance,MessageType messageType, DataObject message) { 26 | String channelId = message.getString("channel_id"); 27 | DiscordInstance discordInstance = loadBalancer.getDiscordInstance(channelId); 28 | if(messageType.equals(MessageType.UPDATE) && !Objects.isNull(discordInstance) && CollectionUtil.isNotEmpty(message.getArray("embeds"))){ 29 | parseInfoData(discordInstance,message.getArray("embeds").getObject(0).getString("description")); 30 | AsyncLockUtils.LockObject lock = AsyncLockUtils.getLock(ACCOUNT_INFO_LOCK_KEY+channelId); 31 | if(lock !=null){ 32 | lock.awake(); 33 | } 34 | } 35 | } 36 | 37 | private void parseInfoData(DiscordInstance discordInstance,String description){ 38 | String[] split = description.split("\n"); 39 | for (String s : split) { 40 | if (StrUtil.isBlank(s)){ 41 | continue; 42 | } 43 | String[] line = s.split(":"); 44 | String key = line[0]; 45 | String value = line[1]; 46 | value = StrUtil.trimToEmpty(value); 47 | if (key.contains("Fast Time Remaining")) { 48 | discordInstance.account().setFastTimeRemaining(value); 49 | } else if (key.contains("Lifetime Usage")) { 50 | discordInstance.account().setLifeTimeUsage(value); 51 | } else if (key.contains("Relaxed Usage")) { 52 | discordInstance.account().setRelaxedUsage(value); 53 | } else if (key.contains("User ID")) { 54 | discordInstance.account().setUserId(value); 55 | } else if (key.contains("Subscription")) { 56 | discordInstance.account().setSubscription(value+":"+line[2]); 57 | } else if (key.contains("fast")) { 58 | discordInstance.account().setFastQueuedJobs(value); 59 | } else if (key.contains("relax")) { 60 | discordInstance.account().setRelaxQueuedJobs(value); 61 | } else if (key.contains("Running Jobs")) { 62 | discordInstance.account().setRunningJobs(value); 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/controller/AccountController.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.controller; 2 | 3 | import com.github.novicezk.midjourney.domain.DiscordAccount; 4 | import com.github.novicezk.midjourney.dto.SettingsDTO; 5 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordLoadBalancer; 7 | import com.github.novicezk.midjourney.result.Message; 8 | import com.github.novicezk.midjourney.util.AsyncLockUtils; 9 | import com.github.novicezk.midjourney.util.SnowFlake; 10 | import com.github.novicezk.midjourney.wss.handle.AccountInfoHandler; 11 | import io.swagger.annotations.Api; 12 | import io.swagger.annotations.ApiOperation; 13 | import io.swagger.annotations.ApiParam; 14 | import lombok.RequiredArgsConstructor; 15 | import org.springframework.web.bind.annotation.*; 16 | 17 | import java.time.Duration; 18 | import java.util.List; 19 | import java.util.Objects; 20 | import java.util.concurrent.TimeoutException; 21 | 22 | import static com.github.novicezk.midjourney.ReturnCode.VALIDATION_ERROR; 23 | 24 | @Api(tags = "账号查询") 25 | @RestController 26 | @RequestMapping("/account") 27 | @RequiredArgsConstructor 28 | public class AccountController { 29 | private final DiscordLoadBalancer loadBalancer; 30 | 31 | @ApiOperation(value = "指定ID获取账号") 32 | @GetMapping("/{id}/fetch") 33 | public DiscordAccount fetch(@ApiParam(value = "账号ID") @PathVariable String id) { 34 | DiscordInstance instance = this.loadBalancer.getDiscordInstance(id); 35 | return instance == null ? null : instance.account(); 36 | } 37 | 38 | @ApiOperation(value = "查询所有账号") 39 | @GetMapping("/list") 40 | public List list() { 41 | return this.loadBalancer.getAllInstances().stream().map(DiscordInstance::account).toList(); 42 | } 43 | 44 | 45 | @ApiOperation(value = "info信息") 46 | @GetMapping("{id}/info") 47 | public Message info(@ApiParam(value = "channel-id") @PathVariable String id) { 48 | DiscordInstance instance = this.loadBalancer.getDiscordInstance(id); 49 | if (Objects.isNull(instance)) { 50 | return Message.failure("channel-id 不存在"); 51 | } 52 | try { 53 | instance.info(SnowFlake.INSTANCE.nextId()); 54 | AsyncLockUtils.waitForLock(AccountInfoHandler.ACCOUNT_INFO_LOCK_KEY + instance.getInstanceId(), Duration.ofMinutes(1L)); 55 | } catch (TimeoutException e) { 56 | return Message.failure("获取info 信息失败: " + e.getMessage()); 57 | } 58 | return Message.success(instance.account()); 59 | } 60 | 61 | @ApiOperation(value = "settings") 62 | @PostMapping("{id}/settings") 63 | public Message settings(@ApiParam(value = "账号ID") @PathVariable String id, 64 | @RequestBody SettingsDTO value){ 65 | DiscordInstance instance = this.loadBalancer.getDiscordInstance(id); 66 | if(Objects.isNull(instance)){ 67 | return Message.failure("channel-id 不存在"); 68 | } 69 | 70 | if(Objects.isNull(value) || Objects.isNull(value.getAttr())){ 71 | return Message.of(VALIDATION_ERROR,"value参数错误"); 72 | } 73 | 74 | Message response = instance.settings(SnowFlake.INSTANCE.nextId(),value.getAttr().getValue()); 75 | return response; 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/controller/TaskController.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.controller; 2 | 3 | import cn.hutool.core.comparator.CompareUtil; 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.dto.TaskConditionDTO; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordLoadBalancer; 7 | import com.github.novicezk.midjourney.service.TaskStoreService; 8 | import com.github.novicezk.midjourney.support.Task; 9 | import io.swagger.annotations.Api; 10 | import io.swagger.annotations.ApiOperation; 11 | import io.swagger.annotations.ApiParam; 12 | import lombok.RequiredArgsConstructor; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestBody; 17 | import org.springframework.web.bind.annotation.RequestMapping; 18 | import org.springframework.web.bind.annotation.RestController; 19 | 20 | import java.util.ArrayList; 21 | import java.util.Collections; 22 | import java.util.Comparator; 23 | import java.util.HashSet; 24 | import java.util.List; 25 | import java.util.Optional; 26 | import java.util.Set; 27 | 28 | @Api(tags = "任务查询") 29 | @RestController 30 | @RequestMapping("/task") 31 | @RequiredArgsConstructor 32 | public class TaskController { 33 | private final TaskStoreService taskStoreService; 34 | private final DiscordLoadBalancer discordLoadBalancer; 35 | 36 | @ApiOperation(value = "指定ID获取任务") 37 | @GetMapping("/{id}/fetch") 38 | public Task fetch(@ApiParam(value = "任务ID") @PathVariable String id) { 39 | Optional queueTaskOptional = this.discordLoadBalancer.getQueueTasks().stream() 40 | .filter(t -> CharSequenceUtil.equals(t.getId(), id)).findFirst(); 41 | return queueTaskOptional.orElseGet(() -> this.taskStoreService.get(id)); 42 | } 43 | 44 | @ApiOperation(value = "查询任务队列") 45 | @GetMapping("/queue") 46 | public List queue() { 47 | return this.discordLoadBalancer.getQueueTasks().stream() 48 | .sorted(Comparator.comparing(Task::getSubmitTime)) 49 | .toList(); 50 | } 51 | 52 | @ApiOperation(value = "查询所有任务") 53 | @GetMapping("/list") 54 | public List list() { 55 | return this.taskStoreService.list().stream() 56 | .sorted((t1, t2) -> CompareUtil.compare(t2.getSubmitTime(), t1.getSubmitTime())) 57 | .toList(); 58 | } 59 | 60 | @ApiOperation(value = "根据ID列表查询任务") 61 | @PostMapping("/list-by-condition") 62 | public List listByIds(@RequestBody TaskConditionDTO conditionDTO) { 63 | if (conditionDTO.getIds() == null) { 64 | return Collections.emptyList(); 65 | } 66 | List result = new ArrayList<>(); 67 | Set notInQueueIds = new HashSet<>(conditionDTO.getIds()); 68 | this.discordLoadBalancer.getQueueTasks().forEach(t -> { 69 | if (conditionDTO.getIds().contains(t.getId())) { 70 | result.add(t); 71 | notInQueueIds.remove(t.getId()); 72 | } 73 | }); 74 | notInQueueIds.forEach(id -> { 75 | Task task = this.taskStoreService.get(id); 76 | if (task != null) { 77 | result.add(task); 78 | } 79 | }); 80 | return result; 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/DiscordAccountInitializer.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | 4 | import cn.hutool.core.bean.BeanUtil; 5 | import cn.hutool.core.exceptions.ValidateException; 6 | import cn.hutool.core.text.CharSequenceUtil; 7 | import com.github.novicezk.midjourney.ProxyProperties; 8 | import com.github.novicezk.midjourney.ReturnCode; 9 | import com.github.novicezk.midjourney.domain.DiscordAccount; 10 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 11 | import com.github.novicezk.midjourney.loadbalancer.DiscordLoadBalancer; 12 | import com.github.novicezk.midjourney.util.AsyncLockUtils; 13 | import lombok.RequiredArgsConstructor; 14 | import lombok.extern.slf4j.Slf4j; 15 | import org.apache.logging.log4j.util.Strings; 16 | import org.springframework.boot.ApplicationArguments; 17 | import org.springframework.boot.ApplicationRunner; 18 | import org.springframework.stereotype.Component; 19 | 20 | import java.time.Duration; 21 | import java.util.List; 22 | import java.util.Set; 23 | import java.util.stream.Collectors; 24 | 25 | @Slf4j 26 | @Component 27 | @RequiredArgsConstructor 28 | public class DiscordAccountInitializer implements ApplicationRunner { 29 | private final DiscordLoadBalancer discordLoadBalancer; 30 | private final DiscordAccountHelper discordAccountHelper; 31 | private final ProxyProperties properties; 32 | 33 | @Override 34 | public void run(ApplicationArguments args) throws Exception { 35 | ProxyProperties.ProxyConfig proxy = this.properties.getProxy(); 36 | if (Strings.isNotBlank(proxy.getHost())) { 37 | System.setProperty("http.proxyHost", proxy.getHost()); 38 | System.setProperty("http.proxyPort", String.valueOf(proxy.getPort())); 39 | System.setProperty("https.proxyHost", proxy.getHost()); 40 | System.setProperty("https.proxyPort", String.valueOf(proxy.getPort())); 41 | } 42 | 43 | List configAccounts = this.properties.getAccounts(); 44 | if (CharSequenceUtil.isNotBlank(this.properties.getDiscord().getChannelId())) { 45 | configAccounts.add(this.properties.getDiscord()); 46 | } 47 | List instances = this.discordLoadBalancer.getAllInstances(); 48 | for (ProxyProperties.DiscordAccountConfig configAccount : configAccounts) { 49 | DiscordAccount account = new DiscordAccount(); 50 | BeanUtil.copyProperties(configAccount, account); 51 | account.setId(configAccount.getChannelId()); 52 | try { 53 | DiscordInstance instance = this.discordAccountHelper.createDiscordInstance(account); 54 | if (!account.isEnable()) { 55 | continue; 56 | } 57 | instance.startWss(); 58 | AsyncLockUtils.LockObject lock = AsyncLockUtils.waitForLock("wss:" + account.getChannelId(), Duration.ofSeconds(10)); 59 | if (ReturnCode.SUCCESS != lock.getProperty("code", Integer.class, 0)) { 60 | throw new ValidateException(lock.getProperty("description", String.class)); 61 | } 62 | instances.add(instance); 63 | } catch (Exception e) { 64 | log.error("Account({}) init fail, disabled: {}", account.getDisplay(), e.getMessage()); 65 | account.setEnable(false); 66 | } 67 | } 68 | Set enableInstanceIds = instances.stream().filter(DiscordInstance::isAlive).map(DiscordInstance::getInstanceId).collect(Collectors.toSet()); 69 | log.info("当前可用账号数 [{}] - {}", enableInstanceIds.size(), String.join(", ", enableInstanceIds)); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/translate/BaiduTranslateServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service.translate; 2 | 3 | 4 | import cn.hutool.core.exceptions.ValidateException; 5 | import cn.hutool.core.text.CharSequenceUtil; 6 | import cn.hutool.core.util.RandomUtil; 7 | import cn.hutool.crypto.digest.MD5; 8 | import com.github.novicezk.midjourney.ProxyProperties; 9 | import com.github.novicezk.midjourney.service.TranslateService; 10 | import lombok.extern.slf4j.Slf4j; 11 | import org.json.JSONArray; 12 | import org.json.JSONObject; 13 | import org.springframework.beans.factory.support.BeanDefinitionValidationException; 14 | import org.springframework.http.HttpEntity; 15 | import org.springframework.http.HttpHeaders; 16 | import org.springframework.http.HttpMethod; 17 | import org.springframework.http.HttpStatus; 18 | import org.springframework.http.MediaType; 19 | import org.springframework.http.ResponseEntity; 20 | import org.springframework.util.LinkedMultiValueMap; 21 | import org.springframework.util.MultiValueMap; 22 | import org.springframework.web.client.RestTemplate; 23 | 24 | import java.util.ArrayList; 25 | import java.util.List; 26 | 27 | @Slf4j 28 | public class BaiduTranslateServiceImpl implements TranslateService { 29 | private static final String TRANSLATE_API = "https://fanyi-api.baidu.com/api/trans/vip/translate"; 30 | 31 | private final String appid; 32 | private final String appSecret; 33 | 34 | public BaiduTranslateServiceImpl(ProxyProperties.BaiduTranslateConfig translateConfig) { 35 | this.appid = translateConfig.getAppid(); 36 | this.appSecret = translateConfig.getAppSecret(); 37 | if (!CharSequenceUtil.isAllNotBlank(this.appid, this.appSecret)) { 38 | throw new BeanDefinitionValidationException("mj.baidu-translate.appid或mj.baidu-translate.app-secret未配置"); 39 | } 40 | } 41 | 42 | @Override 43 | public String translateToEnglish(String prompt) { 44 | if (!containsChinese(prompt)) { 45 | return prompt; 46 | } 47 | String salt = RandomUtil.randomNumbers(5); 48 | String sign = MD5.create().digestHex(this.appid + prompt + salt + this.appSecret); 49 | HttpHeaders headers = new HttpHeaders(); 50 | headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 51 | MultiValueMap body = new LinkedMultiValueMap<>(); 52 | body.add("from", "zh"); 53 | body.add("to", "en"); 54 | body.add("appid", this.appid); 55 | body.add("salt", salt); 56 | body.add("q", prompt); 57 | body.add("sign", sign); 58 | HttpEntity> requestEntity = new HttpEntity<>(body, headers); 59 | try { 60 | ResponseEntity responseEntity = new RestTemplate().exchange(TRANSLATE_API, HttpMethod.POST, requestEntity, String.class); 61 | if (responseEntity.getStatusCode() != HttpStatus.OK || CharSequenceUtil.isBlank(responseEntity.getBody())) { 62 | throw new ValidateException(responseEntity.getStatusCodeValue() + " - " + responseEntity.getBody()); 63 | } 64 | JSONObject result = new JSONObject(responseEntity.getBody()); 65 | if (result.has("error_code")) { 66 | throw new ValidateException(result.getString("error_code") + " - " + result.getString("error_msg")); 67 | } 68 | List strings = new ArrayList<>(); 69 | JSONArray transResult = result.getJSONArray("trans_result"); 70 | for (int i = 0; i < transResult.length(); i++) { 71 | strings.add(transResult.getJSONObject(i).getString("dst")); 72 | } 73 | return CharSequenceUtil.join("\n", strings); 74 | } catch (Exception e) { 75 | log.warn("调用百度翻译失败: {}", e.getMessage()); 76 | } 77 | return prompt; 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/support/DiscordHelper.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.support; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.ProxyProperties; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | @RequiredArgsConstructor 10 | public class DiscordHelper { 11 | private final ProxyProperties properties; 12 | /** 13 | * DISCORD_SERVER_URL. 14 | */ 15 | public static final String DISCORD_SERVER_URL = "https://discord.com"; 16 | /** 17 | * DISCORD_CDN_URL. 18 | */ 19 | public static final String DISCORD_CDN_URL = "https://cdn.discordapp.com"; 20 | /** 21 | * DISCORD_WSS_URL. 22 | */ 23 | public static final String DISCORD_WSS_URL = "wss://gateway.discord.gg"; 24 | /** 25 | * DISCORD_UPLOAD_URL. 26 | */ 27 | public static final String DISCORD_UPLOAD_URL = "https://discord-attachments-uploads-prd.storage.googleapis.com"; 28 | 29 | public String getServer() { 30 | if (CharSequenceUtil.isBlank(this.properties.getNgDiscord().getServer())) { 31 | return DISCORD_SERVER_URL; 32 | } 33 | String serverUrl = this.properties.getNgDiscord().getServer(); 34 | if (serverUrl.endsWith("/")) { 35 | serverUrl = serverUrl.substring(0, serverUrl.length() - 1); 36 | } 37 | return serverUrl; 38 | } 39 | 40 | public String getCdn() { 41 | if (CharSequenceUtil.isBlank(this.properties.getNgDiscord().getCdn())) { 42 | return DISCORD_CDN_URL; 43 | } 44 | String cdnUrl = this.properties.getNgDiscord().getCdn(); 45 | if (cdnUrl.endsWith("/")) { 46 | cdnUrl = cdnUrl.substring(0, cdnUrl.length() - 1); 47 | } 48 | return cdnUrl; 49 | } 50 | 51 | public String getWss() { 52 | if (CharSequenceUtil.isBlank(this.properties.getNgDiscord().getWss())) { 53 | return DISCORD_WSS_URL; 54 | } 55 | String wssUrl = this.properties.getNgDiscord().getWss(); 56 | if (wssUrl.endsWith("/")) { 57 | wssUrl = wssUrl.substring(0, wssUrl.length() - 1); 58 | } 59 | return wssUrl; 60 | } 61 | 62 | public String getResumeWss() { 63 | if (CharSequenceUtil.isBlank(this.properties.getNgDiscord().getResumeWss())) { 64 | return null; 65 | } 66 | String resumeWss = this.properties.getNgDiscord().getResumeWss(); 67 | if (resumeWss.endsWith("/")) { 68 | resumeWss = resumeWss.substring(0, resumeWss.length() - 1); 69 | } 70 | return resumeWss; 71 | } 72 | 73 | public String getDiscordUploadUrl(String uploadUrl) { 74 | if (CharSequenceUtil.isBlank(this.properties.getNgDiscord().getUploadServer()) || CharSequenceUtil.isBlank(uploadUrl)) { 75 | return uploadUrl; 76 | } 77 | String uploadServer = this.properties.getNgDiscord().getUploadServer(); 78 | if (uploadServer.endsWith("/")) { 79 | uploadServer = uploadServer.substring(0, uploadServer.length() - 1); 80 | } 81 | return uploadUrl.replaceFirst(DISCORD_UPLOAD_URL, uploadServer); 82 | } 83 | 84 | public String getMessageHash(String imageUrl) { 85 | if (CharSequenceUtil.isBlank(imageUrl)) { 86 | return null; 87 | } 88 | if (CharSequenceUtil.endWith(imageUrl, "_grid_0.webp")) { 89 | int hashStartIndex = imageUrl.lastIndexOf("/"); 90 | if (hashStartIndex < 0) { 91 | return null; 92 | } 93 | return CharSequenceUtil.sub(imageUrl, hashStartIndex + 1, imageUrl.length() - "_grid_0.webp".length()); 94 | } 95 | int hashStartIndex = imageUrl.lastIndexOf("_"); 96 | if (hashStartIndex < 0) { 97 | return null; 98 | } 99 | return CharSequenceUtil.subBefore(imageUrl.substring(hashStartIndex + 1), ".", true); 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

midjourney-proxy

4 | 5 | [English](./README.md) | 中文 6 | 7 | 代理 MidJourney 的discord频道,实现api形式调用AI绘图 8 | 9 | [![GitHub release](https://img.shields.io/static/v1?label=release&message=v2.6.2&color=blue)](https://www.github.com/novicezk/midjourney-proxy) 10 | [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) 11 | 12 |
13 | 14 | ## 主要功能 15 | - [x] 支持 Imagine 指令和相关动作 16 | - [x] Imagine 时支持添加图片base64,作为垫图 17 | - [x] 支持 Blend(图片混合)、Describe(图生文) 指令 18 | - [x] 支持任务实时进度 19 | - [x] 支持中文prompt翻译,需配置百度翻译或gpt 20 | - [x] prompt 敏感词预检测,支持覆盖调整 21 | - [x] user-token 连接 wss,可以获取错误信息和完整功能 22 | - [x] 支持多账号配置,每个账号可设置对应的任务队列 23 | 24 | **🚀 更多功能请查看 [midjourney-proxy-plus](https://github.com/litter-coder/midjourney-proxy-plus)** 25 | > - [x] 支持开源版的所有功能 26 | > - [x] 支持 Shorten(prompt分析) 指令 27 | > - [x] 支持焦点移动: Pan ⬅️ ➡️ ⬆️ ⬇️ 28 | > - [x] 支持图片变焦: Zoom 🔍 29 | > - [x] 支持局部重绘: Vary (Region) 🖌 30 | > - [x] 支持几乎所有的关联按钮动作和🎛️ Remix模式 31 | > - [x] 支持获取图片的seed值 32 | > - [x] 账号池持久化,动态维护 33 | > - [x] 支持获取账号/info、/settings信息 34 | > - [x] 账号settings设置 35 | > - [x] 支持niji bot机器人 36 | > - [x] 支持InsightFace人脸替换机器人 37 | > - [x] 内嵌管理后台页面 38 | 39 | ## 使用前提 40 | 1. 注册并订阅 MidJourney,创建`自己的服务器和频道`,参考 https://docs.midjourney.com/docs/quick-start 41 | 2. 获取用户Token、服务器ID、频道ID:[获取方式](./docs/discord-params.md) 42 | 43 | ## 快速启动 44 | 1. `Railway`: 基于Railway平台,不需要自己的服务器: [部署方式](./docs/railway-start.md);若Railway不能使用,可使用Zeabur启动 45 | 2. `Zeabur`: 基于Zeabur平台,不需要自己的服务器: [部署方式](./docs/zeabur-start.md) 46 | 3. `Docker`: 在服务器或本地使用Docker启动: [部署方式](./docs/docker-start.md) 47 | 48 | ## 本地开发 49 | - 依赖java17和maven 50 | - 更改配置项: 修改src/main/application.yml 51 | - 项目运行: 启动ProxyApplication的main函数 52 | - 更改代码后,构建镜像: Dockerfile取消VOLUME的注释,执行 `docker build . -t midjourney-proxy` 53 | 54 | ## 配置项 55 | - mj.accounts: 参考 [账号池配置](./docs/config.md#%E8%B4%A6%E5%8F%B7%E6%B1%A0%E9%85%8D%E7%BD%AE%E5%8F%82%E8%80%83) 56 | - mj.task-store.type: 任务存储方式,默认in_memory(内存\重启后丢失),可选redis 57 | - mj.task-store.timeout: 任务存储过期时间,过期后删除,默认30天 58 | - mj.api-secret: 接口密钥,为空不启用鉴权;调用接口时需要加请求头 mj-api-secret 59 | - mj.translate-way: 中文prompt翻译成英文的方式,可选null(默认)、baidu、gpt 60 | - 更多配置查看 [配置项](./docs/config.md) 61 | 62 | ## 相关文档 63 | 1. [API接口说明](./docs/api.md) 64 | 2. [版本更新记录](https://github.com/novicezk/midjourney-proxy/wiki/%E6%9B%B4%E6%96%B0%E8%AE%B0%E5%BD%95) 65 | 66 | ## 注意事项 67 | 1. 作图频繁等行为,可能会触发midjourney账号警告,请谨慎使用 68 | 2. 常见问题及解决办法见 [Wiki / FAQ](https://github.com/novicezk/midjourney-proxy/wiki/FAQ) 69 | 3. 感兴趣的朋友也欢迎加入交流群讨论一下,扫码进群名额已满,加管理员微信邀请进群,备注: mj加群 70 | 71 | 微信二维码 72 | 73 | ## 应用项目 74 | 依赖此项目且开源的,欢迎联系作者,加到此处展示 75 | - [wechat-midjourney](https://github.com/novicezk/wechat-midjourney) : 代理微信客户端,接入MidJourney,仅示例应用场景,不再更新 76 | - [chatgpt-web-midjourney-proxy](https://github.com/Dooy/chatgpt-web-midjourney-proxy) : chatgpt web, midjourney, gpts,tts, whisper 一套ui全搞定 77 | - [chatnio](https://github.com/Deeptrain-Community/chatnio) : 下一代 AI 一站式 B/C 端解决方案, 集成精美 UI 和强大功能的聚合模型平台 78 | - [new-api](https://github.com/Calcium-Ion/new-api) : 接入Midjourney Proxy的接口管理 & 分发系统 79 | - [stable-diffusion-mobileui](https://github.com/yuanyuekeji/stable-diffusion-mobileui) : SDUI,基于本接口和SD,可一键打包生成H5和小程序 80 | - [MidJourney-Web](https://github.com/ConnectAI-E/MidJourney-Web) : 🍎 Supercharged Experience For MidJourney On Web UI 81 | 82 | ## 开放API 83 | 提供非官方的MJ/SD开放API,添加管理员微信咨询,备注: api 84 | 85 | ## 其它 86 | 如果觉得这个项目对您有所帮助,请帮忙点个star 87 | 88 | [![Star History Chart](https://api.star-history.com/svg?repos=novicezk/midjourney-proxy&type=Date)](https://star-history.com/#novicezk/midjourney-proxy&Date) 89 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/translate/GPTTranslateServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service.translate; 2 | 3 | 4 | import cn.hutool.core.text.CharSequenceUtil; 5 | import com.github.novicezk.midjourney.ProxyProperties; 6 | import com.github.novicezk.midjourney.service.TranslateService; 7 | import com.unfbx.chatgpt.OpenAiClient; 8 | import com.unfbx.chatgpt.entity.chat.ChatChoice; 9 | import com.unfbx.chatgpt.entity.chat.ChatCompletion; 10 | import com.unfbx.chatgpt.entity.chat.ChatCompletionResponse; 11 | import com.unfbx.chatgpt.entity.chat.Message; 12 | import com.unfbx.chatgpt.function.KeyRandomStrategy; 13 | import com.unfbx.chatgpt.interceptor.OpenAILogger; 14 | import com.unfbx.chatgpt.interceptor.OpenAiResponseInterceptor; 15 | import lombok.extern.slf4j.Slf4j; 16 | import okhttp3.OkHttpClient; 17 | import okhttp3.logging.HttpLoggingInterceptor; 18 | import org.springframework.beans.factory.support.BeanDefinitionValidationException; 19 | 20 | import java.net.InetSocketAddress; 21 | import java.net.Proxy; 22 | import java.util.Arrays; 23 | import java.util.Collections; 24 | import java.util.List; 25 | import java.util.concurrent.TimeUnit; 26 | 27 | @Slf4j 28 | public class GPTTranslateServiceImpl implements TranslateService { 29 | private final OpenAiClient openAiClient; 30 | private final ProxyProperties.OpenaiConfig openaiConfig; 31 | 32 | public GPTTranslateServiceImpl(ProxyProperties properties) { 33 | this.openaiConfig = properties.getOpenai(); 34 | if (CharSequenceUtil.isBlank(this.openaiConfig.getGptApiKey())) { 35 | throw new BeanDefinitionValidationException("mj.openai.gpt-api-key未配置"); 36 | } 37 | HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(new OpenAILogger()); 38 | httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); 39 | OkHttpClient.Builder okHttpBuilder = new OkHttpClient.Builder() 40 | .addInterceptor(httpLoggingInterceptor) 41 | .addInterceptor(new OpenAiResponseInterceptor()) 42 | .connectTimeout(10, TimeUnit.SECONDS) 43 | .writeTimeout(30, TimeUnit.SECONDS) 44 | .readTimeout(30, TimeUnit.SECONDS); 45 | if (CharSequenceUtil.isNotBlank(properties.getProxy().getHost())) { 46 | Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(properties.getProxy().getHost(), properties.getProxy().getPort())); 47 | okHttpBuilder.proxy(proxy); 48 | } 49 | OpenAiClient.Builder apiBuilder = OpenAiClient.builder() 50 | .apiKey(Collections.singletonList(this.openaiConfig.getGptApiKey())) 51 | .keyStrategy(new KeyRandomStrategy()) 52 | .okHttpClient(okHttpBuilder.build()); 53 | if (CharSequenceUtil.isNotBlank(this.openaiConfig.getGptApiUrl())) { 54 | apiBuilder.apiHost(this.openaiConfig.getGptApiUrl()); 55 | } 56 | this.openAiClient = apiBuilder.build(); 57 | } 58 | 59 | @Override 60 | public String translateToEnglish(String prompt) { 61 | if (!containsChinese(prompt)) { 62 | return prompt; 63 | } 64 | Message m1 = Message.builder().role(Message.Role.SYSTEM).content("你是一个翻译:我输入的是中文,你把它翻译成英文,直接输出翻译后的结果即可,不需要输出其他内容。").build(); 65 | Message m2 = Message.builder().role(Message.Role.USER).content(prompt).build(); 66 | ChatCompletion chatCompletion = ChatCompletion.builder() 67 | .messages(Arrays.asList(m1, m2)) 68 | .model(this.openaiConfig.getModel()) 69 | .temperature(this.openaiConfig.getTemperature()) 70 | .maxTokens(this.openaiConfig.getMaxTokens()) 71 | .build(); 72 | ChatCompletionResponse chatCompletionResponse = this.openAiClient.chatCompletion(chatCompletion); 73 | try { 74 | List choices = chatCompletionResponse.getChoices(); 75 | if (!choices.isEmpty()) { 76 | return choices.get(0).getMessage().getContent(); 77 | } 78 | } catch (Exception e) { 79 | log.warn("调用chat-gpt接口翻译中文失败: {}", e.getMessage()); 80 | } 81 | return prompt; 82 | } 83 | } -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API接口说明 2 | 3 | `http://ip:port/mj` 已有api文档,此处仅作补充 4 | 5 | ## 1. 数据结构 6 | 7 | ### 任务 8 | | 字段 | 类型 | 示例 | 描述 | 9 | |:-----:|:----:|:----|:----| 10 | | id | string | 1689231405853400 | 任务ID | 11 | | action | string | IMAGINE | 任务类型: IMAGINE(绘图)、UPSCALE(选中放大)、VARIATION(选中变换)、REROLL(重新执行)、DESCRIBE(图生文)、BLEAND(图片混合) | 12 | | status | string | SUCCESS | 任务状态: NOT_START(未启动)、SUBMITTED(已提交处理)、IN_PROGRESS(执行中)、FAILURE(失败)、SUCCESS(成功) | 13 | | prompt | string | 猫猫 | 提示词 | 14 | | promptEn | string | Cat | 英文提示词 | 15 | | description | string | /imagine 猫猫 | 任务描述 | 16 | | submitTime | number | 1689231405854 | 提交时间 | 17 | | startTime | number | 1689231442755 | 开始执行时间 | 18 | | finishTime | number | 1689231544312 | 结束时间 | 19 | | progress | string | 100% | 任务进度 | 20 | | imageUrl | string | https://cdn.discordapp.com/attachments/xxx/xxx/xxxx.png | 生成图片的url, 成功或执行中时有值,可能为png或webp | 21 | | failReason | string | [Invalid parameter] Invalid value | 失败原因, 失败时有值 | 22 | | properties | object | {"finalPrompt": "Cat"} | 任务的扩展属性,系统内部使用 | 23 | 24 | 25 | ## 2. 任务提交返回 26 | - code=1: 提交成功,result为任务ID 27 | ```json 28 | { 29 | "code": 1, 30 | "description": "成功", 31 | "result": "8498455807619990", 32 | "properties": { 33 | "discordInstanceId": "1118138338562560102" 34 | } 35 | } 36 | ``` 37 | - code=21: 任务已存在,U时可能发生 38 | ```json 39 | { 40 | "code": 21, 41 | "description": "任务已存在", 42 | "result": "0741798445574458", 43 | "properties": { 44 | "status": "SUCCESS", 45 | "imageUrl": "https://xxxx" 46 | } 47 | } 48 | ``` 49 | - code=22: 提交成功,进入队列等待 50 | ```json 51 | { 52 | "code": 22, 53 | "description": "排队中,前面还有1个任务", 54 | "result": "0741798445574458", 55 | "properties": { 56 | "numberOfQueues": 1, 57 | "discordInstanceId": "1118138338562560102" 58 | } 59 | } 60 | ``` 61 | - code=23: 队列已满,请稍后尝试 62 | ```json 63 | { 64 | "code": 23, 65 | "description": "队列已满,请稍后尝试", 66 | "result": "14001929738841620", 67 | "properties": { 68 | "discordInstanceId": "1118138338562560102" 69 | } 70 | } 71 | ``` 72 | - code=24: prompt包含敏感词 73 | ```json 74 | { 75 | "code": 24, 76 | "description": "可能包含敏感词", 77 | "properties": { 78 | "promptEn": "nude body", 79 | "bannedWord": "nude" 80 | } 81 | } 82 | ``` 83 | - other: 提交错误,description为错误描述 84 | 85 | ## 3. `/mj/submit/simple-change` 绘图变化-simple 86 | 接口作用同 `/mj/submit/change`(绘图变化),传参方式不同,该接口接收content,格式为`ID 操作`,例如:1320098173412546 U2 87 | 88 | - 放大 U1~U4 89 | - 变换 V1~V4 90 | - 重新执行 R 91 | 92 | ## 4. `/mj/submit/describe` 图生文 93 | ```json 94 | { 95 | // 图片的base64字符串 96 | "base64": "data:image/png;base64,xxx" 97 | } 98 | ``` 99 | 100 | 后续任务完成后,properties中finalPrompt即为图片生成的prompt 101 | ```json 102 | { 103 | "id":"14001929738841620", 104 | "action":"DESCRIBE", 105 | "status": "SUCCESS", 106 | "description":"/describe 14001929738841620.png", 107 | "imageUrl":"https://cdn.discordapp.com/attachments/xxx/xxx/14001929738841620.png", 108 | "properties": { 109 | "finalPrompt": "1️⃣ Cat --ar 5:4\n\n2️⃣ Cat2 --ar 5:4\n\n3️⃣ Cat3 --ar 5:4\n\n4️⃣ Cat4 --ar 5:4" 110 | } 111 | // ... 112 | } 113 | ``` 114 | 115 | ## 5. 任务变更回调 116 | 任务状态变化或进度改变时,会调用业务系统的接口 117 | - 接口地址为配置的 mj.notify-hook,任务提交时支持传`notifyHook`以改变此任务的回调地址 118 | - 两者都为空时,不触发回调 119 | 120 | POST application/json 121 | ```json 122 | { 123 | "id": "14001929738841620", 124 | "action": "IMAGINE", 125 | "status": "SUCCESS", 126 | "prompt": "猫猫", 127 | "promptEn": "Cat", 128 | "description": "/imagine 猫猫", 129 | "submitTime": 1689231405854, 130 | "startTime": 1689231442755, 131 | "finishTime": 1689231544312, 132 | "progress": "100%", 133 | "imageUrl": "https://cdn.discordapp.com/attachments/xxx/xxx/xxxx.png", 134 | "failReason": null, 135 | "properties": { 136 | "finalPrompt": "Cat" 137 | } 138 | } 139 | ``` 140 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/ErrorMessageHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.Constants; 5 | import com.github.novicezk.midjourney.enums.MessageType; 6 | import com.github.novicezk.midjourney.enums.TaskStatus; 7 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 8 | import com.github.novicezk.midjourney.support.Task; 9 | import com.github.novicezk.midjourney.support.TaskCondition; 10 | import lombok.extern.slf4j.Slf4j; 11 | import net.dv8tion.jda.api.utils.data.DataArray; 12 | import net.dv8tion.jda.api.utils.data.DataObject; 13 | import org.springframework.stereotype.Component; 14 | 15 | import java.util.Optional; 16 | import java.util.Set; 17 | 18 | @Slf4j 19 | @Component 20 | public class ErrorMessageHandler extends MessageHandler { 21 | 22 | @Override 23 | public int order() { 24 | return 2; 25 | } 26 | 27 | @Override 28 | public void handle(DiscordInstance instance, MessageType messageType, DataObject message) { 29 | String content = getMessageContent(message); 30 | if (CharSequenceUtil.startWith(content, "Failed")) { 31 | // mj官方异常 32 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 33 | String nonce = getMessageNonce(message); 34 | Task task = instance.getRunningTaskByNonce(nonce); 35 | if (task != null) { 36 | task.fail(content); 37 | task.awake(); 38 | } 39 | return; 40 | } 41 | Optional embedsOptional = message.optArray("embeds"); 42 | if (embedsOptional.isEmpty() || embedsOptional.get().isEmpty()) { 43 | return; 44 | } 45 | DataObject embed = embedsOptional.get().getObject(0); 46 | String title = embed.getString("title", null); 47 | if (CharSequenceUtil.isBlank(title)) { 48 | return; 49 | } 50 | String description = embed.getString("description", null); 51 | String footerText = ""; 52 | Optional footer = embed.optObject("footer"); 53 | if (footer.isPresent()) { 54 | footerText = footer.get().getString("text", ""); 55 | } 56 | int color = embed.getInt("color", 0); 57 | if (color == 16239475) { 58 | log.warn("{} - MJ警告信息: {}\n{}\nfooter: {}", instance.getInstanceId(), title, description, footerText); 59 | } else if (color == 16711680) { 60 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 61 | log.error("{} - MJ异常信息: {}\n{}\nfooter: {}", instance.getInstanceId(), title, description, footerText); 62 | String nonce = getMessageNonce(message); 63 | Task task; 64 | if (CharSequenceUtil.isNotBlank(nonce)) { 65 | task = instance.getRunningTaskByNonce(nonce); 66 | } else { 67 | task = findTaskWhenError(instance, messageType, message); 68 | } 69 | if (task != null) { 70 | task.fail("[" + title + "] " + description); 71 | task.awake(); 72 | } 73 | } else { 74 | if ("link".equals(embed.getString("type", "")) || CharSequenceUtil.isBlank(description)) { 75 | return; 76 | } 77 | // 兼容 Invalid link! \ Could not complete 等错误 78 | Task task = findTaskWhenError(instance, messageType, message); 79 | if (task != null) { 80 | message.put(Constants.MJ_MESSAGE_HANDLED, true); 81 | log.warn("{} - MJ可能的异常信息: {}\n{}\nfooter: {}", instance.getInstanceId(), title, description, footerText); 82 | task.fail("[" + title + "] " + description); 83 | task.awake(); 84 | } 85 | } 86 | } 87 | 88 | private Task findTaskWhenError(DiscordInstance instance, MessageType messageType, DataObject message) { 89 | String progressMessageId = null; 90 | if (MessageType.CREATE.equals(messageType)) { 91 | progressMessageId = getReferenceMessageId(message); 92 | } else if (MessageType.UPDATE.equals(messageType)) { 93 | progressMessageId = message.getString("id"); 94 | } 95 | if (CharSequenceUtil.isBlank(progressMessageId)) { 96 | return null; 97 | } 98 | TaskCondition condition = new TaskCondition().setStatusSet(Set.of(TaskStatus.IN_PROGRESS, TaskStatus.SUBMITTED)) 99 | .setProgressMessageId(progressMessageId); 100 | return instance.findRunningTask(condition).findFirst().orElse(null); 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/handle/MessageHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.handle; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import com.github.novicezk.midjourney.Constants; 5 | import com.github.novicezk.midjourney.enums.MessageType; 6 | import com.github.novicezk.midjourney.loadbalancer.DiscordInstance; 7 | import com.github.novicezk.midjourney.loadbalancer.DiscordLoadBalancer; 8 | import com.github.novicezk.midjourney.support.DiscordHelper; 9 | import com.github.novicezk.midjourney.support.Task; 10 | import com.github.novicezk.midjourney.support.TaskCondition; 11 | import net.dv8tion.jda.api.utils.data.DataArray; 12 | import net.dv8tion.jda.api.utils.data.DataObject; 13 | 14 | import javax.annotation.Resource; 15 | import java.util.Comparator; 16 | import java.util.Optional; 17 | 18 | public abstract class MessageHandler { 19 | @Resource 20 | protected DiscordLoadBalancer discordLoadBalancer; 21 | @Resource 22 | protected DiscordHelper discordHelper; 23 | 24 | public abstract void handle(DiscordInstance instance, MessageType messageType, DataObject message); 25 | 26 | public int order() { 27 | return 100; 28 | } 29 | 30 | protected String getMessageContent(DataObject message) { 31 | return message.hasKey("content") ? message.getString("content") : ""; 32 | } 33 | 34 | protected String getMessageNonce(DataObject message) { 35 | return message.hasKey("nonce") ? message.getString("nonce") : ""; 36 | } 37 | 38 | protected String getInteractionName(DataObject message) { 39 | Optional interaction = message.optObject("interaction"); 40 | return interaction.map(dataObject -> dataObject.getString("name", "")).orElse(""); 41 | } 42 | 43 | protected String getReferenceMessageId(DataObject message) { 44 | Optional reference = message.optObject("message_reference"); 45 | return reference.map(dataObject -> dataObject.getString("message_id", "")).orElse(""); 46 | } 47 | 48 | protected void findAndFinishImageTask(DiscordInstance instance, TaskCondition condition, String finalPrompt, DataObject message) { 49 | String imageUrl = getImageUrl(message); 50 | String messageHash = this.discordHelper.getMessageHash(imageUrl); 51 | condition.setMessageHash(messageHash); 52 | Task task = instance.findRunningTask(condition) 53 | .findFirst().orElseGet(() -> { 54 | condition.setMessageHash(null); 55 | return instance.findRunningTask(condition) 56 | .filter(t -> t.getStartTime() != null) 57 | .min(Comparator.comparing(Task::getStartTime)) 58 | .orElse(null); 59 | }); 60 | if (task == null) { 61 | return; 62 | } 63 | task.setProperty(Constants.TASK_PROPERTY_FINAL_PROMPT, finalPrompt); 64 | task.setProperty(Constants.TASK_PROPERTY_MESSAGE_HASH, messageHash); 65 | task.setImageUrl(imageUrl); 66 | finishTask(task, message); 67 | task.awake(); 68 | } 69 | 70 | protected void finishTask(Task task, DataObject message) { 71 | task.setProperty(Constants.TASK_PROPERTY_MESSAGE_ID, message.getString("id")); 72 | task.setProperty(Constants.TASK_PROPERTY_FLAGS, message.getInt("flags", 0)); 73 | task.setProperty(Constants.TASK_PROPERTY_MESSAGE_HASH, this.discordHelper.getMessageHash(task.getImageUrl())); 74 | task.success(); 75 | } 76 | 77 | protected boolean hasImage(DataObject message) { 78 | DataArray attachments = message.optArray("attachments").orElse(DataArray.empty()); 79 | return !attachments.isEmpty(); 80 | } 81 | 82 | protected String getImageUrl(DataObject message) { 83 | DataArray attachments = message.getArray("attachments"); 84 | if (!attachments.isEmpty()) { 85 | String imageUrl = attachments.getObject(0).getString("url"); 86 | return replaceCdnUrl(imageUrl); 87 | } 88 | return null; 89 | } 90 | 91 | protected String replaceCdnUrl(String imageUrl) { 92 | if (CharSequenceUtil.isBlank(imageUrl)) { 93 | return imageUrl; 94 | } 95 | String cdn = this.discordHelper.getCdn(); 96 | if (CharSequenceUtil.startWith(imageUrl, cdn)) { 97 | return imageUrl; 98 | } 99 | return CharSequenceUtil.replaceFirst(imageUrl, DiscordHelper.DISCORD_CDN_URL, cdn); 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | ## 配置项 2 | 3 | | 变量名 | 非空 | 描述 | 4 | |:------------------------------|:--:|:----------------------------------------------| 5 | | mj.accounts | 是 | [账号池配置](./config.md#%E8%B4%A6%E5%8F%B7%E6%B1%A0%E9%85%8D%E7%BD%AE%E5%8F%82%E8%80%83),配置后不需要额外设置mj.discord | 6 | | mj.discord.guild-id | 是 | discord服务器ID | 7 | | mj.discord.channel-id | 是 | discord频道ID | 8 | | mj.discord.user-token | 是 | discord用户Token | 9 | | mj.discord.user-agent | 否 | 调用discord接口、连接wss时的user-agent,建议从浏览器network复制 | 10 | | mj.discord.core-size | 否 | 并发数,默认为3 | 11 | | mj.discord.queue-size | 否 | 等待队列,默认长度10 | 12 | | mj.discord.timeout-minutes | 否 | 任务超时时间,默认为5分钟 | 13 | | mj.api-secret | 否 | 接口密钥,为空不启用鉴权;调用接口时需要加请求头 mj-api-secret | 14 | | mj.notify-hook | 否 | 全局的任务状态变更回调地址 | 15 | | mj.notify-notify-pool-size | 否 | 通知回调线程池大小,默认10 | 16 | | mj.task-store.type | 否 | 任务存储方式,默认in_memory(内存\重启后丢失),可选redis | 17 | | mj.task-store.timeout | 否 | 任务过期时间,过期后删除,默认30天 | 18 | | mj.proxy.host | 否 | 代理host,全局代理不生效时设置 | 19 | | mj.proxy.port | 否 | 代理port,全局代理不生效时设置 | 20 | | mj.ng-discord.server | 否 | https://discord.com 反代地址 | 21 | | mj.ng-discord.cdn | 否 | https://cdn.discordapp.com 反代地址 | 22 | | mj.ng-discord.wss | 否 | wss://gateway.discord.gg 反代地址 | 23 | | mj.ng-discord.resume-wss | 否 | wss://gateway-us-east1-b.discord.gg 反代地址 | 24 | | mj.ng-discord.upload-server | 否 | https://discord-attachments-uploads-prd.storage.googleapis.com 反代地址 | 25 | | mj.translate-way | 否 | 中文prompt翻译成英文的方式,可选null(默认)、baidu、gpt | 26 | | mj.baidu-translate.appid | 否 | 百度翻译的appid | 27 | | mj.baidu-translate.app-secret | 否 | 百度翻译的app-secret | 28 | | mj.openai.gpt-api-url | 否 | 自定义gpt的接口地址,默认不需要配置 | 29 | | mj.openai.gpt-api-key | 否 | gpt的api-key | 30 | | mj.openai.timeout | 否 | openai调用的超时时间,默认30秒 | 31 | | mj.openai.model | 否 | openai的模型,默认gpt-3.5-turbo | 32 | | mj.openai.max-tokens | 否 | 返回结果的最大分词数,默认2048 | 33 | | mj.openai.temperature | 否 | 相似度(0-2.0),默认0 | 34 | | spring.redis | 否 | 任务存储方式设置为redis,需配置redis相关属性 | 35 | 36 | ### 账号池配置参考 37 | ```yaml 38 | mj: 39 | accounts: 40 | - guild-id: xxx 41 | channel-id: xxx 42 | user-token: xxxx 43 | user-agent: xxxx 44 | - guild-id: xxx 45 | channel-id: xxx 46 | user-token: xxxx 47 | user-agent: xxxx 48 | ``` 49 | 50 | 账号字段说明 51 | 52 | | 名称 | 非空 | 描述 | 53 | |:------------------| :----: |:--------------------------------------------------------------------| 54 | | guild-id | 是 | discord服务器ID | 55 | | channel-id | 是 | discord频道ID | 56 | | user-token | 是 | discord用户Token | 57 | | user-agent | 否 | 调用discord接口、连接wss时的user-agent,建议从浏览器network复制 | 58 | | enable | 否 | 是否可用,默认true | 59 | | core-size | 否 | 并发数,默认3 | 60 | | queue-size | 否 | 等待队列长度,默认10 | 61 | | timeout-minutes | 否 | 任务超时时间(分钟),默认5 | 62 | 63 | ### spring.redis配置参考 64 | ```yaml 65 | spring: 66 | redis: 67 | host: 10.107.xxx.xxx 68 | port: 6379 69 | password: xxx 70 | ``` -------------------------------------------------------------------------------- /src/main/java/spring/config/BeanConfig.java: -------------------------------------------------------------------------------- 1 | package spring.config; 2 | 3 | import cn.hutool.core.io.IoUtil; 4 | import cn.hutool.core.util.ReflectUtil; 5 | import com.github.novicezk.midjourney.ProxyProperties; 6 | import com.github.novicezk.midjourney.loadbalancer.rule.IRule; 7 | import com.github.novicezk.midjourney.service.NotifyService; 8 | import com.github.novicezk.midjourney.service.TaskStoreService; 9 | import com.github.novicezk.midjourney.service.TranslateService; 10 | import com.github.novicezk.midjourney.service.store.InMemoryTaskStoreServiceImpl; 11 | import com.github.novicezk.midjourney.service.store.RedisTaskStoreServiceImpl; 12 | import com.github.novicezk.midjourney.service.translate.BaiduTranslateServiceImpl; 13 | import com.github.novicezk.midjourney.service.translate.GPTTranslateServiceImpl; 14 | import com.github.novicezk.midjourney.service.translate.NoTranslateServiceImpl; 15 | import com.github.novicezk.midjourney.support.DiscordAccountHelper; 16 | import com.github.novicezk.midjourney.support.DiscordHelper; 17 | import com.github.novicezk.midjourney.support.Task; 18 | import com.github.novicezk.midjourney.wss.handle.MessageHandler; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.context.ApplicationContext; 21 | import org.springframework.context.annotation.Bean; 22 | import org.springframework.context.annotation.Configuration; 23 | import org.springframework.data.redis.connection.RedisConnectionFactory; 24 | import org.springframework.data.redis.core.RedisTemplate; 25 | import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; 26 | import org.springframework.data.redis.serializer.StringRedisSerializer; 27 | import org.springframework.web.client.RestTemplate; 28 | 29 | import java.io.IOException; 30 | import java.time.Duration; 31 | import java.util.HashMap; 32 | import java.util.List; 33 | import java.util.Map; 34 | 35 | @Configuration 36 | public class BeanConfig { 37 | @Autowired 38 | private ApplicationContext applicationContext; 39 | @Autowired 40 | private ProxyProperties properties; 41 | 42 | @Bean 43 | TranslateService translateService() { 44 | return switch (this.properties.getTranslateWay()) { 45 | case BAIDU -> new BaiduTranslateServiceImpl(this.properties.getBaiduTranslate()); 46 | case GPT -> new GPTTranslateServiceImpl(this.properties); 47 | default -> new NoTranslateServiceImpl(); 48 | }; 49 | } 50 | 51 | @Bean 52 | TaskStoreService taskStoreService(RedisConnectionFactory redisConnectionFactory) { 53 | ProxyProperties.TaskStore.Type type = this.properties.getTaskStore().getType(); 54 | Duration timeout = this.properties.getTaskStore().getTimeout(); 55 | return switch (type) { 56 | case IN_MEMORY -> new InMemoryTaskStoreServiceImpl(timeout); 57 | case REDIS -> new RedisTaskStoreServiceImpl(timeout, taskRedisTemplate(redisConnectionFactory)); 58 | }; 59 | } 60 | 61 | @Bean 62 | RedisTemplate taskRedisTemplate(RedisConnectionFactory redisConnectionFactory) { 63 | RedisTemplate redisTemplate = new RedisTemplate<>(); 64 | redisTemplate.setConnectionFactory(redisConnectionFactory); 65 | redisTemplate.setKeySerializer(new StringRedisSerializer()); 66 | redisTemplate.setHashKeySerializer(new StringRedisSerializer()); 67 | redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Task.class)); 68 | return redisTemplate; 69 | } 70 | 71 | @Bean 72 | public RestTemplate restTemplate() { 73 | return new RestTemplate(); 74 | } 75 | 76 | @Bean 77 | public IRule loadBalancerRule() { 78 | String ruleClassName = IRule.class.getPackageName() + "." + this.properties.getAccountChooseRule(); 79 | return ReflectUtil.newInstance(ruleClassName); 80 | } 81 | 82 | @Bean 83 | List messageHandlers() { 84 | return this.applicationContext.getBeansOfType(MessageHandler.class).values().stream().toList(); 85 | } 86 | 87 | @Bean 88 | DiscordAccountHelper discordAccountHelper(DiscordHelper discordHelper, TaskStoreService taskStoreService, NotifyService notifyService) throws IOException { 89 | var resources = this.applicationContext.getResources("classpath:api-params/*.json"); 90 | Map paramsMap = new HashMap<>(); 91 | for (var resource : resources) { 92 | String filename = resource.getFilename(); 93 | String params = IoUtil.readUtf8(resource.getInputStream()); 94 | paramsMap.put(filename.substring(0, filename.length() - 5), params); 95 | } 96 | return new DiscordAccountHelper(discordHelper, this.properties, restTemplate(), taskStoreService, notifyService, messageHandlers(), paramsMap); 97 | } 98 | 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/service/NotifyServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.service; 2 | 3 | import cn.hutool.cache.CacheUtil; 4 | import cn.hutool.cache.impl.TimedCache; 5 | import cn.hutool.core.comparator.CompareUtil; 6 | import cn.hutool.core.text.CharSequenceUtil; 7 | import com.fasterxml.jackson.core.JsonProcessingException; 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | import com.github.novicezk.midjourney.Constants; 10 | import com.github.novicezk.midjourney.ProxyProperties; 11 | import com.github.novicezk.midjourney.enums.TaskStatus; 12 | import com.github.novicezk.midjourney.support.Task; 13 | import lombok.extern.slf4j.Slf4j; 14 | import org.springframework.http.HttpEntity; 15 | import org.springframework.http.HttpHeaders; 16 | import org.springframework.http.MediaType; 17 | import org.springframework.http.ResponseEntity; 18 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 19 | import org.springframework.stereotype.Service; 20 | import org.springframework.web.client.RestTemplate; 21 | 22 | import java.time.Duration; 23 | 24 | @Slf4j 25 | @Service 26 | public class NotifyServiceImpl implements NotifyService { 27 | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 28 | private final ThreadPoolTaskExecutor executor; 29 | private final TimedCache taskStatusMap = CacheUtil.newTimedCache(Duration.ofHours(1).toMillis()); 30 | 31 | public NotifyServiceImpl(ProxyProperties properties) { 32 | this.executor = new ThreadPoolTaskExecutor(); 33 | this.executor.setCorePoolSize(properties.getNotifyPoolSize()); 34 | this.executor.setThreadNamePrefix("TaskNotify-"); 35 | this.executor.initialize(); 36 | } 37 | 38 | @Override 39 | public synchronized void notifyTaskChange(Task task) { 40 | String notifyHook = task.getPropertyGeneric(Constants.TASK_PROPERTY_NOTIFY_HOOK); 41 | if (CharSequenceUtil.isBlank(notifyHook)) { 42 | return; 43 | } 44 | String taskId = task.getId(); 45 | String statusStr = task.getStatus() + ":" + task.getProgress(); 46 | log.trace("Wait notify task change, task: {}({}), hook: {}", taskId, statusStr, notifyHook); 47 | try { 48 | String paramsStr = OBJECT_MAPPER.writeValueAsString(task); 49 | this.executor.execute(() -> { 50 | try { 51 | executeNotify(taskId, statusStr, notifyHook, paramsStr); 52 | } catch (Exception e) { 53 | log.warn("Notify task change error, task: {}({}), hook: {}, msg: {}", taskId, statusStr, notifyHook, e.getMessage()); 54 | } 55 | }); 56 | } catch (JsonProcessingException e) { 57 | log.error(e.getMessage(), e); 58 | } 59 | } 60 | 61 | private void executeNotify(String taskId, String currentStatusStr, String notifyHook, String paramsStr) { 62 | synchronized (this.taskStatusMap) { 63 | String existStatusStr = this.taskStatusMap.get(taskId, () -> currentStatusStr); 64 | int compare = compareStatusStr(currentStatusStr, existStatusStr); 65 | if (compare < 0) { 66 | log.debug("Ignore this change, task: {}({})", taskId, currentStatusStr); 67 | return; 68 | } 69 | this.taskStatusMap.put(taskId, currentStatusStr); 70 | } 71 | log.debug("推送任务变更, 任务: {}({}), hook: {}", taskId, currentStatusStr, notifyHook); 72 | ResponseEntity responseEntity = postJson(notifyHook, paramsStr); 73 | if (!responseEntity.getStatusCode().is2xxSuccessful()) { 74 | log.warn("Notify task change fail, task: {}({}), hook: {}, code: {}, msg: {}", taskId, currentStatusStr, notifyHook, responseEntity.getStatusCodeValue(), responseEntity.getBody()); 75 | } 76 | } 77 | 78 | private ResponseEntity postJson(String notifyHook, String paramsJson) { 79 | HttpHeaders headers = new HttpHeaders(); 80 | headers.setContentType(MediaType.APPLICATION_JSON); 81 | HttpEntity httpEntity = new HttpEntity<>(paramsJson, headers); 82 | return new RestTemplate().postForEntity(notifyHook, httpEntity, String.class); 83 | } 84 | 85 | private int compareStatusStr(String statusStr1, String statusStr2) { 86 | if (CharSequenceUtil.equals(statusStr1, statusStr2)) { 87 | return 0; 88 | } 89 | float o1 = convertOrder(statusStr1); 90 | float o2 = convertOrder(statusStr2); 91 | return CompareUtil.compare(o1, o2); 92 | } 93 | 94 | private float convertOrder(String statusStr) { 95 | String[] split = statusStr.split(":"); 96 | TaskStatus status = TaskStatus.valueOf(split[0]); 97 | if (status != TaskStatus.IN_PROGRESS || split.length == 1) { 98 | return status.getOrder(); 99 | } 100 | String progress = split[1]; 101 | if (progress.endsWith("%")) { 102 | return status.getOrder() + Float.parseFloat(progress.substring(0, progress.length() - 1)) / 100; 103 | } else { 104 | return status.getOrder(); 105 | } 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/ProxyProperties.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney; 2 | 3 | import com.github.novicezk.midjourney.enums.TranslateWay; 4 | import lombok.Data; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.time.Duration; 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | @Data 13 | @Component 14 | @ConfigurationProperties(prefix = "mj") 15 | public class ProxyProperties { 16 | /** 17 | * task存储配置. 18 | */ 19 | private final TaskStore taskStore = new TaskStore(); 20 | /** 21 | * discord账号选择规则. 22 | */ 23 | private String accountChooseRule = "BestWaitIdleRule"; 24 | /** 25 | * discord单账号配置. 26 | */ 27 | private final DiscordAccountConfig discord = new DiscordAccountConfig(); 28 | /** 29 | * discord账号池配置. 30 | */ 31 | private final List accounts = new ArrayList<>(); 32 | /** 33 | * 代理配置. 34 | */ 35 | private final ProxyConfig proxy = new ProxyConfig(); 36 | /** 37 | * 反代配置. 38 | */ 39 | private final NgDiscordConfig ngDiscord = new NgDiscordConfig(); 40 | /** 41 | * 百度翻译配置. 42 | */ 43 | private final BaiduTranslateConfig baiduTranslate = new BaiduTranslateConfig(); 44 | /** 45 | * openai配置. 46 | */ 47 | private final OpenaiConfig openai = new OpenaiConfig(); 48 | /** 49 | * 中文prompt翻译方式. 50 | */ 51 | private TranslateWay translateWay = TranslateWay.NULL; 52 | /** 53 | * 接口密钥,为空不启用鉴权;调用接口时需要加请求头 mj-api-secret. 54 | */ 55 | private String apiSecret; 56 | /** 57 | * 任务状态变更回调地址. 58 | */ 59 | private String notifyHook; 60 | /** 61 | * 通知回调线程池大小. 62 | */ 63 | private int notifyPoolSize = 10; 64 | 65 | @Data 66 | public static class DiscordAccountConfig { 67 | /** 68 | * 服务器ID. 69 | */ 70 | private String guildId; 71 | /** 72 | * 频道ID. 73 | */ 74 | private String channelId; 75 | /** 76 | * 用户Token. 77 | */ 78 | private String userToken; 79 | /** 80 | * 用户UserAgent. 81 | */ 82 | private String userAgent = Constants.DEFAULT_DISCORD_USER_AGENT; 83 | /** 84 | * 是否可用. 85 | */ 86 | private boolean enable = true; 87 | /** 88 | * 并发数. 89 | */ 90 | private int coreSize = 3; 91 | /** 92 | * 等待队列长度. 93 | */ 94 | private int queueSize = 10; 95 | /** 96 | * 任务超时时间(分钟). 97 | */ 98 | private int timeoutMinutes = 5; 99 | } 100 | 101 | @Data 102 | public static class BaiduTranslateConfig { 103 | /** 104 | * 百度翻译的APP_ID. 105 | */ 106 | private String appid; 107 | /** 108 | * 百度翻译的密钥. 109 | */ 110 | private String appSecret; 111 | } 112 | 113 | @Data 114 | public static class OpenaiConfig { 115 | /** 116 | * 自定义gpt的api-url. 117 | */ 118 | private String gptApiUrl; 119 | /** 120 | * gpt的api-key. 121 | */ 122 | private String gptApiKey; 123 | /** 124 | * 超时时间. 125 | */ 126 | private Duration timeout = Duration.ofSeconds(30); 127 | /** 128 | * 使用的模型. 129 | */ 130 | private String model = "gpt-3.5-turbo"; 131 | /** 132 | * 返回结果的最大分词数. 133 | */ 134 | private int maxTokens = 2048; 135 | /** 136 | * 相似度,取值 0-2. 137 | */ 138 | private double temperature = 0; 139 | } 140 | 141 | @Data 142 | public static class TaskStore { 143 | /** 144 | * 任务过期时间,默认30天. 145 | */ 146 | private Duration timeout = Duration.ofDays(30); 147 | /** 148 | * 任务存储方式: redis(默认)、in_memory. 149 | */ 150 | private Type type = Type.IN_MEMORY; 151 | 152 | public enum Type { 153 | /** 154 | * redis. 155 | */ 156 | REDIS, 157 | /** 158 | * in_memory. 159 | */ 160 | IN_MEMORY 161 | } 162 | } 163 | 164 | @Data 165 | public static class ProxyConfig { 166 | /** 167 | * 代理host. 168 | */ 169 | private String host; 170 | /** 171 | * 代理端口. 172 | */ 173 | private Integer port; 174 | } 175 | 176 | @Data 177 | public static class NgDiscordConfig { 178 | /** 179 | * https://discord.com 反代. 180 | */ 181 | private String server; 182 | /** 183 | * https://cdn.discordapp.com 反代. 184 | */ 185 | private String cdn; 186 | /** 187 | * wss://gateway.discord.gg 反代. 188 | */ 189 | private String wss; 190 | /** 191 | * wss://gateway-us-east1-b.discord.gg 反代. 192 | */ 193 | private String resumeWss; 194 | /** 195 | * https://discord-attachments-uploads-prd.storage.googleapis.com 反代. 196 | */ 197 | private String uploadServer; 198 | } 199 | 200 | @Data 201 | public static class TaskQueueConfig { 202 | /** 203 | * 并发数. 204 | */ 205 | private int coreSize = 3; 206 | /** 207 | * 等待队列长度. 208 | */ 209 | private int queueSize = 10; 210 | /** 211 | * 任务超时时间(分钟). 212 | */ 213 | private int timeoutMinutes = 5; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 2.6.15 11 | 12 | 13 | com.github.novicezk 14 | midjourney-proxy 15 | 2.6.2 16 | 17 | 18 | 5.8.26 19 | 20240303 20 | 5.0.0-beta.21 21 | 1.1.5 22 | 2.0.0 23 | 4.1.0 24 | 1.21 25 | 4.5.14 26 | 17 27 | ${java.version} 28 | ${java.version} 29 | 30 | 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-web 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-data-redis 39 | 40 | 41 | org.springframework 42 | spring-websocket 43 | 44 | 45 | 46 | cn.hutool 47 | hutool-core 48 | ${hutool.version} 49 | 50 | 51 | cn.hutool 52 | hutool-cache 53 | ${hutool.version} 54 | 55 | 56 | cn.hutool 57 | hutool-crypto 58 | ${hutool.version} 59 | 60 | 61 | org.json 62 | json 63 | ${org-json.version} 64 | 65 | 66 | net.dv8tion 67 | JDA 68 | ${jda.version} 69 | 70 | 71 | club.minnced 72 | opus-java 73 | 74 | 75 | 76 | 77 | com.unfbx 78 | chatgpt-java 79 | ${chatgpt-java.version} 80 | 81 | 82 | slf4j-simple 83 | org.slf4j 84 | 85 | 86 | 87 | 88 | eu.maxschuster 89 | dataurl 90 | ${dataurl.version} 91 | 92 | 93 | com.github.xiaoymin 94 | knife4j-openapi2-spring-boot-starter 95 | ${knife4j.verison} 96 | 97 | 98 | eu.bitwalker 99 | UserAgentUtils 100 | ${user-agent-utils.verison} 101 | 102 | 103 | 104 | org.springframework.boot 105 | spring-boot-configuration-processor 106 | true 107 | 108 | 109 | org.projectlombok 110 | lombok 111 | true 112 | 113 | 114 | 115 | 116 | 117 | 118 | org.springframework.boot 119 | spring-boot-maven-plugin 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/util/SnowFlake.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.util; 2 | 3 | import cn.hutool.core.exceptions.ValidateException; 4 | import com.github.novicezk.midjourney.exception.SnowFlakeException; 5 | import lombok.extern.slf4j.Slf4j; 6 | 7 | import java.lang.management.ManagementFactory; 8 | import java.net.InetAddress; 9 | import java.net.NetworkInterface; 10 | import java.util.Date; 11 | import java.util.concurrent.ThreadLocalRandom; 12 | 13 | @Slf4j 14 | public class SnowFlake { 15 | private long workerId; 16 | private long datacenterId; 17 | private long sequence = 0L; 18 | private final long twepoch; 19 | private final long sequenceMask; 20 | private final long workerIdShift; 21 | private final long datacenterIdShift; 22 | private final long timestampLeftShift; 23 | private long lastTimestamp = -1L; 24 | private final boolean randomSequence; 25 | private long count = 0L; 26 | private final long timeOffset; 27 | private final ThreadLocalRandom tlr = ThreadLocalRandom.current(); 28 | 29 | public static final SnowFlake INSTANCE = new SnowFlake(); 30 | 31 | private SnowFlake() { 32 | this(false, 10, null, 5L, 5L, 12L); 33 | } 34 | 35 | private SnowFlake(boolean randomSequence, long timeOffset, Date epochDate, long workerIdBits, long datacenterIdBits, long sequenceBits) { 36 | if (null != epochDate) { 37 | this.twepoch = epochDate.getTime(); 38 | } else { 39 | // 2012/12/12 23:59:59 GMT 40 | this.twepoch = 1355327999000L; 41 | } 42 | long maxWorkerId = ~(-1L << workerIdBits); 43 | long maxDatacenterId = ~(-1L << datacenterIdBits); 44 | this.sequenceMask = ~(-1L << sequenceBits); 45 | this.workerIdShift = sequenceBits; 46 | this.datacenterIdShift = sequenceBits + workerIdBits; 47 | this.timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; 48 | this.randomSequence = randomSequence; 49 | this.timeOffset = timeOffset; 50 | try { 51 | this.datacenterId = getDatacenterId(maxDatacenterId); 52 | this.workerId = getMaxWorkerId(datacenterId, maxWorkerId); 53 | } catch (Exception e) { 54 | log.warn("datacenterId or workerId generate error: {}, set default value", e.getMessage()); 55 | this.datacenterId = 4; 56 | this.workerId = 1; 57 | } 58 | } 59 | 60 | public synchronized String nextId() { 61 | long currentTimestamp = timeGen(); 62 | if (currentTimestamp < this.lastTimestamp) { 63 | long offset = this.lastTimestamp - currentTimestamp; 64 | if (offset > this.timeOffset) { 65 | throw new ValidateException("Clock moved backwards, refusing to generate id for [" + offset + "ms]"); 66 | } 67 | try { 68 | this.wait(offset << 1); 69 | } catch (InterruptedException e) { 70 | throw new SnowFlakeException(e); 71 | } 72 | currentTimestamp = timeGen(); 73 | if (currentTimestamp < this.lastTimestamp) { 74 | throw new SnowFlakeException("Clock moved backwards, refusing to generate id for [" + offset + "ms]"); 75 | } 76 | } 77 | if (this.lastTimestamp == currentTimestamp) { 78 | long tempSequence = this.sequence + 1; 79 | if (this.randomSequence) { 80 | this.sequence = tempSequence & this.sequenceMask; 81 | this.count = (this.count + 1) & this.sequenceMask; 82 | if (this.count == 0) { 83 | currentTimestamp = this.tillNextMillis(this.lastTimestamp); 84 | } 85 | } else { 86 | this.sequence = tempSequence & this.sequenceMask; 87 | if (this.sequence == 0) { 88 | currentTimestamp = this.tillNextMillis(lastTimestamp); 89 | } 90 | } 91 | } else { 92 | this.sequence = this.randomSequence ? this.tlr.nextLong(this.sequenceMask + 1) : 0L; 93 | this.count = 0L; 94 | } 95 | this.lastTimestamp = currentTimestamp; 96 | long id = ((currentTimestamp - this.twepoch) << this.timestampLeftShift) | 97 | (this.datacenterId << this.datacenterIdShift) | 98 | (this.workerId << this.workerIdShift) | 99 | this.sequence; 100 | return String.valueOf(id); 101 | } 102 | 103 | private static long getDatacenterId(long maxDatacenterId) { 104 | long id = 0L; 105 | try { 106 | InetAddress ip = InetAddress.getLocalHost(); 107 | NetworkInterface network = NetworkInterface.getByInetAddress(ip); 108 | if (network == null) { 109 | id = 1L; 110 | } else { 111 | byte[] mac = network.getHardwareAddress(); 112 | if (null != mac) { 113 | id = ((0x000000FF & (long) mac[mac.length - 1]) | (0x0000FF00 & (((long) mac[mac.length - 2]) << 8))) >> 6; 114 | id = id % (maxDatacenterId + 1); 115 | } 116 | } 117 | } catch (Exception e) { 118 | throw new SnowFlakeException(e); 119 | } 120 | return id; 121 | } 122 | 123 | private static long getMaxWorkerId(long datacenterId, long maxWorkerId) { 124 | StringBuilder macIpPid = new StringBuilder(); 125 | macIpPid.append(datacenterId); 126 | try { 127 | String name = ManagementFactory.getRuntimeMXBean().getName(); 128 | if (name != null && !name.isEmpty()) { 129 | macIpPid.append(name.split("@")[0]); 130 | } 131 | String hostIp = InetAddress.getLocalHost().getHostAddress(); 132 | String ipStr = hostIp.replace("\\.", ""); 133 | macIpPid.append(ipStr); 134 | } catch (Exception e) { 135 | throw new SnowFlakeException(e); 136 | } 137 | return (macIpPid.toString().hashCode() & 0xffff) % (maxWorkerId + 1); 138 | } 139 | 140 | private long tillNextMillis(long lastTimestamp) { 141 | long timestamp = timeGen(); 142 | while (timestamp <= lastTimestamp) { 143 | timestamp = timeGen(); 144 | } 145 | return timestamp; 146 | } 147 | 148 | private long timeGen() { 149 | return System.currentTimeMillis(); 150 | } 151 | 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/user/SpringUserWebSocketStarter.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.user; 2 | 3 | 4 | import cn.hutool.core.exceptions.ValidateException; 5 | import cn.hutool.core.text.CharSequenceUtil; 6 | import cn.hutool.core.thread.ThreadUtil; 7 | import com.github.novicezk.midjourney.ReturnCode; 8 | import com.github.novicezk.midjourney.domain.DiscordAccount; 9 | import com.github.novicezk.midjourney.util.AsyncLockUtils; 10 | import com.github.novicezk.midjourney.wss.WebSocketStarter; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.apache.tomcat.websocket.Constants; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.springframework.util.concurrent.ListenableFutureCallback; 15 | import org.springframework.web.socket.CloseStatus; 16 | import org.springframework.web.socket.WebSocketHttpHeaders; 17 | import org.springframework.web.socket.WebSocketSession; 18 | import org.springframework.web.socket.client.standard.StandardWebSocketClient; 19 | 20 | import java.io.IOException; 21 | import java.net.URI; 22 | import java.time.Duration; 23 | import java.util.concurrent.TimeoutException; 24 | 25 | @Slf4j 26 | public class SpringUserWebSocketStarter implements WebSocketStarter { 27 | private static final int CONNECT_RETRY_LIMIT = 5; 28 | 29 | private final DiscordAccount account; 30 | private final UserMessageListener userMessageListener; 31 | private final String wssServer; 32 | private final String resumeWss; 33 | 34 | private boolean running = false; 35 | private boolean sessionClosing = false; 36 | 37 | private WebSocketSession webSocketSession = null; 38 | private ResumeData resumeData = null; 39 | 40 | public SpringUserWebSocketStarter(String wssServer, String resumeWss, DiscordAccount account, UserMessageListener userMessageListener) { 41 | this.wssServer = wssServer; 42 | this.resumeWss = resumeWss; 43 | this.account = account; 44 | this.userMessageListener = userMessageListener; 45 | } 46 | 47 | @Override 48 | public void start() throws Exception { 49 | start(false); 50 | } 51 | 52 | private void start(boolean reconnect) { 53 | this.sessionClosing = false; 54 | WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); 55 | headers.add("Accept-Encoding", "gzip, deflate, br"); 56 | headers.add("Accept-Language", "zh-CN,zh;q=0.9"); 57 | headers.add("Cache-Control", "no-cache"); 58 | headers.add("Pragma", "no-cache"); 59 | headers.add("Sec-Websocket-Extensions", "permessage-deflate; client_max_window_bits"); 60 | headers.add("User-Agent", this.account.getUserAgent()); 61 | var handler = new SpringWebSocketHandler(this.account, this.userMessageListener, this::onSocketSuccess, this::onSocketFailure); 62 | String gatewayUrl; 63 | if (reconnect) { 64 | gatewayUrl = getGatewayServer(this.resumeData.resumeGatewayUrl()) + "/?encoding=json&v=9&compress=zlib-stream"; 65 | handler.setSessionId(this.resumeData.sessionId()); 66 | handler.setSequence(this.resumeData.sequence()); 67 | handler.setResumeGatewayUrl(this.resumeData.resumeGatewayUrl()); 68 | } else { 69 | gatewayUrl = getGatewayServer(null) + "/?encoding=json&v=9&compress=zlib-stream"; 70 | } 71 | var webSocketClient = new StandardWebSocketClient(); 72 | webSocketClient.getUserProperties().put(Constants.IO_TIMEOUT_MS_PROPERTY, "10000"); 73 | var socketSessionFuture = webSocketClient.doHandshake(handler, headers, URI.create(gatewayUrl)); 74 | socketSessionFuture.addCallback(new ListenableFutureCallback<>() { 75 | @Override 76 | public void onFailure(@NotNull Throwable e) { 77 | onSocketFailure(SpringWebSocketHandler.CLOSE_CODE_EXCEPTION, e.getMessage()); 78 | } 79 | 80 | @Override 81 | public void onSuccess(WebSocketSession session) { 82 | SpringUserWebSocketStarter.this.webSocketSession = session; 83 | } 84 | }); 85 | } 86 | 87 | private void onSocketSuccess(String sessionId, Object sequence, String resumeGatewayUrl) { 88 | this.resumeData = new ResumeData(sessionId, sequence, resumeGatewayUrl); 89 | this.running = true; 90 | notifyWssLock(ReturnCode.SUCCESS, ""); 91 | } 92 | 93 | private void onSocketFailure(int code, String reason) { 94 | if (this.sessionClosing) { 95 | this.sessionClosing = false; 96 | return; 97 | } 98 | closeSocketSessionWhenIsOpen(); 99 | if (!this.running) { 100 | notifyWssLock(code, reason); 101 | return; 102 | } 103 | this.running = false; 104 | if (code >= 4000) { 105 | log.warn("[wss-{}] Can't reconnect! Account disabled. Closed by {}({}).", this.account.getDisplay(), code, reason); 106 | disableAccount(); 107 | } else if (code == 2001) { 108 | log.warn("[wss-{}] Closed by {}({}). Try reconnect...", this.account.getDisplay(), code, reason); 109 | tryReconnect(); 110 | } else { 111 | log.warn("[wss-{}] Closed by {}({}). Try new connection...", this.account.getDisplay(), code, reason); 112 | tryNewConnect(); 113 | } 114 | } 115 | 116 | private void tryReconnect() { 117 | try { 118 | tryStart(true); 119 | } catch (Exception e) { 120 | if (e instanceof TimeoutException) { 121 | closeSocketSessionWhenIsOpen(); 122 | } 123 | log.warn("[wss-{}] Reconnect fail: {}, Try new connection...", this.account.getDisplay(), e.getMessage()); 124 | ThreadUtil.sleep(1000); 125 | tryNewConnect(); 126 | } 127 | } 128 | 129 | private void tryNewConnect() { 130 | for (int i = 1; i <= CONNECT_RETRY_LIMIT; i++) { 131 | try { 132 | tryStart(false); 133 | return; 134 | } catch (Exception e) { 135 | if (e instanceof TimeoutException) { 136 | closeSocketSessionWhenIsOpen(); 137 | } 138 | log.warn("[wss-{}] New connect fail ({}): {}", this.account.getDisplay(), i, e.getMessage()); 139 | ThreadUtil.sleep(5000); 140 | } 141 | } 142 | log.error("[wss-{}] Account disabled", this.account.getDisplay()); 143 | disableAccount(); 144 | } 145 | 146 | public void tryStart(boolean reconnect) throws Exception { 147 | start(reconnect); 148 | AsyncLockUtils.LockObject lock = AsyncLockUtils.waitForLock("wss:" + this.account.getId(), Duration.ofSeconds(20)); 149 | int code = lock.getProperty("code", Integer.class, 0); 150 | if (code == ReturnCode.SUCCESS) { 151 | log.debug("[wss-{}] {} success.", this.account.getDisplay(), reconnect ? "Reconnect" : "New connect"); 152 | return; 153 | } 154 | throw new ValidateException(lock.getProperty("description", String.class)); 155 | } 156 | 157 | private void notifyWssLock(int code, String reason) { 158 | AsyncLockUtils.LockObject lock = AsyncLockUtils.getLock("wss:" + this.account.getId()); 159 | if (lock != null) { 160 | lock.setProperty("code", code); 161 | lock.setProperty("description", reason); 162 | lock.awake(); 163 | } 164 | } 165 | 166 | private void disableAccount() { 167 | if (Boolean.FALSE.equals(this.account.isEnable())) { 168 | return; 169 | } 170 | this.account.setEnable(false); 171 | } 172 | 173 | private void closeSocketSessionWhenIsOpen() { 174 | try { 175 | if (this.webSocketSession != null && this.webSocketSession.isOpen()) { 176 | this.sessionClosing = true; 177 | this.webSocketSession.close(); 178 | } 179 | } catch (IOException e) { 180 | // do nothing 181 | } 182 | } 183 | 184 | private String getGatewayServer(String resumeGatewayUrl) { 185 | if (CharSequenceUtil.isNotBlank(resumeGatewayUrl)) { 186 | return CharSequenceUtil.isBlank(this.resumeWss) ? resumeGatewayUrl : this.resumeWss; 187 | } 188 | return this.wssServer; 189 | } 190 | 191 | public record ResumeData(String sessionId, Object sequence, String resumeGatewayUrl) { 192 | } 193 | } -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/loadbalancer/DiscordInstanceImpl.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.loadbalancer; 2 | 3 | 4 | import cn.hutool.core.thread.ThreadUtil; 5 | import com.github.novicezk.midjourney.Constants; 6 | import com.github.novicezk.midjourney.ReturnCode; 7 | import com.github.novicezk.midjourney.domain.DiscordAccount; 8 | import com.github.novicezk.midjourney.enums.BlendDimensions; 9 | import com.github.novicezk.midjourney.enums.TaskStatus; 10 | import com.github.novicezk.midjourney.result.Message; 11 | import com.github.novicezk.midjourney.result.SubmitResultVO; 12 | import com.github.novicezk.midjourney.service.DiscordService; 13 | import com.github.novicezk.midjourney.service.DiscordServiceImpl; 14 | import com.github.novicezk.midjourney.service.NotifyService; 15 | import com.github.novicezk.midjourney.service.TaskStoreService; 16 | import com.github.novicezk.midjourney.support.Task; 17 | import com.github.novicezk.midjourney.wss.WebSocketStarter; 18 | import eu.maxschuster.dataurl.DataUrl; 19 | import lombok.extern.slf4j.Slf4j; 20 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 21 | import org.springframework.web.client.RestTemplate; 22 | 23 | import java.util.Collections; 24 | import java.util.HashMap; 25 | import java.util.List; 26 | import java.util.Map; 27 | import java.util.concurrent.Callable; 28 | import java.util.concurrent.CopyOnWriteArrayList; 29 | import java.util.concurrent.Future; 30 | import java.util.concurrent.RejectedExecutionException; 31 | 32 | @Slf4j 33 | public class DiscordInstanceImpl implements DiscordInstance { 34 | private final DiscordAccount account; 35 | private final WebSocketStarter socketStarter; 36 | private final DiscordService service; 37 | private final TaskStoreService taskStoreService; 38 | private final NotifyService notifyService; 39 | 40 | private final ThreadPoolTaskExecutor taskExecutor; 41 | private final List runningTasks; 42 | private final List queueTasks; 43 | private final Map> taskFutureMap = Collections.synchronizedMap(new HashMap<>()); 44 | 45 | public DiscordInstanceImpl(DiscordAccount account, WebSocketStarter socketStarter, RestTemplate restTemplate, 46 | TaskStoreService taskStoreService, NotifyService notifyService, Map paramsMap) { 47 | this.account = account; 48 | this.socketStarter = socketStarter; 49 | this.taskStoreService = taskStoreService; 50 | this.notifyService = notifyService; 51 | this.service = new DiscordServiceImpl(account, restTemplate, paramsMap); 52 | this.runningTasks = new CopyOnWriteArrayList<>(); 53 | this.queueTasks = new CopyOnWriteArrayList<>(); 54 | this.taskExecutor = new ThreadPoolTaskExecutor(); 55 | this.taskExecutor.setCorePoolSize(account.getCoreSize()); 56 | this.taskExecutor.setMaxPoolSize(account.getCoreSize()); 57 | this.taskExecutor.setQueueCapacity(account.getQueueSize()); 58 | this.taskExecutor.setThreadNamePrefix("TaskQueue-" + account.getDisplay() + "-"); 59 | this.taskExecutor.initialize(); 60 | } 61 | 62 | @Override 63 | public String getInstanceId() { 64 | return this.account.getChannelId(); 65 | } 66 | 67 | @Override 68 | public DiscordAccount account() { 69 | return this.account; 70 | } 71 | 72 | @Override 73 | public boolean isAlive() { 74 | return this.account.isEnable(); 75 | } 76 | 77 | @Override 78 | public void startWss() throws Exception { 79 | this.socketStarter.start(); 80 | } 81 | 82 | @Override 83 | public List getRunningTasks() { 84 | return this.runningTasks; 85 | } 86 | 87 | @Override 88 | public List getQueueTasks() { 89 | return this.queueTasks; 90 | } 91 | 92 | @Override 93 | public void exitTask(Task task) { 94 | try { 95 | Future future = this.taskFutureMap.get(task.getId()); 96 | if (future != null) { 97 | future.cancel(true); 98 | } 99 | saveAndNotify(task); 100 | } finally { 101 | this.runningTasks.remove(task); 102 | this.queueTasks.remove(task); 103 | this.taskFutureMap.remove(task.getId()); 104 | } 105 | } 106 | 107 | @Override 108 | public Map> getRunningFutures() { 109 | return this.taskFutureMap; 110 | } 111 | 112 | @Override 113 | public synchronized SubmitResultVO submitTask(Task task, Callable> discordSubmit) { 114 | this.taskStoreService.save(task); 115 | int currentWaitNumbers; 116 | try { 117 | currentWaitNumbers = this.taskExecutor.getThreadPoolExecutor().getQueue().size(); 118 | Future future = this.taskExecutor.submit(() -> executeTask(task, discordSubmit)); 119 | this.taskFutureMap.put(task.getId(), future); 120 | this.queueTasks.add(task); 121 | } catch (RejectedExecutionException e) { 122 | this.taskStoreService.delete(task.getId()); 123 | return SubmitResultVO.fail(ReturnCode.QUEUE_REJECTED, "队列已满,请稍后尝试") 124 | .setProperty(Constants.TASK_PROPERTY_DISCORD_INSTANCE_ID, this.getInstanceId()); 125 | } catch (Exception e) { 126 | log.error("submit task error", e); 127 | return SubmitResultVO.fail(ReturnCode.FAILURE, "提交失败,系统异常") 128 | .setProperty(Constants.TASK_PROPERTY_DISCORD_INSTANCE_ID, this.getInstanceId()); 129 | } 130 | if (currentWaitNumbers == 0) { 131 | return SubmitResultVO.of(ReturnCode.SUCCESS, "提交成功", task.getId()) 132 | .setProperty(Constants.TASK_PROPERTY_DISCORD_INSTANCE_ID, this.getInstanceId()); 133 | } else { 134 | return SubmitResultVO.of(ReturnCode.IN_QUEUE, "排队中,前面还有" + currentWaitNumbers + "个任务", task.getId()) 135 | .setProperty("numberOfQueues", currentWaitNumbers) 136 | .setProperty(Constants.TASK_PROPERTY_DISCORD_INSTANCE_ID, this.getInstanceId()); 137 | } 138 | } 139 | 140 | private void executeTask(Task task, Callable> discordSubmit) { 141 | this.runningTasks.add(task); 142 | try { 143 | Message result = discordSubmit.call(); 144 | task.setStartTime(System.currentTimeMillis()); 145 | if (result.getCode() != ReturnCode.SUCCESS) { 146 | task.fail(result.getDescription()); 147 | saveAndNotify(task); 148 | log.debug("task finished, id: {}, status: {}", task.getId(), task.getStatus()); 149 | return; 150 | } 151 | task.setStatus(TaskStatus.SUBMITTED); 152 | task.setProgress("0%"); 153 | asyncSaveAndNotify(task); 154 | do { 155 | task.sleep(); 156 | asyncSaveAndNotify(task); 157 | } while (task.getStatus() == TaskStatus.IN_PROGRESS); 158 | log.debug("task finished, id: {}, status: {}", task.getId(), task.getStatus()); 159 | } catch (InterruptedException e) { 160 | Thread.currentThread().interrupt(); 161 | } catch (Exception e) { 162 | log.error("task execute error", e); 163 | task.fail("执行错误,系统异常"); 164 | saveAndNotify(task); 165 | } finally { 166 | this.runningTasks.remove(task); 167 | this.queueTasks.remove(task); 168 | this.taskFutureMap.remove(task.getId()); 169 | } 170 | } 171 | 172 | private void asyncSaveAndNotify(Task task) { 173 | ThreadUtil.execute(() -> saveAndNotify(task)); 174 | } 175 | 176 | private void saveAndNotify(Task task) { 177 | this.taskStoreService.save(task); 178 | this.notifyService.notifyTaskChange(task); 179 | } 180 | 181 | @Override 182 | public Message imagine(String prompt, String nonce) { 183 | return this.service.imagine(prompt, nonce); 184 | } 185 | 186 | @Override 187 | public Message upscale(String messageId, int index, String messageHash, int messageFlags, String nonce) { 188 | return this.service.upscale(messageId, index, messageHash, messageFlags, nonce); 189 | } 190 | 191 | @Override 192 | public Message variation(String messageId, int index, String messageHash, int messageFlags, String nonce) { 193 | return this.service.variation(messageId, index, messageHash, messageFlags, nonce); 194 | } 195 | 196 | @Override 197 | public Message reroll(String messageId, String messageHash, int messageFlags, String nonce) { 198 | return this.service.reroll(messageId, messageHash, messageFlags, nonce); 199 | } 200 | 201 | @Override 202 | public Message describe(String finalFileName, String nonce) { 203 | return this.service.describe(finalFileName, nonce); 204 | } 205 | 206 | @Override 207 | public Message blend(List finalFileNames, BlendDimensions dimensions, String nonce) { 208 | return this.service.blend(finalFileNames, dimensions, nonce); 209 | } 210 | 211 | @Override 212 | public Message zoom(String messageId, String messageHash, String nonce, String zoomOut) { 213 | return this.service.zoom(messageId, messageHash, nonce, zoomOut); 214 | } 215 | 216 | @Override 217 | public Message upscale(String messageId, String messageHash, String nonce, String upscale) { 218 | return this.service.upscale(messageId, messageHash, nonce, upscale); 219 | } 220 | 221 | @Override 222 | public Message vary(String messageId, String messageHash, String nonce, String vary) { 223 | return this.service.vary(messageId, messageHash, nonce, vary); 224 | } 225 | 226 | @Override 227 | public Message move(String messageId, String messageHash, String nonce, String move) { 228 | return this.service.move(messageId, messageHash, nonce, move); 229 | } 230 | 231 | @Override 232 | public Message info(String nonce) { 233 | return this.service.info(nonce); 234 | } 235 | 236 | @Override 237 | public Message settings(String nonce, String value) { 238 | return this.service.settings(nonce, value); 239 | } 240 | 241 | @Override 242 | public Message upload(String fileName, DataUrl dataUrl) { 243 | return this.service.upload(fileName, dataUrl); 244 | } 245 | 246 | @Override 247 | public Message sendImageMessage(String content, String finalFileName) { 248 | return this.service.sendImageMessage(content, finalFileName); 249 | } 250 | 251 | } 252 | -------------------------------------------------------------------------------- /src/main/java/com/github/novicezk/midjourney/wss/user/SpringWebSocketHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.novicezk.midjourney.wss.user; 2 | 3 | import cn.hutool.core.text.CharSequenceUtil; 4 | import cn.hutool.core.thread.ThreadUtil; 5 | import cn.hutool.core.util.RandomUtil; 6 | import com.github.novicezk.midjourney.domain.DiscordAccount; 7 | import eu.bitwalker.useragentutils.UserAgent; 8 | import lombok.Setter; 9 | import lombok.extern.slf4j.Slf4j; 10 | import net.dv8tion.jda.api.utils.data.DataArray; 11 | import net.dv8tion.jda.api.utils.data.DataObject; 12 | import net.dv8tion.jda.api.utils.data.DataType; 13 | import net.dv8tion.jda.internal.requests.WebSocketCode; 14 | import net.dv8tion.jda.internal.utils.compress.Decompressor; 15 | import net.dv8tion.jda.internal.utils.compress.ZlibDecompressor; 16 | import org.jetbrains.annotations.NotNull; 17 | import org.springframework.web.socket.CloseStatus; 18 | import org.springframework.web.socket.TextMessage; 19 | import org.springframework.web.socket.WebSocketHandler; 20 | import org.springframework.web.socket.WebSocketMessage; 21 | import org.springframework.web.socket.WebSocketSession; 22 | 23 | import java.io.IOException; 24 | import java.nio.ByteBuffer; 25 | import java.nio.charset.StandardCharsets; 26 | import java.util.concurrent.Executors; 27 | import java.util.concurrent.Future; 28 | import java.util.concurrent.ScheduledExecutorService; 29 | import java.util.concurrent.TimeUnit; 30 | 31 | @Slf4j 32 | public class SpringWebSocketHandler implements WebSocketHandler { 33 | public static final int CLOSE_CODE_RECONNECT = 2001; 34 | public static final int CLOSE_CODE_INVALIDATE = 1009; 35 | public static final int CLOSE_CODE_EXCEPTION = 1011; 36 | 37 | private final DiscordAccount account; 38 | private final UserMessageListener userMessageListener; 39 | private final SuccessCallback successCallback; 40 | private final FailureCallback failureCallback; 41 | 42 | private final ScheduledExecutorService heartExecutor; 43 | private final DataObject authData; 44 | 45 | @Setter 46 | private String sessionId = null; 47 | @Setter 48 | private Object sequence = null; 49 | @Setter 50 | private String resumeGatewayUrl = null; 51 | 52 | private long interval = 41250; 53 | private boolean heartbeatAck = false; 54 | 55 | private Future heartbeatInterval; 56 | private Future heartbeatTimeout; 57 | 58 | private final Decompressor decompressor = new ZlibDecompressor(2048); 59 | 60 | public SpringWebSocketHandler(DiscordAccount account, UserMessageListener userMessageListener, SuccessCallback successCallback, FailureCallback failureCallback) { 61 | this.account = account; 62 | this.userMessageListener = userMessageListener; 63 | this.successCallback = successCallback; 64 | this.failureCallback = failureCallback; 65 | this.heartExecutor = Executors.newSingleThreadScheduledExecutor(); 66 | this.authData = createAuthData(); 67 | } 68 | 69 | @Override 70 | public void afterConnectionEstablished(@NotNull WebSocketSession session) throws Exception { 71 | // do nothing 72 | } 73 | 74 | @Override 75 | public void handleTransportError(@NotNull WebSocketSession session, @NotNull Throwable e) throws Exception { 76 | log.error("[wss-{}] Transport error", this.account.getDisplay(), e); 77 | onFailure(CLOSE_CODE_EXCEPTION, "transport error"); 78 | } 79 | 80 | @Override 81 | public void afterConnectionClosed(@NotNull WebSocketSession session, @NotNull CloseStatus closeStatus) throws Exception { 82 | onFailure(closeStatus.getCode(), closeStatus.getReason()); 83 | } 84 | 85 | @Override 86 | public boolean supportsPartialMessages() { 87 | return true; 88 | } 89 | 90 | @Override 91 | public void handleMessage(@NotNull WebSocketSession session, WebSocketMessage message) throws Exception { 92 | ByteBuffer buffer = (ByteBuffer) message.getPayload(); 93 | byte[] decompressed = decompressor.decompress(buffer.array()); 94 | if (decompressed == null) { 95 | return; 96 | } 97 | String json = new String(decompressed, StandardCharsets.UTF_8); 98 | DataObject data = DataObject.fromJson(json); 99 | int opCode = data.getInt("op"); 100 | switch (opCode) { 101 | case WebSocketCode.HEARTBEAT -> handleHeartbeat(session); 102 | case WebSocketCode.HEARTBEAT_ACK -> { 103 | this.heartbeatAck = true; 104 | clearHeartbeatTimeout(); 105 | } 106 | case WebSocketCode.HELLO -> { 107 | handleHello(session, data); 108 | doResumeOrIdentify(session); 109 | } 110 | case WebSocketCode.RESUME -> onSuccess(); 111 | case WebSocketCode.RECONNECT -> onFailure(CLOSE_CODE_RECONNECT, "receive server reconnect"); 112 | case WebSocketCode.INVALIDATE_SESSION -> onFailure(CLOSE_CODE_INVALIDATE, "receive session invalid"); 113 | case WebSocketCode.DISPATCH -> handleDispatch(data); 114 | default -> log.debug("[wss-{}] Receive unknown code: {}.", account.getDisplay(), data); 115 | } 116 | } 117 | 118 | private void handleDispatch(DataObject raw) { 119 | this.sequence = raw.opt("s").orElse(null); 120 | if (!raw.isType("d", DataType.OBJECT)) { 121 | return; 122 | } 123 | DataObject content = raw.getObject("d"); 124 | String t = raw.getString("t", null); 125 | if ("READY".equals(t)) { 126 | this.sessionId = content.getString("session_id"); 127 | this.resumeGatewayUrl = content.getString("resume_gateway_url"); 128 | onSuccess(); 129 | } else if ("RESUMED".equals(t)) { 130 | onSuccess(); 131 | } else { 132 | try { 133 | this.userMessageListener.onMessage(raw); 134 | } catch (Exception e) { 135 | log.error("[wss-{}] Handle message error", this.account.getDisplay(), e); 136 | } 137 | } 138 | } 139 | 140 | private void handleHeartbeat(WebSocketSession session) { 141 | sendMessage(session, WebSocketCode.HEARTBEAT, this.sequence); 142 | this.heartbeatTimeout = ThreadUtil.execAsync(() -> { 143 | ThreadUtil.sleep(this.interval); 144 | onFailure(CLOSE_CODE_RECONNECT, "heartbeat has not ack"); 145 | }); 146 | } 147 | 148 | private void handleHello(WebSocketSession session, DataObject data) { 149 | clearHeartbeatInterval(); 150 | this.interval = data.getObject("d").getLong("heartbeat_interval"); 151 | this.heartbeatAck = true; 152 | this.heartbeatInterval = this.heartExecutor.scheduleAtFixedRate(() -> { 153 | if (this.heartbeatAck) { 154 | this.heartbeatAck = false; 155 | sendMessage(session, WebSocketCode.HEARTBEAT, this.sequence); 156 | } else { 157 | onFailure(CLOSE_CODE_RECONNECT, "heartbeat has not ack interval"); 158 | } 159 | }, (long) Math.floor(RandomUtil.randomDouble(0, 1) * this.interval), this.interval, TimeUnit.MILLISECONDS); 160 | } 161 | 162 | private void doResumeOrIdentify(WebSocketSession session) { 163 | if (CharSequenceUtil.isBlank(this.sessionId)) { 164 | sendMessage(session, WebSocketCode.IDENTIFY, this.authData); 165 | } else { 166 | var data = DataObject.empty().put("token", this.account.getUserToken()) 167 | .put("session_id", this.sessionId).put("seq", this.sequence); 168 | sendMessage(session, WebSocketCode.RESUME, data); 169 | } 170 | } 171 | 172 | private void sendMessage(WebSocketSession session, int op, Object d) { 173 | var data = DataObject.empty().put("op", op).put("d", d); 174 | try { 175 | session.sendMessage(new TextMessage(data.toString())); 176 | } catch (IOException e) { 177 | log.error("[wss-{}] Send message error", this.account.getDisplay(), e); 178 | onFailure(CLOSE_CODE_EXCEPTION, "send message error"); 179 | } 180 | } 181 | 182 | private void onSuccess() { 183 | ThreadUtil.execute(() -> this.successCallback.onSuccess(this.sessionId, this.sequence, this.resumeGatewayUrl)); 184 | } 185 | 186 | private void onFailure(int code, String reason) { 187 | clearHeartbeatTimeout(); 188 | clearHeartbeatInterval(); 189 | ThreadUtil.execute(() -> this.failureCallback.onFailure(code, reason)); 190 | } 191 | 192 | private void clearHeartbeatTimeout() { 193 | if (this.heartbeatTimeout != null) { 194 | this.heartbeatTimeout.cancel(true); 195 | this.heartbeatTimeout = null; 196 | } 197 | } 198 | 199 | private void clearHeartbeatInterval() { 200 | if (this.heartbeatInterval != null) { 201 | this.heartbeatInterval.cancel(true); 202 | this.heartbeatInterval = null; 203 | } 204 | } 205 | 206 | private DataObject createAuthData() { 207 | UserAgent agent = UserAgent.parseUserAgentString(this.account.getUserAgent()); 208 | DataObject connectionProperties = DataObject.empty() 209 | .put("browser", agent.getBrowser().getGroup().getName()) 210 | .put("browser_user_agent", this.account.getUserAgent()) 211 | .put("browser_version", agent.getBrowserVersion().toString()) 212 | .put("client_build_number", 222963) 213 | .put("client_event_source", null) 214 | .put("device", "") 215 | .put("os", agent.getOperatingSystem().getName()) 216 | .put("referer", "https://www.midjourney.com") 217 | .put("referrer_current", "") 218 | .put("referring_domain", "www.midjourney.com") 219 | .put("referring_domain_current", "") 220 | .put("release_channel", "stable") 221 | .put("system_locale", "zh-CN"); 222 | DataObject presence = DataObject.empty() 223 | .put("activities", DataArray.empty()) 224 | .put("afk", false) 225 | .put("since", 0) 226 | .put("status", "online"); 227 | DataObject clientState = DataObject.empty() 228 | .put("api_code_version", 0) 229 | .put("guild_versions", DataObject.empty()) 230 | .put("highest_last_message_id", "0") 231 | .put("private_channels_version", "0") 232 | .put("read_state_version", 0) 233 | .put("user_guild_settings_version", -1) 234 | .put("user_settings_version", -1); 235 | return DataObject.empty() 236 | .put("capabilities", 16381) 237 | .put("client_state", clientState) 238 | .put("compress", false) 239 | .put("presence", presence) 240 | .put("properties", connectionProperties) 241 | .put("token", this.account.getUserToken()); 242 | } 243 | 244 | } 245 | --------------------------------------------------------------------------------