feat: sending messages works

This commit is contained in:
Andrew Glaze
2026-03-20 17:53:29 -04:00
parent df3cb95c91
commit 4d39ed8053
8 changed files with 240 additions and 157 deletions

View File

@@ -4,12 +4,12 @@ import FoundationNetworking
#endif #endif
public actor Bot { public actor Bot {
let client: ApiClient public let client: DiscordClient
let gateway: GatewayClient let gateway: GatewayClient
let intents: Intents let intents: Intents
public init(token: String, intents: Intents) async throws { public init(token: String, intents: Intents) async throws {
client = ApiClient(token: token) client = DiscordClient(token: token)
self.intents = intents self.intents = intents
let gatewayURL = try await client.getGatewayURL() let gatewayURL = try await client.getGatewayURL()
gateway = GatewayClient(gatewayURL: gatewayURL, token: token) gateway = GatewayClient(gatewayURL: gatewayURL, token: token)

View File

@@ -3,7 +3,7 @@ import Foundation
import FoundationNetworking import FoundationNetworking
#endif #endif
struct ApiClient { public struct DiscordClient: Sendable {
static let apiUrl: URL = URL(string: "https://discord.com/api/v10")! static let apiUrl: URL = URL(string: "https://discord.com/api/v10")!
let token: String let token: String
@@ -19,7 +19,7 @@ struct ApiClient {
} }
func getGatewayURL() async throws -> URL { 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" req.httpMethod = "GET"
let (data, _) = try await authReq(req) let (data, _) = try await authReq(req)
@@ -27,6 +27,26 @@ struct ApiClient {
let decoded = try json.decode(GetGatewayResponse.self, from: data) let decoded = try json.decode(GetGatewayResponse.self, from: data)
return decoded.url 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 { public enum ApiError: Error {

View File

@@ -56,33 +56,45 @@ actor GatewayClient {
} }
func getMessage() async throws -> GatewayMessage { func getMessage() async throws -> GatewayMessage {
let wsMessage = try! await ws.receive() var strBuffer = ""
print(wsMessage) var gwMessage: GatewayMessage? = nil
guard case .string(let str) = wsMessage else { throw GatewayError.invalidMessage }
let json = JSONDecoder() 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 sequenceNum = gwMessage.s ?? sequenceNum
return gwMessage return gwMessage
} }
private func sendHeartbeat() async throws { private func sendHeartbeat() async throws {
let hbMessage = "{\"op\":1,\"d\":\(sequenceNum == nil ? "null" : String(sequenceNum!))}" let hbMessage = "{\"op\":1,\"d\":\(sequenceNum == nil ? "null" : String(sequenceNum!))}"
print(hbMessage)
try await ws.send(.string(hbMessage)) try await ws.send(.string(hbMessage))
} }
var events: AsyncStream<GatewayMessage> { var events: AsyncStream<GatewayMessage> {
AsyncStream { [self] in AsyncStream { [self] in
guard await open else { return nil } var event: GatewayMessage? = nil
while event == nil {
do { do {
let event = try await getMessage() while await !open {
if event.op == 1 { try await sendHeartbeat() } try await Task.sleep(for: .seconds(1))
return event }
event = try await getMessage()
if event!.op == 1 { try await sendHeartbeat() }
} catch { } catch {
print("Error listening to gateway: \(error)") print("Error listening to gateway: \(error)")
return nil
} }
} }
return event!
}
} }
} }

View File

@@ -35,27 +35,28 @@ public struct Intents: OptionSet, Sendable {
} }
public struct GetGatewayResponse: Codable, Sendable { public struct GetGatewayResponse: Codable, Sendable {
let url: URL public let url: URL
let shards: Int public let shards: Int
let session_start_limit: SessionStartLimit public let session_start_limit: SessionStartLimit
} }
public struct SessionStartLimit: Codable, Sendable { public struct SessionStartLimit: Codable, Sendable {
let total: Int public let total: Int
let remaining: Int public let remaining: Int
let reset_after: Int public let reset_after: Int
let max_concurrency: Int public let max_concurrency: Int
} }
public enum GatewayPayload: Decodable, Sendable { public enum GatewayPayload: Decodable, Sendable {
case hello(HelloPayload) case hello(GatewayHello)
case messageCreate(MessageCreate)
} }
public struct GatewayMessage: Decodable, Sendable { public struct GatewayMessage: Decodable, Sendable {
let op: Int public let op: Int
let d: GatewayPayload? public let d: GatewayPayload?
let s: Int? public let s: Int?
let t: String? public let t: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case t case t
@@ -71,8 +72,11 @@ public struct GatewayMessage: Decodable, Sendable {
t = try container.decode(String?.self, forKey: .t) t = try container.decode(String?.self, forKey: .t)
switch op { switch op {
case 10: case 10:
let hello = try container.decode(HelloPayload.self, forKey: .d) let hello = try container.decode(GatewayHello.self, forKey: .d)
d = .hello(hello) d = .hello(hello)
case 0 where t == "MESSAGE_CREATE":
let messageCreate = try container.decode(MessageCreate.self, forKey: .d)
d = .messageCreate(messageCreate)
default: default:
d = nil d = nil
break break
@@ -87,7 +91,46 @@ public struct GatewayMessage: Decodable, Sendable {
} }
} }
public struct HelloPayload: Codable, Sendable { public struct GatewayHello: Codable, Sendable {
let heartbeat_interval: Int 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?
}

View File

@@ -1,11 +1,11 @@
// import DiscordBM // import DiscordKit
// //
// struct Actions { // struct Actions {
// static func getUserFromMention(_ mention: MentionUser) -> String { // static func getUserFromMention(_ mention: MentionUser) -> String {
// mention.member?.nick ?? mention.global_name ?? mention.username // 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 author = "**\(ctx.member?.nick ?? ctx.author?.global_name ?? ctx.author?.username ?? "Zundamon")**"
// let dests = ctx.mentions.map(getUserFromMention).map({ "**\($0)**" }) // let dests = ctx.mentions.map(getUserFromMention).map({ "**\($0)**" })
// let orig: String // let orig: String
@@ -99,7 +99,7 @@
// static func pat( // static func pat(
// _ args: ArraySlice<String.SubSequence>, // _ args: ArraySlice<String.SubSequence>,
// client: DiscordClient, // client: DiscordClient,
// ctx: Gateway.MessageCreate // ctx: MessageCreate
// ) async throws { // ) async throws {
// guard let patRes = patRes else { print("pat.txt not loaded"); return } // guard let patRes = patRes else { print("pat.txt not loaded"); return }
// try await performAction(ctx: ctx, client: client, resOpts: patRes) // try await performAction(ctx: ctx, client: client, resOpts: patRes)

View File

@@ -1,38 +1,38 @@
// import Foundation import Foundation
// #if canImport(FoundationNetworking) #if canImport(FoundationNetworking)
// import FoundationNetworking import FoundationNetworking
// #endif #endif
// import DiscordBM import DiscordKit
//
// struct MessageHandler { struct MessageHandler {
// let ctx: Gateway.MessageCreate let ctx: MessageCreate
// let client: any DiscordClient let client: DiscordClient
//
// static let prefix = ":" static let prefix = ":"
// static let zundaGifData = try? Data(contentsOf: URL(filePath: "resources/media/zundamone.gif")) static let zundaGifData = try? Data(contentsOf: URL(filePath: "resources/media/zundamone.gif"))
//
// func handle() async throws { func handle() async throws {
// guard !(ctx.author?.bot ?? false) else { return } guard !(ctx.author?.bot ?? false) else { return }
// if (ctx.content.hasPrefix(MessageHandler.prefix)) { if (ctx.content.hasPrefix(MessageHandler.prefix)) {
// let split = ctx.content.split(separator: " ") let split = ctx.content.split(separator: " ")
// let command = split.first?.trimmingPrefix(MessageHandler.prefix) let command = split.first?.trimmingPrefix(MessageHandler.prefix)
// let args = split[1...] let args = split[1...]
//
// switch command { switch command {
// case "wow": try await handleWow(args) case "wow": try await handleWow(args)
// case "domath": try await Wolfram.handleMath(args, client: client, ctx: ctx) // case "domath": try await Wolfram.handleMath(args, client: client, ctx: ctx)
// case "hug": try await Actions.hug(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 "pat": try await Actions.pat(args, client: client, ctx: ctx)
// case "pet": try await Actions.pat(args, client: client, ctx: ctx) // case "pet": try await Actions.pat(args, client: client, ctx: ctx)
// default: break default: break
// } }
// } else if ctx.mentions.contains(where: { $0.id == Zundamon.ownID }) { } else if ctx.mentions.contains(where: { $0.id == Zundamon.ownID }) {
// if ctx.content if ctx.content
// .replacingOccurrences(of: "<@\(Zundamon.ownID!.rawValue)>", with: "") .replacingOccurrences(of: "<@\(Zundamon.ownID!)>", with: "")
// .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
// .count == 0, .count == 0,
// let zundaGif = MessageHandler.zundaGifData let zundaGif = MessageHandler.zundaGifData
// { {
// try await client.createMessage( // try await client.createMessage(
// channelId: ctx.channel_id, // channelId: ctx.channel_id,
// payload: .init( // payload: .init(
@@ -46,71 +46,72 @@
// attachments: [.init(index: 0, filename: "zundamone.gif")], // attachments: [.init(index: 0, filename: "zundamone.gif")],
// ) // )
// ).guardSuccess() // ).guardSuccess()
// } else { } else {
// try await handle8Ball() try await handle8Ball()
// } }
// } }
// } }
//
// static let ballResponses = [ static let ballResponses = [
// "It is certain.", "It is certain.",
// "It is decidedly so.", "It is decidedly so.",
// "Without a doubt.", "Without a doubt.",
// "Yes definitely.", "Yes definitely.",
// "You may rely on it.", "You may rely on it.",
// "As I see it, yes.", "As I see it, yes.",
// "Most likely.", "Most likely.",
// "Outlook good.", "Outlook good.",
// "Yes.", "Yes.",
// "Signs point to yes.", "Signs point to yes.",
// "Reply hazy, try again.", "Reply hazy, try again.",
// "Ask again later.", "Ask again later.",
// "Better not tell you now.", "Better not tell you now.",
// "Cannot predict now.", "Cannot predict now.",
// "Concentrate and ask again.", "Concentrate and ask again.",
// "Dont count on it.", "Dont count on it.",
// "My reply is no.", "My reply is no.",
// "My sources say no.", "My sources say no.",
// "Outlook not so good.", "Outlook not so good.",
// "Very doubtful.", "Very doubtful.",
// "Ui beam", "Ui beam",
// "We are Shigure Ui", "We are Shigure Ui",
// "We are Shigure Ux", "We are Shigure Ux",
// ] ]
// func handle8Ball() async throws { func handle8Ball() async throws {
// try await client.createMessage( print("yo wuddup")
// channelId: ctx.channel_id, try await client.createMessage(
// payload: .init( channelId: ctx.channel_id,
// content: MessageHandler.ballResponses.randomElement(), payload: .init(
// message_reference: .init( content: MessageHandler.ballResponses.randomElement(),
// type: .default, message_reference: .init(
// message_id: ctx.id, type: 0,
// channel_id: ctx.channel_id, message_id: ctx.id,
// guild_id: ctx.guild_id, channel_id: ctx.channel_id,
// ), guild_id: ctx.guild_id,
// ) ),
// ).guardSuccess() )
// } )
// }
// static let wows = [
// "<:wow:1477062414913634334>", static let wows = [
// "<:wow2:1477062432357875948>", "<:wow:1477062414913634334>",
// "<:wow4:1477062471746588713>", "<:wow2:1477062432357875948>",
// "<:wow5:1477062452804849845>" "<:wow4:1477062471746588713>",
// ] "<:wow5:1477062452804849845>"
// func handleWow(_ args: ArraySlice<String.SubSequence>) async throws { ]
// try await client.createMessage( func handleWow(_ args: ArraySlice<String.SubSequence>) async throws {
// channelId: ctx.channel_id, try await client.createMessage(
// payload: .init( channelId: ctx.channel_id,
// content: MessageHandler.wows.randomElement(), payload: .init(
// message_reference: .init( content: MessageHandler.wows.randomElement(),
// type: .default, message_reference: .init(
// message_id: ctx.id, type: 0,
// channel_id: ctx.channel_id, message_id: ctx.id,
// guild_id: ctx.guild_id, channel_id: ctx.channel_id,
// ), guild_id: ctx.guild_id,
// ) ),
// ).guardSuccess() )
// } )
// }
// }
}

View File

@@ -2,8 +2,8 @@
// #if canImport(FoundationNetworking) // #if canImport(FoundationNetworking)
// import FoundationNetworking // import FoundationNetworking
// #endif // #endif
// import DiscordKit
// import XMLCoder // import XMLCoder
// import DiscordBM
// //
// struct Wolfram { // struct Wolfram {
// static let token = ProcessInfo.processInfo.environment["WOLFRAM_APP_ID"]! // static let token = ProcessInfo.processInfo.environment["WOLFRAM_APP_ID"]!
@@ -22,7 +22,7 @@
// var img: URL? // var img: URL?
// //
// if let resultPod = resultPod { // 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: ": ") // .replacingOccurrences(of: " | ", with: ": ")
// } // }
// if let imgPod = wolframRes.pod.first(where: { // if let imgPod = wolframRes.pod.first(where: {
@@ -46,7 +46,7 @@
// static func handleMath( // static func handleMath(
// _ args: ArraySlice<String.SubSequence>, // _ args: ArraySlice<String.SubSequence>,
// client: DiscordClient, // client: DiscordClient,
// ctx: Gateway.MessageCreate // ctx: MessageCreate
// ) async throws { // ) async throws {
// try await client.triggerTypingIndicator(channelId: ctx.channel_id).guardSuccess() // try await client.triggerTypingIndicator(channelId: ctx.channel_id).guardSuccess()
// //
@@ -68,14 +68,14 @@
// try await client.createMessage(channelId: ctx.channel_id, payload: .init( // try await client.createMessage(channelId: ctx.channel_id, payload: .init(
// content: answer, // content: answer,
// message_reference: .init( // message_reference: .init(
// type: .default, // type: 0,
// message_id: ctx.id, // message_id: ctx.id,
// channel_id: ctx.channel_id, // channel_id: ctx.channel_id,
// guild_id: ctx.guild_id, // guild_id: ctx.guild_id,
// ), // ),
// files: files, // files: files,
// attachments: attachments // attachments: attachments
// )).guardSuccess() // ))
// } // }
// } // }
// //

View File

@@ -4,7 +4,7 @@ import SwiftDotenv
@main @main
struct Zundamon { 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 { static func main() async throws {
if case .failure = Result(catching: { try Dotenv.configure() }) { if case .failure = Result(catching: { try Dotenv.configure() }) {
@@ -16,9 +16,10 @@ struct Zundamon {
guard !token.isEmpty else { fatalError("Err: Empty DISCORD_TOKEN. Exiting...") } guard !token.isEmpty else { fatalError("Err: Empty DISCORD_TOKEN. Exiting...") }
let intents: Intents = [.guilds, .guildMessages, .messageContent, .guildMembers, .directMessages] let intents: Intents = [.guilds, .guildMessages, .messageContent, .guildMembers, .directMessages]
print(intents)
let bot = try await DiscordKit.Bot(token: token, intents: 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]) // let bot = await BotGatewayManager(token: token, intents: [.guildMessages, .messageContent])
@@ -30,6 +31,12 @@ struct Zundamon {
taskGroup.addTask { taskGroup.addTask {
for await event in await bot.events { for await event in await bot.events {
dump(event) dump(event)
switch event.d {
case .messageCreate(let event):
try await MessageHandler(ctx: event, client: bot.client).handle()
default:
continue
}
} }
} }
} }