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
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)

View File

@@ -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 {

View File

@@ -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<GatewayMessage> {
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!
}
}

View File

@@ -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?
}

View File

@@ -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<String.SubSequence>,
// 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)

View File

@@ -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.",
// "Dont 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<String.SubSequence>) 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.",
"Dont 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<String.SubSequence>) 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,
),
)
)
}
}

View File

@@ -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<String.SubSequence>,
// 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()
// ))
// }
// }
//

View File

@@ -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
}
}
}
}