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 | 4 | * 文档: https://www.xiubbs.com/t-401-1-1.html |
| 5 | 5 | * |
| 6 | 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 | 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 | 2 | "type": 2, |
| 3 | 3 | "application_id": "936929561302675456", |
| 4 | - "guild_id": "1224337694918971392", | |
| 5 | - "channel_id": "1224337694918971396", | |
| 4 | + "guild_id": "1221445697157468200", | |
| 5 | + "channel_id": "1221445862962630706", | |
| 6 | 6 | "session_id": "696318caed5180a2210e358e44801449", |
| 7 | 7 | "data": { |
| 8 | 8 | "version": "1166847114203123795", |
| ... | ... | @@ -13,7 +13,7 @@ |
| 13 | 13 | { |
| 14 | 14 | "type": 3, |
| 15 | 15 | "name": "prompt", |
| 16 | - "value": "中国的是什么样子" | |
| 16 | + "value": "童话世界应该是什么样?" | |
| 17 | 17 | } |
| 18 | 18 | ], |
| 19 | 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 | 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 | 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 | +} | ... | ... |