Commit 84825579b6f5a15231d2bbb884cda61c020d1c6c
1 parent
bd8e6c2b
😄模拟发送、接收discard消息成功!
Showing
23 changed files
with
1041 additions
and
30 deletions
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/Midjourney/MjHttpExecute.java deleted
| 1 | -package cn.iocoder.yudao.framework.ai.Midjourney; | ||
| 2 | - | ||
| 3 | -import org.springframework.http.HttpHeaders; | ||
| 4 | -import org.springframework.http.MediaType; | ||
| 5 | -import org.springframework.web.client.RestTemplate; | ||
| 6 | - | ||
| 7 | -public class MjHttpExecute implements MjExecute { | ||
| 8 | - | ||
| 9 | - private static final String URL = "https://discord.com/"; | ||
| 10 | - | ||
| 11 | - private RestTemplate restTemplate = new RestTemplate(); | ||
| 12 | - | ||
| 13 | - | ||
| 14 | - @Override | ||
| 15 | - public boolean execute(MjCommandEnum mjCommand, String prompt) { | ||
| 16 | - // 发送的 uri | ||
| 17 | - String uri = "api/v9/interactions"; | ||
| 18 | - // restTemplate 发送post请求 | ||
| 19 | - String result = restTemplate.postForObject(URL + uri, prompt, String.class); | ||
| 20 | - // 加载当前目录下文件 | ||
| 21 | - | ||
| 22 | - return false; | ||
| 23 | - } | ||
| 24 | -} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/DiscordJadMain.java
0 → 100644
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/DiscordWebSocketClient.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney; | ||
| 2 | +import cn.hutool.http.useragent.UserAgent; | ||
| 3 | +import cn.hutool.http.useragent.UserAgentUtil; | ||
| 4 | +import cn.hutool.json.JSONObject; | ||
| 5 | +import com.alibaba.fastjson.JSON; | ||
| 6 | +import org.springframework.web.socket.*; | ||
| 7 | +import org.springframework.web.socket.client.standard.StandardWebSocketClient; | ||
| 8 | +import org.springframework.web.socket.handler.TextWebSocketHandler; | ||
| 9 | + | ||
| 10 | +import java.io.IOException; | ||
| 11 | +import java.net.URI; | ||
| 12 | +import java.net.URISyntaxException; | ||
| 13 | +import java.nio.ByteBuffer; | ||
| 14 | +import java.nio.charset.StandardCharsets; | ||
| 15 | +import java.util.concurrent.ExecutionException; | ||
| 16 | +import java.util.concurrent.Future; | ||
| 17 | + | ||
| 18 | +/** | ||
| 19 | + * https://blog.csdn.net/qq_38490457/article/details/125250135 | ||
| 20 | + */ | ||
| 21 | +public class DiscordWebSocketClient { | ||
| 22 | + | ||
| 23 | + private static final String DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=9&encoding=json"; | ||
| 24 | + | ||
| 25 | + public static void main(String[] args) throws InterruptedException, ExecutionException, IOException, URISyntaxException { | ||
| 26 | + StandardWebSocketClient client = new StandardWebSocketClient(); | ||
| 27 | + DiscordWebSocketHandler handler = new DiscordWebSocketHandler(); | ||
| 28 | + | ||
| 29 | + | ||
| 30 | + WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); | ||
| 31 | + headers.add("Accept-Encoding", "gzip, deflate, br"); | ||
| 32 | + headers.add("Accept-Language", "zh-CN,zh;q=0.9"); | ||
| 33 | + headers.add("Cache-Control", "no-cache"); | ||
| 34 | + headers.add("Pragma", "no-cache"); | ||
| 35 | + headers.add("Sec-Websocket-Extensions", "permessage-deflate; client_max_window_bits"); | ||
| 36 | + headers.add("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"); | ||
| 37 | + | ||
| 38 | + Future<WebSocketSession> futureSession = client.doHandshake(handler, headers, new URI(DISCORD_GATEWAY_URL)); | ||
| 39 | + WebSocketSession session = futureSession.get(); // 这会阻塞直到连接建立 | ||
| 40 | + | ||
| 41 | + // 登录过程(你需要替换 TOKEN 为你的 Discord Bot Token) | ||
| 42 | +// String token = "YOUR_DISCORD_BOT_TOKEN"; // 请替换为你的 Bot Token | ||
| 43 | +// String identifyPayload = "{\"op\":2,\"d\":{\"token\":\"" + token + "\",\"properties\":{\"$os\":\"java\",\"$browser\":\"spring-websocket\",\"$device\":\"spring-websocket\"},\"compress\":false,\"large_threshold\":256,\"shard\":[0,1]}}"; | ||
| 44 | +// session.sendMessage(new TextMessage(identifyPayload)); | ||
| 45 | + | ||
| 46 | + // 发送心跳以保持连接活跃 | ||
| 47 | + Thread heartbeatThread = new Thread(() -> { | ||
| 48 | + int interval = 0; // 初始心跳间隔,后续从 Discord 服务器获取 | ||
| 49 | + while (!Thread.currentThread().isInterrupted()) { | ||
| 50 | + try { | ||
| 51 | + Thread.sleep(interval * 1000); // 等待指定的心跳间隔 | ||
| 52 | + session.sendMessage(new TextMessage("{\"op\":1,\"d\":null}")); // 发送心跳包 | ||
| 53 | + } catch (Exception e) { | ||
| 54 | + e.printStackTrace(); | ||
| 55 | + break; | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | + }); | ||
| 59 | + heartbeatThread.start(); | ||
| 60 | + | ||
| 61 | + // 等待用户输入来保持程序运行(仅用于示例) | ||
| 62 | + System.in.read(); | ||
| 63 | + | ||
| 64 | + // 关闭连接和线程 | ||
| 65 | + session.close(); | ||
| 66 | + heartbeatThread.interrupt(); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + private static class DiscordWebSocketHandler implements WebSocketHandler { | ||
| 70 | + | ||
| 71 | + @Override | ||
| 72 | + public void afterConnectionEstablished(WebSocketSession session) throws Exception { | ||
| 73 | + | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Override | ||
| 77 | + public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception { | ||
| 78 | + Object payload = message.getPayload(); | ||
| 79 | + | ||
| 80 | + session.sendMessage(new TextMessage(JSON.toJSONString(createAuthData()))); | ||
| 81 | + String a= ""; | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + @Override | ||
| 85 | + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { | ||
| 86 | + | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + @Override | ||
| 90 | + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { | ||
| 91 | + | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + @Override | ||
| 95 | + public boolean supportsPartialMessages() { | ||
| 96 | + return false; | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + | ||
| 100 | + private JSONObject createAuthData() { | ||
| 101 | + String userAgentStr = "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"; | ||
| 102 | + UserAgent userAgent = UserAgentUtil.parse(userAgentStr); | ||
| 103 | + String token = "NTY5MDY4NDAxNzEyOTU1Mzky.G4-Fu0.MzD-7ll-ElbXTTgDPHF-WS_UyhMAfbKN3WyyBc"; | ||
| 104 | + | ||
| 105 | + JSONObject connectionProperties = new JSONObject() | ||
| 106 | + .put("browser", userAgent.getBrowser().getName()) | ||
| 107 | + .put("browser_user_agent", userAgentStr) | ||
| 108 | + .put("browser_version", userAgent.getVersion()) | ||
| 109 | + .put("client_build_number", 222963) | ||
| 110 | + .put("client_event_source", null) | ||
| 111 | + .put("device", "") | ||
| 112 | + .put("os", userAgent.getOs().getName()) | ||
| 113 | + .put("referer", "https://www.midjourney.com") | ||
| 114 | + .put("referrer_current", "") | ||
| 115 | + .put("referring_domain", "www.midjourney.com") | ||
| 116 | + .put("referring_domain_current", "") | ||
| 117 | + .put("release_channel", "stable") | ||
| 118 | + .put("system_locale", "zh-CN"); | ||
| 119 | + JSONObject presence = new JSONObject() | ||
| 120 | + .put("activities", "") | ||
| 121 | + .put("afk", false) | ||
| 122 | + .put("since", 0) | ||
| 123 | + .put("status", "online"); | ||
| 124 | + JSONObject clientState = new JSONObject() | ||
| 125 | + .put("api_code_version", 0) | ||
| 126 | + .put("guild_versions", "") | ||
| 127 | + .put("highest_last_message_id", "0") | ||
| 128 | + .put("private_channels_version", "0") | ||
| 129 | + .put("read_state_version", 0) | ||
| 130 | + .put("user_guild_settings_version", -1) | ||
| 131 | + .put("user_settings_version", -1); | ||
| 132 | + return new JSONObject() | ||
| 133 | + .put("capabilities", 16381) | ||
| 134 | + .put("client_state", clientState) | ||
| 135 | + .put("compress", false) | ||
| 136 | + .put("presence", presence) | ||
| 137 | + .put("properties", connectionProperties) | ||
| 138 | + .put("token", token); | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/Midjourney/MidjourneyApi.java renamed to yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/MidjourneyApi.java
| 1 | -package cn.iocoder.yudao.framework.ai.Midjourney; | 1 | +package cn.iocoder.yudao.framework.ai.midjourney; |
| 2 | 2 | ||
| 3 | /** | 3 | /** |
| 4 | * 文档: https://www.xiubbs.com/t-401-1-1.html | 4 | * 文档: https://www.xiubbs.com/t-401-1-1.html |
| 5 | * | 5 | * |
| 6 | * https://github.com/novicezk/midjourney-proxy/blob/main/README_CN.md | 6 | * https://github.com/novicezk/midjourney-proxy/blob/main/README_CN.md |
| 7 | * | 7 | * |
| 8 | + * discord4j、https://github.com/discord-jda/JDA | ||
| 9 | + * | ||
| 8 | */ | 10 | */ |
| 9 | public class MidjourneyApi { | 11 | public class MidjourneyApi { |
| 10 | 12 |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/Midjourney/MjCommandEnum.java renamed to yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/MjCommandEnum.java
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/Midjourney/MjExecute.java renamed to yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/MjExecute.java
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/MjHttpExecute.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.io.FileUtil; | ||
| 4 | +import org.springframework.http.HttpEntity; | ||
| 5 | +import org.springframework.http.HttpHeaders; | ||
| 6 | +import org.springframework.http.MediaType; | ||
| 7 | +import org.springframework.web.client.RestTemplate; | ||
| 8 | + | ||
| 9 | +import java.nio.charset.Charset; | ||
| 10 | + | ||
| 11 | +public class MjHttpExecute implements MjExecute { | ||
| 12 | + | ||
| 13 | + private static final String URL = "https://discord.com/"; | ||
| 14 | + | ||
| 15 | + @Override | ||
| 16 | + public boolean execute(MjCommandEnum mjCommand, String prompt) { | ||
| 17 | + // 发送的 uri | ||
| 18 | + String uri = "api/v9/interactions"; | ||
| 19 | + // restTemplate 发送post请求 | ||
| 20 | +// String result = restTemplate.postForObject(URL + uri, prompt, String.class); | ||
| 21 | + // 加载当前目录下文件 | ||
| 22 | + return false; | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + public static void main(String[] args) { | ||
| 26 | + RestTemplate restTemplate = new RestTemplate(); | ||
| 27 | + | ||
| 28 | + String token = "NTY5MDY4NDAxNzEyOTU1Mzky.G4-Fu0.MzD-7ll-ElbXTTgDPHF-WS_UyhMAfbKN3WyyBc"; | ||
| 29 | + String body = FileUtil.readString("/Users/fansili/projects/github/ruoyi-vue-pro/yudao-module-ai" + | ||
| 30 | + "/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney" + | ||
| 31 | + "/interactions_type2.json", Charset.forName("utf-8")); | ||
| 32 | + // 创建HTTP头部 | ||
| 33 | + HttpHeaders headers = new HttpHeaders(); | ||
| 34 | + headers.setContentType(MediaType.APPLICATION_JSON); // 设置内容类型为JSON | ||
| 35 | + headers.set("Authorization", token); // 如果需要,设置认证信息(例如JWT令牌) | ||
| 36 | + headers.set("Referer", "https://discord.com/channels/1221445697157468200/1221445862962630706"); // 如果需要,设置认证信息(例如JWT令牌) | ||
| 37 | + headers.set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"); // 如果需要,设置认证信息(例如JWT令牌) | ||
| 38 | + headers.set("Cookie", "__dcfduid=6ca536c0e3fa11eeb7cbe34c31b49caf; __sdcfduid=6ca536c1e3fa11eeb7cbe34c31b49caf52cce5ffd8983d2a052cf6aba75fe5fe566f2c265902e283ce30dbf98b8c9c93; _gcl_au=1.1.245923998.1710853617; _ga=GA1.1.111061823.1710853617; __cfruid=6385bb3f48345a006b25992db7dcf984e395736d-1712124666; _cfuvid=O09la5ms0ypNptiG0iD8A6BKWlTxz1LG0WR7qRStD7o-1712124666575-0.0.1.1-604800000; locale=zh-CN; cf_clearance=l_YGod1_SUtYxpDVeZXiX7DLLPl1DYrquZe8WVltvYs-1712124668-1.0.1.1-Hl2.fToel23EpF2HCu9J20rB4D7OhhCzoajPSdo.9Up.wPxhvq22DP9RHzEBKuIUlKyH.kJLxXJfAt2N.LD5WQ; OptanonConsent=isIABGlobal=false&datestamp=Wed+Apr+03+2024+14%3A11%3A15+GMT%2B0800+(%E4%B8%AD%E5%9B%BD%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=6.33.0&hosts=&landingPath=https%3A%2F%2Fdiscord.com%2F&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1; _ga_Q149DFWHT7=GS1.1.1712124668.4.1.1712124679.0.0.0"); // 如果需要,设置认证信息(例如JWT令牌) | ||
| 39 | + // 封装请求体和头部信息 | ||
| 40 | + HttpEntity<String> requestEntity = new HttpEntity<>(body, headers); | ||
| 41 | + | ||
| 42 | + // 定义请求URL和返回类型 | ||
| 43 | + String uri = "api/v9/interactions"; | ||
| 44 | + String res = restTemplate.postForObject(URL + uri, requestEntity, String.class); | ||
| 45 | + System.err.println("11"); | ||
| 46 | + | ||
| 47 | + // | ||
| 48 | +// MjHttpExecute mjHttpExecute = new MjHttpExecute(); | ||
| 49 | +// mjHttpExecute.execute(null, "童话世界应该是什么样?"); | ||
| 50 | + | ||
| 51 | + } | ||
| 52 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/Midjourney/interactions_type2.json renamed to yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/interactions_type2.json
| 1 | { | 1 | { |
| 2 | "type": 2, | 2 | "type": 2, |
| 3 | "application_id": "936929561302675456", | 3 | "application_id": "936929561302675456", |
| 4 | - "guild_id": "1224337694918971392", | ||
| 5 | - "channel_id": "1224337694918971396", | 4 | + "guild_id": "1221445697157468200", |
| 5 | + "channel_id": "1221445862962630706", | ||
| 6 | "session_id": "696318caed5180a2210e358e44801449", | 6 | "session_id": "696318caed5180a2210e358e44801449", |
| 7 | "data": { | 7 | "data": { |
| 8 | "version": "1166847114203123795", | 8 | "version": "1166847114203123795", |
| @@ -13,7 +13,7 @@ | @@ -13,7 +13,7 @@ | ||
| 13 | { | 13 | { |
| 14 | "type": 3, | 14 | "type": 3, |
| 15 | "name": "prompt", | 15 | "name": "prompt", |
| 16 | - "value": "中国的是什么样子" | 16 | + "value": "童话世界应该是什么样?" |
| 17 | } | 17 | } |
| 18 | ], | 18 | ], |
| 19 | "application_command": { | 19 | "application_command": { |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/Midjourney/interactions_type3.json renamed to yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/interactions_type3.json
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/jad/Constants.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.jad; | ||
| 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 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/jad/DiscordAccount.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.jad; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import lombok.EqualsAndHashCode; | ||
| 6 | + | ||
| 7 | +@Data | ||
| 8 | +@EqualsAndHashCode(callSuper = true) | ||
| 9 | + | ||
| 10 | +public class DiscordAccount extends DomainObject { | ||
| 11 | + | ||
| 12 | + | ||
| 13 | + private String guildId; | ||
| 14 | + | ||
| 15 | + private String channelId; | ||
| 16 | + | ||
| 17 | + private String userToken; | ||
| 18 | + | ||
| 19 | + private String userAgent = Constants.DEFAULT_DISCORD_USER_AGENT; | ||
| 20 | + | ||
| 21 | + | ||
| 22 | + private boolean enable = true; | ||
| 23 | + | ||
| 24 | + | ||
| 25 | + private int coreSize = 3; | ||
| 26 | + | ||
| 27 | + private int queueSize = 10; | ||
| 28 | + | ||
| 29 | + private int timeoutMinutes = 5; | ||
| 30 | + | ||
| 31 | + @JsonIgnore | ||
| 32 | + public String getDisplay() { | ||
| 33 | + return this.channelId; | ||
| 34 | + } | ||
| 35 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/jad/DomainObject.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.jad; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| 5 | +import lombok.Getter; | ||
| 6 | +import lombok.Setter; | ||
| 7 | + | ||
| 8 | +import java.io.Serializable; | ||
| 9 | +import java.util.HashMap; | ||
| 10 | +import java.util.Map; | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +public class DomainObject implements Serializable { | ||
| 14 | + @Getter | ||
| 15 | + @Setter | ||
| 16 | + protected String id; | ||
| 17 | + | ||
| 18 | + @Setter | ||
| 19 | + protected Map<String, Object> properties; // 扩展属性,仅支持基本类型 | ||
| 20 | + | ||
| 21 | + @JsonIgnore | ||
| 22 | + private final transient Object lock = new Object(); | ||
| 23 | + | ||
| 24 | + public void sleep() throws InterruptedException { | ||
| 25 | + synchronized (this.lock) { | ||
| 26 | + this.lock.wait(); | ||
| 27 | + } | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + public void awake() { | ||
| 31 | + synchronized (this.lock) { | ||
| 32 | + this.lock.notifyAll(); | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + public DomainObject setProperty(String name, Object value) { | ||
| 37 | + getProperties().put(name, value); | ||
| 38 | + return this; | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + public DomainObject removeProperty(String name) { | ||
| 42 | + getProperties().remove(name); | ||
| 43 | + return this; | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + public Object getProperty(String name) { | ||
| 47 | + return getProperties().get(name); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + @SuppressWarnings("unchecked") | ||
| 51 | + public <T> T getPropertyGeneric(String name) { | ||
| 52 | + return (T) getProperty(name); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + public <T> T getProperty(String name, Class<T> clz) { | ||
| 56 | + return getProperty(name, clz, null); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + public <T> T getProperty(String name, Class<T> clz, T defaultValue) { | ||
| 60 | + Object value = getProperty(name); | ||
| 61 | + return value == null ? defaultValue : clz.cast(value); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + public Map<String, Object> getProperties() { | ||
| 65 | + if (this.properties == null) { | ||
| 66 | + this.properties = new HashMap<>(); | ||
| 67 | + } | ||
| 68 | + return this.properties; | ||
| 69 | + } | ||
| 70 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/AsyncLockUtils.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.wss; | ||
| 2 | + | ||
| 3 | +import cn.hutool.cache.CacheUtil; | ||
| 4 | +import cn.hutool.cache.impl.TimedCache; | ||
| 5 | +import cn.hutool.core.thread.ThreadUtil; | ||
| 6 | +import cn.iocoder.yudao.framework.ai.midjourney.jad.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<String, LockObject> 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 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/DiscordHelper.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.wss; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.text.CharSequenceUtil; | ||
| 4 | +import lombok.RequiredArgsConstructor; | ||
| 5 | +import org.springframework.stereotype.Component; | ||
| 6 | + | ||
| 7 | +@Component | ||
| 8 | +@RequiredArgsConstructor | ||
| 9 | +public class DiscordHelper { | ||
| 10 | + /** | ||
| 11 | + * DISCORD_SERVER_URL. | ||
| 12 | + */ | ||
| 13 | + public static final String DISCORD_SERVER_URL = "https://discord.com"; | ||
| 14 | + /** | ||
| 15 | + * DISCORD_CDN_URL. | ||
| 16 | + */ | ||
| 17 | + public static final String DISCORD_CDN_URL = "https://cdn.discordapp.com"; | ||
| 18 | + /** | ||
| 19 | + * DISCORD_WSS_URL. | ||
| 20 | + */ | ||
| 21 | + public static final String DISCORD_WSS_URL = "wss://gateway.discord.gg"; | ||
| 22 | + /** | ||
| 23 | + * DISCORD_UPLOAD_URL. | ||
| 24 | + */ | ||
| 25 | + public static final String DISCORD_UPLOAD_URL = "https://discord-attachments-uploads-prd.storage.googleapis.com"; | ||
| 26 | + | ||
| 27 | + public String getServer() { | ||
| 28 | + return DISCORD_SERVER_URL; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + public String getCdn() { | ||
| 32 | + return DISCORD_CDN_URL; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + public String getWss() { | ||
| 36 | + return DISCORD_WSS_URL; | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + public String getMessageHash(String imageUrl) { | ||
| 40 | + if (CharSequenceUtil.isBlank(imageUrl)) { | ||
| 41 | + return null; | ||
| 42 | + } | ||
| 43 | + if (CharSequenceUtil.endWith(imageUrl, "_grid_0.webp")) { | ||
| 44 | + int hashStartIndex = imageUrl.lastIndexOf("/"); | ||
| 45 | + if (hashStartIndex < 0) { | ||
| 46 | + return null; | ||
| 47 | + } | ||
| 48 | + return CharSequenceUtil.sub(imageUrl, hashStartIndex + 1, imageUrl.length() - "_grid_0.webp".length()); | ||
| 49 | + } | ||
| 50 | + int hashStartIndex = imageUrl.lastIndexOf("_"); | ||
| 51 | + if (hashStartIndex < 0) { | ||
| 52 | + return null; | ||
| 53 | + } | ||
| 54 | + return CharSequenceUtil.subBefore(imageUrl.substring(hashStartIndex + 1), ".", true); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/Main.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.wss; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.yudao.framework.ai.midjourney.jad.DiscordAccount; | ||
| 4 | +import cn.iocoder.yudao.framework.ai.midjourney.wss.user.SpringUserWebSocketStarter; | ||
| 5 | +import cn.iocoder.yudao.framework.ai.midjourney.wss.user.UserMessageListener; | ||
| 6 | + | ||
| 7 | +import java.util.Scanner; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * author: fansili | ||
| 11 | + * time: 2024/4/3 16:40 | ||
| 12 | + */ | ||
| 13 | +public class Main { | ||
| 14 | + | ||
| 15 | + public static void main(String[] args) { | ||
| 16 | + String token = "NTY5MDY4NDAxNzEyOTU1Mzky.G4-Fu0.MzD-7ll-ElbXTTgDPHF-WS_UyhMAfbKN3WyyBc"; | ||
| 17 | + | ||
| 18 | + | ||
| 19 | + DiscordHelper discordHelper = new DiscordHelper(); | ||
| 20 | + DiscordAccount discordAccount = new DiscordAccount(); | ||
| 21 | + discordAccount.setUserToken(token); | ||
| 22 | + discordAccount.setGuildId("1221445697157468200"); | ||
| 23 | + discordAccount.setChannelId("1221445862962630706"); | ||
| 24 | + | ||
| 25 | + | ||
| 26 | + var messageListener = new UserMessageListener(); | ||
| 27 | + var webSocketStarter = new SpringUserWebSocketStarter(discordHelper.getWss(), null, discordAccount, messageListener); | ||
| 28 | + | ||
| 29 | + try { | ||
| 30 | + webSocketStarter.start(); | ||
| 31 | + } catch (Exception e) { | ||
| 32 | + throw new RuntimeException(e); | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + | ||
| 36 | + Scanner scanner = new Scanner(System.in); | ||
| 37 | + scanner.nextLine(); | ||
| 38 | + } | ||
| 39 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/MessageType.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.wss; | ||
| 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 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/ReturnCode.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.wss; | ||
| 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 | +} | ||
| 0 | \ No newline at end of file | 43 | \ No newline at end of file |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/WebSocketStarter.java
0 → 100644
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/user/FailureCallback.java
0 → 100644
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/user/SpringUserWebSocketStarter.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.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 cn.iocoder.yudao.framework.ai.midjourney.jad.DiscordAccount; | ||
| 8 | +import cn.iocoder.yudao.framework.ai.midjourney.wss.AsyncLockUtils; | ||
| 9 | +import cn.iocoder.yudao.framework.ai.midjourney.wss.ReturnCode; | ||
| 10 | +import cn.iocoder.yudao.framework.ai.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 | + | ||
| 36 | + private WebSocketSession webSocketSession = null; | ||
| 37 | + private ResumeData resumeData = null; | ||
| 38 | + | ||
| 39 | + public SpringUserWebSocketStarter(String wssServer, String resumeWss, DiscordAccount account, UserMessageListener userMessageListener) { | ||
| 40 | + this.wssServer = wssServer; | ||
| 41 | + this.resumeWss = resumeWss; | ||
| 42 | + this.account = account; | ||
| 43 | + this.userMessageListener = userMessageListener; | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + @Override | ||
| 47 | + public void start() throws Exception { | ||
| 48 | + start(false); | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + private void start(boolean reconnect) { | ||
| 52 | + WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); | ||
| 53 | + headers.add("Accept-Encoding", "gzip, deflate, br"); | ||
| 54 | + headers.add("Accept-Language", "zh-CN,zh;q=0.9"); | ||
| 55 | + headers.add("Cache-Control", "no-cache"); | ||
| 56 | + headers.add("Pragma", "no-cache"); | ||
| 57 | + headers.add("Sec-Websocket-Extensions", "permessage-deflate; client_max_window_bits"); | ||
| 58 | + headers.add("User-Agent", this.account.getUserAgent()); | ||
| 59 | + var handler = new SpringWebSocketHandler(this.account, this.userMessageListener, this::onSocketSuccess, this::onSocketFailure); | ||
| 60 | + String gatewayUrl; | ||
| 61 | + if (reconnect) { | ||
| 62 | + gatewayUrl = getGatewayServer(this.resumeData.resumeGatewayUrl()) + "/?encoding=json&v=9&compress=zlib-stream"; | ||
| 63 | + handler.setSessionId(this.resumeData.sessionId()); | ||
| 64 | + handler.setSequence(this.resumeData.sequence()); | ||
| 65 | + handler.setResumeGatewayUrl(this.resumeData.resumeGatewayUrl()); | ||
| 66 | + } else { | ||
| 67 | + gatewayUrl = getGatewayServer(null) + "/?encoding=json&v=9&compress=zlib-stream"; | ||
| 68 | + } | ||
| 69 | + var webSocketClient = new StandardWebSocketClient(); | ||
| 70 | + webSocketClient.getUserProperties().put(Constants.IO_TIMEOUT_MS_PROPERTY, "10000"); | ||
| 71 | + var socketSessionFuture = webSocketClient.doHandshake(handler, headers, URI.create(gatewayUrl)); | ||
| 72 | + socketSessionFuture.addCallback(new ListenableFutureCallback<>() { | ||
| 73 | + @Override | ||
| 74 | + public void onFailure(@NotNull Throwable e) { | ||
| 75 | + onSocketFailure(SpringWebSocketHandler.CLOSE_CODE_EXCEPTION, e.getMessage()); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + @Override | ||
| 79 | + public void onSuccess(WebSocketSession session) { | ||
| 80 | + SpringUserWebSocketStarter.this.webSocketSession = session; | ||
| 81 | + } | ||
| 82 | + }); | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + private void onSocketSuccess(String sessionId, Object sequence, String resumeGatewayUrl) { | ||
| 86 | + this.resumeData = new ResumeData(sessionId, sequence, resumeGatewayUrl); | ||
| 87 | + this.running = true; | ||
| 88 | + notifyWssLock(ReturnCode.SUCCESS, ""); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + private void onSocketFailure(int code, String reason) { | ||
| 92 | + if (code == 1001) { | ||
| 93 | + return; | ||
| 94 | + } | ||
| 95 | + closeSocketSessionWhenIsOpen(); | ||
| 96 | + if (!this.running) { | ||
| 97 | + notifyWssLock(code, reason); | ||
| 98 | + return; | ||
| 99 | + } | ||
| 100 | + this.running = false; | ||
| 101 | + if (code >= 4000) { | ||
| 102 | + log.warn("[wss-{}] Can't reconnect! Account disabled. Closed by {}({}).", this.account.getDisplay(), code, reason); | ||
| 103 | + disableAccount(); | ||
| 104 | + } else if (code == 2001) { | ||
| 105 | + log.warn("[wss-{}] Closed by {}({}). Try reconnect...", this.account.getDisplay(), code, reason); | ||
| 106 | + tryReconnect(); | ||
| 107 | + } else { | ||
| 108 | + log.warn("[wss-{}] Closed by {}({}). Try new connection...", this.account.getDisplay(), code, reason); | ||
| 109 | + tryNewConnect(); | ||
| 110 | + } | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + private void tryReconnect() { | ||
| 114 | + try { | ||
| 115 | + tryStart(true); | ||
| 116 | + } catch (Exception e) { | ||
| 117 | + if (e instanceof TimeoutException) { | ||
| 118 | + closeSocketSessionWhenIsOpen(); | ||
| 119 | + } | ||
| 120 | + log.warn("[wss-{}] Reconnect fail: {}, Try new connection...", this.account.getDisplay(), e.getMessage()); | ||
| 121 | + ThreadUtil.sleep(1000); | ||
| 122 | + tryNewConnect(); | ||
| 123 | + } | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + private void tryNewConnect() { | ||
| 127 | + for (int i = 1; i <= CONNECT_RETRY_LIMIT; i++) { | ||
| 128 | + try { | ||
| 129 | + tryStart(false); | ||
| 130 | + return; | ||
| 131 | + } catch (Exception e) { | ||
| 132 | + if (e instanceof TimeoutException) { | ||
| 133 | + closeSocketSessionWhenIsOpen(); | ||
| 134 | + } | ||
| 135 | + log.warn("[wss-{}] New connect fail ({}): {}", this.account.getDisplay(), i, e.getMessage()); | ||
| 136 | + ThreadUtil.sleep(5000); | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + log.error("[wss-{}] Account disabled", this.account.getDisplay()); | ||
| 140 | + disableAccount(); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + public void tryStart(boolean reconnect) throws Exception { | ||
| 144 | + start(reconnect); | ||
| 145 | + AsyncLockUtils.LockObject lock = AsyncLockUtils.waitForLock("wss:" + this.account.getId(), Duration.ofSeconds(20)); | ||
| 146 | + int code = lock.getProperty("code", Integer.class, 0); | ||
| 147 | + if (code == ReturnCode.SUCCESS) { | ||
| 148 | + log.debug("[wss-{}] {} success.", this.account.getDisplay(), reconnect ? "Reconnect" : "New connect"); | ||
| 149 | + return; | ||
| 150 | + } | ||
| 151 | + throw new ValidateException(lock.getProperty("description", String.class)); | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + private void notifyWssLock(int code, String reason) { | ||
| 155 | + AsyncLockUtils.LockObject lock = AsyncLockUtils.getLock("wss:" + this.account.getId()); | ||
| 156 | + if (lock != null) { | ||
| 157 | + lock.setProperty("code", code); | ||
| 158 | + lock.setProperty("description", reason); | ||
| 159 | + lock.awake(); | ||
| 160 | + } | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + private void disableAccount() { | ||
| 164 | + if (Boolean.FALSE.equals(this.account.isEnable())) { | ||
| 165 | + return; | ||
| 166 | + } | ||
| 167 | + this.account.setEnable(false); | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + private void closeSocketSessionWhenIsOpen() { | ||
| 171 | + try { | ||
| 172 | + if (this.webSocketSession != null && this.webSocketSession.isOpen()) { | ||
| 173 | + this.webSocketSession.close(CloseStatus.GOING_AWAY); | ||
| 174 | + } | ||
| 175 | + } catch (IOException e) { | ||
| 176 | + // do nothing | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + private String getGatewayServer(String resumeGatewayUrl) { | ||
| 181 | + if (CharSequenceUtil.isNotBlank(resumeGatewayUrl)) { | ||
| 182 | + return CharSequenceUtil.isBlank(this.resumeWss) ? resumeGatewayUrl : this.resumeWss; | ||
| 183 | + } | ||
| 184 | + return this.wssServer; | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + public record ResumeData(String sessionId, Object sequence, String resumeGatewayUrl) { | ||
| 188 | + } | ||
| 189 | +} | ||
| 0 | \ No newline at end of file | 190 | \ No newline at end of file |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/user/SpringWebSocketHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.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 cn.hutool.http.useragent.UserAgent; | ||
| 7 | +import cn.hutool.http.useragent.UserAgentUtil; | ||
| 8 | +import cn.iocoder.yudao.framework.ai.midjourney.jad.DiscordAccount; | ||
| 9 | +import lombok.Setter; | ||
| 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 net.dv8tion.jda.api.utils.data.DataType; | ||
| 14 | +import net.dv8tion.jda.internal.requests.WebSocketCode; | ||
| 15 | +import net.dv8tion.jda.internal.utils.compress.Decompressor; | ||
| 16 | +import net.dv8tion.jda.internal.utils.compress.ZlibDecompressor; | ||
| 17 | +import org.jetbrains.annotations.NotNull; | ||
| 18 | +import org.springframework.web.socket.*; | ||
| 19 | + | ||
| 20 | +import java.io.IOException; | ||
| 21 | +import java.nio.ByteBuffer; | ||
| 22 | +import java.nio.charset.StandardCharsets; | ||
| 23 | +import java.util.concurrent.Executors; | ||
| 24 | +import java.util.concurrent.Future; | ||
| 25 | +import java.util.concurrent.ScheduledExecutorService; | ||
| 26 | +import java.util.concurrent.TimeUnit; | ||
| 27 | + | ||
| 28 | +@Slf4j | ||
| 29 | +public class SpringWebSocketHandler implements WebSocketHandler { | ||
| 30 | + public static final int CLOSE_CODE_RECONNECT = 2001; | ||
| 31 | + public static final int CLOSE_CODE_INVALIDATE = 1009; | ||
| 32 | + public static final int CLOSE_CODE_EXCEPTION = 1011; | ||
| 33 | + | ||
| 34 | + private final DiscordAccount account; | ||
| 35 | + private final UserMessageListener userMessageListener; | ||
| 36 | + private final SuccessCallback successCallback; | ||
| 37 | + private final FailureCallback failureCallback; | ||
| 38 | + | ||
| 39 | + private final ScheduledExecutorService heartExecutor; | ||
| 40 | + private final DataObject authData; | ||
| 41 | + | ||
| 42 | + @Setter | ||
| 43 | + private String sessionId = null; | ||
| 44 | + @Setter | ||
| 45 | + private Object sequence = null; | ||
| 46 | + @Setter | ||
| 47 | + private String resumeGatewayUrl = null; | ||
| 48 | + | ||
| 49 | + private long interval = 41250; | ||
| 50 | + private boolean heartbeatAck = false; | ||
| 51 | + | ||
| 52 | + private Future<?> heartbeatInterval; | ||
| 53 | + private Future<?> heartbeatTimeout; | ||
| 54 | + | ||
| 55 | + private final Decompressor decompressor = new ZlibDecompressor(2048); | ||
| 56 | + | ||
| 57 | + public SpringWebSocketHandler(DiscordAccount account, UserMessageListener userMessageListener, SuccessCallback successCallback, FailureCallback failureCallback) { | ||
| 58 | + this.account = account; | ||
| 59 | + this.userMessageListener = userMessageListener; | ||
| 60 | + this.successCallback = successCallback; | ||
| 61 | + this.failureCallback = failureCallback; | ||
| 62 | + this.heartExecutor = Executors.newSingleThreadScheduledExecutor(); | ||
| 63 | + this.authData = createAuthData(); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + @Override | ||
| 67 | + public void afterConnectionEstablished(@NotNull WebSocketSession session) throws Exception { | ||
| 68 | + // do nothing | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + @Override | ||
| 72 | + public void handleTransportError(@NotNull WebSocketSession session, @NotNull Throwable e) throws Exception { | ||
| 73 | + log.error("[wss-{}] Transport error", this.account.getDisplay(), e); | ||
| 74 | + onFailure(CLOSE_CODE_EXCEPTION, "transport error"); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + @Override | ||
| 78 | + public void afterConnectionClosed(@NotNull WebSocketSession session, @NotNull CloseStatus closeStatus) throws Exception { | ||
| 79 | + onFailure(closeStatus.getCode(), closeStatus.getReason()); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + @Override | ||
| 83 | + public boolean supportsPartialMessages() { | ||
| 84 | + return true; | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + @Override | ||
| 88 | + public void handleMessage(@NotNull WebSocketSession session, WebSocketMessage<?> message) throws Exception { | ||
| 89 | + ByteBuffer buffer = (ByteBuffer) message.getPayload(); | ||
| 90 | + byte[] decompressed = decompressor.decompress(buffer.array()); | ||
| 91 | + if (decompressed == null) { | ||
| 92 | + return; | ||
| 93 | + } | ||
| 94 | + String json = new String(decompressed, StandardCharsets.UTF_8); | ||
| 95 | + DataObject data = DataObject.fromJson(json); | ||
| 96 | + int opCode = data.getInt("op"); | ||
| 97 | + switch (opCode) { | ||
| 98 | + case WebSocketCode.HEARTBEAT -> handleHeartbeat(session); | ||
| 99 | + case WebSocketCode.HEARTBEAT_ACK -> { | ||
| 100 | + this.heartbeatAck = true; | ||
| 101 | + clearHeartbeatTimeout(); | ||
| 102 | + } | ||
| 103 | + case WebSocketCode.HELLO -> { | ||
| 104 | + handleHello(session, data); | ||
| 105 | + doResumeOrIdentify(session); | ||
| 106 | + } | ||
| 107 | + case WebSocketCode.RESUME -> onSuccess(); | ||
| 108 | + case WebSocketCode.RECONNECT -> onFailure(CLOSE_CODE_RECONNECT, "receive server reconnect"); | ||
| 109 | + case WebSocketCode.INVALIDATE_SESSION -> onFailure(CLOSE_CODE_INVALIDATE, "receive session invalid"); | ||
| 110 | + case WebSocketCode.DISPATCH -> handleDispatch(data); | ||
| 111 | + default -> log.debug("[wss-{}] Receive unknown code: {}.", account.getDisplay(), data); | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + private void handleDispatch(DataObject raw) { | ||
| 116 | + this.sequence = raw.opt("s").orElse(null); | ||
| 117 | + if (!raw.isType("d", DataType.OBJECT)) { | ||
| 118 | + return; | ||
| 119 | + } | ||
| 120 | + DataObject content = raw.getObject("d"); | ||
| 121 | + String t = raw.getString("t", null); | ||
| 122 | + if ("READY".equals(t)) { | ||
| 123 | + this.sessionId = content.getString("session_id"); | ||
| 124 | + this.resumeGatewayUrl = content.getString("resume_gateway_url"); | ||
| 125 | + onSuccess(); | ||
| 126 | + } else if ("RESUMED".equals(t)) { | ||
| 127 | + onSuccess(); | ||
| 128 | + } else { | ||
| 129 | + try { | ||
| 130 | + this.userMessageListener.onMessage(raw); | ||
| 131 | + } catch (Exception e) { | ||
| 132 | + log.error("[wss-{}] Handle message error", this.account.getDisplay(), e); | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + private void handleHeartbeat(WebSocketSession session) { | ||
| 138 | + sendMessage(session, WebSocketCode.HEARTBEAT, this.sequence); | ||
| 139 | + this.heartbeatTimeout = ThreadUtil.execAsync(() -> { | ||
| 140 | + ThreadUtil.sleep(this.interval); | ||
| 141 | + onFailure(CLOSE_CODE_RECONNECT, "heartbeat has not ack"); | ||
| 142 | + }); | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + private void handleHello(WebSocketSession session, DataObject data) { | ||
| 146 | + clearHeartbeatInterval(); | ||
| 147 | + this.interval = data.getObject("d").getLong("heartbeat_interval"); | ||
| 148 | + this.heartbeatAck = true; | ||
| 149 | + this.heartbeatInterval = this.heartExecutor.scheduleAtFixedRate(() -> { | ||
| 150 | + if (this.heartbeatAck) { | ||
| 151 | + this.heartbeatAck = false; | ||
| 152 | + sendMessage(session, WebSocketCode.HEARTBEAT, this.sequence); | ||
| 153 | + } else { | ||
| 154 | + onFailure(CLOSE_CODE_RECONNECT, "heartbeat has not ack interval"); | ||
| 155 | + } | ||
| 156 | + }, (long) Math.floor(RandomUtil.randomDouble(0, 1) * this.interval), this.interval, TimeUnit.MILLISECONDS); | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + private void doResumeOrIdentify(WebSocketSession session) { | ||
| 160 | + if (CharSequenceUtil.isBlank(this.sessionId)) { | ||
| 161 | + sendMessage(session, WebSocketCode.IDENTIFY, this.authData); | ||
| 162 | + } else { | ||
| 163 | + var data = DataObject.empty().put("token", this.account.getUserToken()) | ||
| 164 | + .put("session_id", this.sessionId).put("seq", this.sequence); | ||
| 165 | + sendMessage(session, WebSocketCode.RESUME, data); | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + private void sendMessage(WebSocketSession session, int op, Object d) { | ||
| 170 | + var data = DataObject.empty().put("op", op).put("d", d); | ||
| 171 | + try { | ||
| 172 | + session.sendMessage(new TextMessage(data.toString())); | ||
| 173 | + } catch (IOException e) { | ||
| 174 | + log.error("[wss-{}] Send message error", this.account.getDisplay(), e); | ||
| 175 | + onFailure(CLOSE_CODE_EXCEPTION, "send message error"); | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + private void onSuccess() { | ||
| 180 | + ThreadUtil.execute(() -> this.successCallback.onSuccess(this.sessionId, this.sequence, this.resumeGatewayUrl)); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + private void onFailure(int code, String reason) { | ||
| 184 | + clearHeartbeatTimeout(); | ||
| 185 | + clearHeartbeatInterval(); | ||
| 186 | + ThreadUtil.execute(() -> this.failureCallback.onFailure(code, reason)); | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + private void clearHeartbeatTimeout() { | ||
| 190 | + if (this.heartbeatTimeout != null) { | ||
| 191 | + this.heartbeatTimeout.cancel(true); | ||
| 192 | + this.heartbeatTimeout = null; | ||
| 193 | + } | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + private void clearHeartbeatInterval() { | ||
| 197 | + if (this.heartbeatInterval != null) { | ||
| 198 | + this.heartbeatInterval.cancel(true); | ||
| 199 | + this.heartbeatInterval = null; | ||
| 200 | + } | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + private DataObject createAuthData() { | ||
| 204 | + UserAgent userAgent = UserAgentUtil.parse(this.account.getUserAgent()); | ||
| 205 | + DataObject connectionProperties = DataObject.empty() | ||
| 206 | + .put("browser", userAgent.getBrowser().getName()) | ||
| 207 | + .put("browser_user_agent", this.account.getUserAgent()) | ||
| 208 | + .put("browser_version", userAgent.getVersion()) | ||
| 209 | + .put("client_build_number", 222963) | ||
| 210 | + .put("client_event_source", null) | ||
| 211 | + .put("device", "") | ||
| 212 | + .put("os", userAgent.getOs().getName()) | ||
| 213 | + .put("referer", "https://www.midjourney.com") | ||
| 214 | + .put("referrer_current", "") | ||
| 215 | + .put("referring_domain", "www.midjourney.com") | ||
| 216 | + .put("referring_domain_current", "") | ||
| 217 | + .put("release_channel", "stable") | ||
| 218 | + .put("system_locale", "zh-CN"); | ||
| 219 | + DataObject presence = DataObject.empty() | ||
| 220 | + .put("activities", DataArray.empty()) | ||
| 221 | + .put("afk", false) | ||
| 222 | + .put("since", 0) | ||
| 223 | + .put("status", "online"); | ||
| 224 | + DataObject clientState = DataObject.empty() | ||
| 225 | + .put("api_code_version", 0) | ||
| 226 | + .put("guild_versions", DataObject.empty()) | ||
| 227 | + .put("highest_last_message_id", "0") | ||
| 228 | + .put("private_channels_version", "0") | ||
| 229 | + .put("read_state_version", 0) | ||
| 230 | + .put("user_guild_settings_version", -1) | ||
| 231 | + .put("user_settings_version", -1); | ||
| 232 | + return DataObject.empty() | ||
| 233 | + .put("capabilities", 16381) | ||
| 234 | + .put("client_state", clientState) | ||
| 235 | + .put("compress", false) | ||
| 236 | + .put("presence", presence) | ||
| 237 | + .put("properties", connectionProperties) | ||
| 238 | + .put("token", this.account.getUserToken()); | ||
| 239 | + } | ||
| 240 | +} |
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/user/SuccessCallback.java
0 → 100644
yudao-module-ai/yudao-spring-boot-starter-ai/src/main/java/cn/iocoder/yudao/framework/ai/midjourney/wss/user/UserMessageListener.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.ai.midjourney.wss.user; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +import cn.hutool.core.thread.ThreadUtil; | ||
| 5 | +import cn.iocoder.yudao.framework.ai.midjourney.wss.MessageType; | ||
| 6 | +import com.alibaba.fastjson.JSON; | ||
| 7 | +import lombok.extern.slf4j.Slf4j; | ||
| 8 | +import net.dv8tion.jda.api.utils.data.DataObject; | ||
| 9 | + | ||
| 10 | +@Slf4j | ||
| 11 | +public class UserMessageListener { | ||
| 12 | + | ||
| 13 | + public void onMessage(DataObject raw) { | ||
| 14 | + MessageType messageType = MessageType.of(raw.getString("t")); | ||
| 15 | + if (messageType == null || MessageType.DELETE == messageType) { | ||
| 16 | + return; | ||
| 17 | + } | ||
| 18 | + DataObject data = raw.getObject("d"); | ||
| 19 | + System.err.println(data); | ||
| 20 | + ThreadUtil.sleep(50); | ||
| 21 | +// for (MessageHandler messageHandler : this.messageHandlers) { | ||
| 22 | +// if (data.getBoolean(Constants.MJ_MESSAGE_HANDLED, false)) { | ||
| 23 | +// return; | ||
| 24 | +// } | ||
| 25 | +// messageHandler.handle(this.instance, messageType, data); | ||
| 26 | +// } | ||
| 27 | + } | ||
| 28 | +} |