From 4d39ed8053c984fdc9940f8730da49da40a20890 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Fri, 20 Mar 2026 17:53:29 -0400 Subject: [PATCH] feat: sending messages works --- Sources/DiscordKit/Bot.swift | 4 +- .../{ApiClient.swift => DiscordClient.swift} | 24 +- Sources/DiscordKit/GatewayClient.swift | 38 ++- Sources/DiscordKit/Models.swift | 71 ++++-- Sources/zundamon/Actions.swift | 6 +- Sources/zundamon/MessageHandler.swift | 233 +++++++++--------- Sources/zundamon/Wolfram.swift | 10 +- Sources/zundamon/Zundamon.swift | 11 +- 8 files changed, 240 insertions(+), 157 deletions(-) rename Sources/DiscordKit/{ApiClient.swift => DiscordClient.swift} (59%) diff --git a/Sources/DiscordKit/Bot.swift b/Sources/DiscordKit/Bot.swift index 9107849..0c14316 100644 --- a/Sources/DiscordKit/Bot.swift +++ b/Sources/DiscordKit/Bot.swift @@ -4,12 +4,12 @@ import FoundationNetworking #endif public actor Bot { - let client: ApiClient + public let client: DiscordClient let gateway: GatewayClient let intents: Intents public init(token: String, intents: Intents) async throws { - client = ApiClient(token: token) + client = DiscordClient(token: token) self.intents = intents let gatewayURL = try await client.getGatewayURL() gateway = GatewayClient(gatewayURL: gatewayURL, token: token) diff --git a/Sources/DiscordKit/ApiClient.swift b/Sources/DiscordKit/DiscordClient.swift similarity index 59% rename from Sources/DiscordKit/ApiClient.swift rename to Sources/DiscordKit/DiscordClient.swift index 7a0fe69..e7eccec 100644 --- a/Sources/DiscordKit/ApiClient.swift +++ b/Sources/DiscordKit/DiscordClient.swift @@ -3,7 +3,7 @@ import Foundation import FoundationNetworking #endif -struct ApiClient { +public struct DiscordClient: Sendable { static let apiUrl: URL = URL(string: "https://discord.com/api/v10")! let token: String @@ -19,7 +19,7 @@ struct ApiClient { } func getGatewayURL() async throws -> URL { - var req = URLRequest(url: ApiClient.apiUrl.appending(path: "/gateway/bot")) + var req = URLRequest(url: DiscordClient.apiUrl.appending(path: "/gateway/bot")) req.httpMethod = "GET" let (data, _) = try await authReq(req) @@ -27,6 +27,26 @@ struct ApiClient { let decoded = try json.decode(GetGatewayResponse.self, from: data) return decoded.url } + + public func getOwnUser() async throws -> User { + var req = URLRequest(url: DiscordClient.apiUrl.appending(path: "/users/@me")) + req.httpMethod = "GET" + let (data, _) = try await authReq(req) + + let json = JSONDecoder() + let decoded = try json.decode(User.self, from: data) + return decoded + } + + public func createMessage(channelId: String, payload: CreateMessageReq) async throws { + var req = URLRequest(url: DiscordClient.apiUrl.appending(path: "/channels/\(channelId)/messages")) + req.httpMethod = "POST" + let json = JSONEncoder() + + req.httpBody = try json.encode(payload) + let (data, res) = try await authReq(req) + + } } public enum ApiError: Error { diff --git a/Sources/DiscordKit/GatewayClient.swift b/Sources/DiscordKit/GatewayClient.swift index 50e1507..0354aa9 100644 --- a/Sources/DiscordKit/GatewayClient.swift +++ b/Sources/DiscordKit/GatewayClient.swift @@ -56,32 +56,44 @@ actor GatewayClient { } func getMessage() async throws -> GatewayMessage { - let wsMessage = try! await ws.receive() - print(wsMessage) - guard case .string(let str) = wsMessage else { throw GatewayError.invalidMessage } + var strBuffer = "" + var gwMessage: GatewayMessage? = nil let json = JSONDecoder() - let gwMessage = try json.decode(GatewayMessage.self, from: Data(str.utf8)) + while gwMessage == nil { + let wsMessage = try await ws.receive() + guard case .string(let str) = wsMessage else { throw GatewayError.invalidMessage } + strBuffer.append(str) + do { + gwMessage = try json.decode(GatewayMessage.self, from: Data(strBuffer.utf8)) + } catch DecodingError.dataCorrupted { + continue + } + } + guard let gwMessage = gwMessage else { throw GatewayError.invalidMessage } sequenceNum = gwMessage.s ?? sequenceNum return gwMessage } private func sendHeartbeat() async throws { let hbMessage = "{\"op\":1,\"d\":\(sequenceNum == nil ? "null" : String(sequenceNum!))}" - print(hbMessage) try await ws.send(.string(hbMessage)) } var events: AsyncStream { AsyncStream { [self] in - guard await open else { return nil } - do { - let event = try await getMessage() - if event.op == 1 { try await sendHeartbeat() } - return event - } catch { - print("Error listening to gateway: \(error)") - return nil + var event: GatewayMessage? = nil + while event == nil { + do { + while await !open { + try await Task.sleep(for: .seconds(1)) + } + event = try await getMessage() + if event!.op == 1 { try await sendHeartbeat() } + } catch { + print("Error listening to gateway: \(error)") + } } + return event! } } diff --git a/Sources/DiscordKit/Models.swift b/Sources/DiscordKit/Models.swift index 0bda4fc..08e551b 100644 --- a/Sources/DiscordKit/Models.swift +++ b/Sources/DiscordKit/Models.swift @@ -35,27 +35,28 @@ public struct Intents: OptionSet, Sendable { } public struct GetGatewayResponse: Codable, Sendable { - let url: URL - let shards: Int - let session_start_limit: SessionStartLimit + public let url: URL + public let shards: Int + public let session_start_limit: SessionStartLimit } public struct SessionStartLimit: Codable, Sendable { - let total: Int - let remaining: Int - let reset_after: Int - let max_concurrency: Int + public let total: Int + public let remaining: Int + public let reset_after: Int + public let max_concurrency: Int } public enum GatewayPayload: Decodable, Sendable { - case hello(HelloPayload) + case hello(GatewayHello) + case messageCreate(MessageCreate) } public struct GatewayMessage: Decodable, Sendable { - let op: Int - let d: GatewayPayload? - let s: Int? - let t: String? + public let op: Int + public let d: GatewayPayload? + public let s: Int? + public let t: String? enum CodingKeys: String, CodingKey { case t @@ -71,8 +72,11 @@ public struct GatewayMessage: Decodable, Sendable { t = try container.decode(String?.self, forKey: .t) switch op { case 10: - let hello = try container.decode(HelloPayload.self, forKey: .d) + let hello = try container.decode(GatewayHello.self, forKey: .d) d = .hello(hello) + case 0 where t == "MESSAGE_CREATE": + let messageCreate = try container.decode(MessageCreate.self, forKey: .d) + d = .messageCreate(messageCreate) default: d = nil break @@ -87,7 +91,46 @@ public struct GatewayMessage: Decodable, Sendable { } } -public struct HelloPayload: Codable, Sendable { +public struct GatewayHello: Codable, Sendable { let heartbeat_interval: Int } +public struct CreateMessageReq: Codable, Sendable { + public init(content: String? = nil, message_reference: MessageRefrence? = nil) { + self.content = content + self.message_reference = message_reference + } + + public let content: String? + public let message_reference: MessageRefrence? +} + +public struct MessageRefrence: Codable, Sendable { + public init(type: Int? = nil, message_id: String? = nil, channel_id: String? = nil, guild_id: String? = nil) { + self.type = type + self.message_id = message_id + self.channel_id = channel_id + self.guild_id = guild_id + } + + let type: Int? + let message_id: String? + let channel_id: String? + let guild_id: String? +} + +public struct MessageCreate: Codable, Sendable { + public let id: String + public let channel_id: String + public let guild_id: String? + public let author: User? + public let content: String + public let mentions: [User] +} + + +public struct User: Codable, Sendable { + public let id: String? + public let bot: Bool? +} + diff --git a/Sources/zundamon/Actions.swift b/Sources/zundamon/Actions.swift index 9d8ac04..1a09e17 100644 --- a/Sources/zundamon/Actions.swift +++ b/Sources/zundamon/Actions.swift @@ -1,11 +1,11 @@ -// import DiscordBM +// import DiscordKit // // struct Actions { // static func getUserFromMention(_ mention: MentionUser) -> String { // mention.member?.nick ?? mention.global_name ?? mention.username // } // -// static func performAction(ctx: Gateway.MessageCreate, client: DiscordClient, resOpts: [String]) async throws { +// static func performAction(ctx: MessageCreate, client: DiscordClient, resOpts: [String]) async throws { // let author = "**\(ctx.member?.nick ?? ctx.author?.global_name ?? ctx.author?.username ?? "Zundamon")**" // let dests = ctx.mentions.map(getUserFromMention).map({ "**\($0)**" }) // let orig: String @@ -99,7 +99,7 @@ // static func pat( // _ args: ArraySlice, // client: DiscordClient, -// ctx: Gateway.MessageCreate +// ctx: MessageCreate // ) async throws { // guard let patRes = patRes else { print("pat.txt not loaded"); return } // try await performAction(ctx: ctx, client: client, resOpts: patRes) diff --git a/Sources/zundamon/MessageHandler.swift b/Sources/zundamon/MessageHandler.swift index 0091efd..1f3a54c 100644 --- a/Sources/zundamon/MessageHandler.swift +++ b/Sources/zundamon/MessageHandler.swift @@ -1,116 +1,117 @@ -// import Foundation -// #if canImport(FoundationNetworking) -// import FoundationNetworking -// #endif -// import DiscordBM -// -// struct MessageHandler { -// let ctx: Gateway.MessageCreate -// let client: any DiscordClient -// -// static let prefix = ":" -// static let zundaGifData = try? Data(contentsOf: URL(filePath: "resources/media/zundamone.gif")) -// -// func handle() async throws { -// guard !(ctx.author?.bot ?? false) else { return } -// if (ctx.content.hasPrefix(MessageHandler.prefix)) { -// let split = ctx.content.split(separator: " ") -// let command = split.first?.trimmingPrefix(MessageHandler.prefix) -// let args = split[1...] -// -// switch command { -// case "wow": try await handleWow(args) -// case "domath": try await Wolfram.handleMath(args, client: client, ctx: ctx) -// case "hug": try await Actions.hug(args, client: client, ctx: ctx) -// case "pat": try await Actions.pat(args, client: client, ctx: ctx) -// case "pet": try await Actions.pat(args, client: client, ctx: ctx) -// default: break -// } -// } else if ctx.mentions.contains(where: { $0.id == Zundamon.ownID }) { -// if ctx.content -// .replacingOccurrences(of: "<@\(Zundamon.ownID!.rawValue)>", with: "") -// .trimmingCharacters(in: .whitespacesAndNewlines) -// .count == 0, -// let zundaGif = MessageHandler.zundaGifData -// { -// try await client.createMessage( -// channelId: ctx.channel_id, -// payload: .init( -// message_reference: .init( -// type: .default, -// message_id: ctx.id, -// channel_id: ctx.channel_id, -// guild_id: ctx.guild_id, -// ), -// files: [.init(data: .init(data: zundaGif), filename: "zundamone.gif")], -// attachments: [.init(index: 0, filename: "zundamone.gif")], -// ) -// ).guardSuccess() -// } else { -// try await handle8Ball() -// } -// } -// } -// -// static let ballResponses = [ -// "It is certain.", -// "It is decidedly so.", -// "Without a doubt.", -// "Yes – definitely.", -// "You may rely on it.", -// "As I see it, yes.", -// "Most likely.", -// "Outlook good.", -// "Yes.", -// "Signs point to yes.", -// "Reply hazy, try again.", -// "Ask again later.", -// "Better not tell you now.", -// "Cannot predict now.", -// "Concentrate and ask again.", -// "Don’t count on it.", -// "My reply is no.", -// "My sources say no.", -// "Outlook not so good.", -// "Very doubtful.", -// "Ui beam", -// "We are Shigure Ui", -// "We are Shigure Ux", -// ] -// func handle8Ball() async throws { -// try await client.createMessage( -// channelId: ctx.channel_id, -// payload: .init( -// content: MessageHandler.ballResponses.randomElement(), -// message_reference: .init( -// type: .default, -// message_id: ctx.id, -// channel_id: ctx.channel_id, -// guild_id: ctx.guild_id, -// ), -// ) -// ).guardSuccess() -// } -// -// static let wows = [ -// "<:wow:1477062414913634334>", -// "<:wow2:1477062432357875948>", -// "<:wow4:1477062471746588713>", -// "<:wow5:1477062452804849845>" -// ] -// func handleWow(_ args: ArraySlice) async throws { -// try await client.createMessage( -// channelId: ctx.channel_id, -// payload: .init( -// content: MessageHandler.wows.randomElement(), -// message_reference: .init( -// type: .default, -// message_id: ctx.id, -// channel_id: ctx.channel_id, -// guild_id: ctx.guild_id, -// ), -// ) -// ).guardSuccess() -// } -// -// } +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import DiscordKit + +struct MessageHandler { + let ctx: MessageCreate + let client: DiscordClient + + static let prefix = ":" + static let zundaGifData = try? Data(contentsOf: URL(filePath: "resources/media/zundamone.gif")) + + func handle() async throws { + guard !(ctx.author?.bot ?? false) else { return } + if (ctx.content.hasPrefix(MessageHandler.prefix)) { + let split = ctx.content.split(separator: " ") + let command = split.first?.trimmingPrefix(MessageHandler.prefix) + let args = split[1...] + + switch command { + case "wow": try await handleWow(args) + // case "domath": try await Wolfram.handleMath(args, client: client, ctx: ctx) + // case "hug": try await Actions.hug(args, client: client, ctx: ctx) + // case "pat": try await Actions.pat(args, client: client, ctx: ctx) + // case "pet": try await Actions.pat(args, client: client, ctx: ctx) + default: break + } + } else if ctx.mentions.contains(where: { $0.id == Zundamon.ownID }) { + if ctx.content + .replacingOccurrences(of: "<@\(Zundamon.ownID!)>", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .count == 0, + let zundaGif = MessageHandler.zundaGifData + { + // try await client.createMessage( + // channelId: ctx.channel_id, + // payload: .init( + // message_reference: .init( + // type: .default, + // message_id: ctx.id, + // channel_id: ctx.channel_id, + // guild_id: ctx.guild_id, + // ), + // files: [.init(data: .init(data: zundaGif), filename: "zundamone.gif")], + // attachments: [.init(index: 0, filename: "zundamone.gif")], + // ) + // ).guardSuccess() + } else { + try await handle8Ball() + } + } + } + + static let ballResponses = [ + "It is certain.", + "It is decidedly so.", + "Without a doubt.", + "Yes – definitely.", + "You may rely on it.", + "As I see it, yes.", + "Most likely.", + "Outlook good.", + "Yes.", + "Signs point to yes.", + "Reply hazy, try again.", + "Ask again later.", + "Better not tell you now.", + "Cannot predict now.", + "Concentrate and ask again.", + "Don’t count on it.", + "My reply is no.", + "My sources say no.", + "Outlook not so good.", + "Very doubtful.", + "Ui beam", + "We are Shigure Ui", + "We are Shigure Ux", + ] + func handle8Ball() async throws { + print("yo wuddup") + try await client.createMessage( + channelId: ctx.channel_id, + payload: .init( + content: MessageHandler.ballResponses.randomElement(), + message_reference: .init( + type: 0, + message_id: ctx.id, + channel_id: ctx.channel_id, + guild_id: ctx.guild_id, + ), + ) + ) + } + + static let wows = [ + "<:wow:1477062414913634334>", + "<:wow2:1477062432357875948>", + "<:wow4:1477062471746588713>", + "<:wow5:1477062452804849845>" + ] + func handleWow(_ args: ArraySlice) async throws { + try await client.createMessage( + channelId: ctx.channel_id, + payload: .init( + content: MessageHandler.wows.randomElement(), + message_reference: .init( + type: 0, + message_id: ctx.id, + channel_id: ctx.channel_id, + guild_id: ctx.guild_id, + ), + ) + ) + } + +} diff --git a/Sources/zundamon/Wolfram.swift b/Sources/zundamon/Wolfram.swift index 560b1f6..0b12cbf 100644 --- a/Sources/zundamon/Wolfram.swift +++ b/Sources/zundamon/Wolfram.swift @@ -2,8 +2,8 @@ // #if canImport(FoundationNetworking) // import FoundationNetworking // #endif +// import DiscordKit // import XMLCoder -// import DiscordBM // // struct Wolfram { // static let token = ProcessInfo.processInfo.environment["WOLFRAM_APP_ID"]! @@ -22,7 +22,7 @@ // var img: URL? // // if let resultPod = resultPod { -// ans = String(resultPod.subpod.compactMap(\.plaintext).joined(by: "\n")) +// ans = String(resultPod.subpod.compactMap(\.plaintext).joined(separator: "\n")) // .replacingOccurrences(of: " | ", with: ": ") // } // if let imgPod = wolframRes.pod.first(where: { @@ -46,7 +46,7 @@ // static func handleMath( // _ args: ArraySlice, // client: DiscordClient, -// ctx: Gateway.MessageCreate +// ctx: MessageCreate // ) async throws { // try await client.triggerTypingIndicator(channelId: ctx.channel_id).guardSuccess() // @@ -68,14 +68,14 @@ // try await client.createMessage(channelId: ctx.channel_id, payload: .init( // content: answer, // message_reference: .init( -// type: .default, +// type: 0, // message_id: ctx.id, // channel_id: ctx.channel_id, // guild_id: ctx.guild_id, // ), // files: files, // attachments: attachments -// )).guardSuccess() +// )) // } // } // diff --git a/Sources/zundamon/Zundamon.swift b/Sources/zundamon/Zundamon.swift index a8e50b8..af3ef40 100644 --- a/Sources/zundamon/Zundamon.swift +++ b/Sources/zundamon/Zundamon.swift @@ -4,7 +4,7 @@ import SwiftDotenv @main struct Zundamon { - //nonisolated(unsafe) static private(set) var ownID: UserSnowflake? = nil + nonisolated(unsafe) static private(set) var ownID: String? = nil static func main() async throws { if case .failure = Result(catching: { try Dotenv.configure() }) { @@ -16,9 +16,10 @@ struct Zundamon { guard !token.isEmpty else { fatalError("Err: Empty DISCORD_TOKEN. Exiting...") } let intents: Intents = [.guilds, .guildMessages, .messageContent, .guildMembers, .directMessages] - print(intents) let bot = try await DiscordKit.Bot(token: token, intents: intents) + ownID = try await bot.client.getOwnUser().id + guard ownID != nil else { fatalError("Failed to get own User ID") } // let bot = await BotGatewayManager(token: token, intents: [.guildMessages, .messageContent]) @@ -30,6 +31,12 @@ struct Zundamon { taskGroup.addTask { for await event in await bot.events { dump(event) + switch event.d { + case .messageCreate(let event): + try await MessageHandler(ctx: event, client: bot.client).handle() + default: + continue + } } } }