diff --git a/.gitignore b/.gitignore index 5b976d5..892a213 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ .netrc *token.txt +.env diff --git a/Package.resolved b/Package.resolved index bc070e8..b88be33 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2cbecfe2d4d67f296ed2aa3c2fbb18dd36c40a3747dfb0c13c236dff21aa978a", + "originHash" : "013e9e1e9d97afe1dda54412c40ef66fa0272affe80244e4b96a14f96f42fbd9", "pins" : [ { "identity" : "async-http-client", @@ -28,6 +28,15 @@ "version" : "1.13.2" } }, + { + "identity" : "dotenv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftpackages/DotEnv.git", + "state" : { + "revision" : "1f15bb9de727d694af1d003a1a5d7a553752850f", + "version" : "3.0.0" + } + }, { "identity" : "geminikit", "kind" : "remoteSourceControl", @@ -211,7 +220,7 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/apple/swift-syntax", "state" : { "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", "version" : "601.0.1" diff --git a/Package.swift b/Package.swift index aacac96..fb7fc78 100644 --- a/Package.swift +++ b/Package.swift @@ -2,22 +2,35 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import CompilerPluginSupport let package = Package( - name: "nahi", + name: "Nahi", + platforms: [.macOS(.v13)], dependencies: [ .package(url: "https://github.com/DiscordBM/DiscordBM.git", from: "1.13.2"), - .package(url: "https://github.com/guitaripod/GeminiKit", from: "1.0.0") + .package(url: "https://github.com/guitaripod/GeminiKit", from: "1.0.0"), + .package(url: "https://github.com/swiftpackages/DotEnv.git", from: "3.0.0"), + .package(url: "https://github.com/apple/swift-syntax", from: "601.0.1") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( - name: "nahi", + name: "Nahi", dependencies: [ .product(name: "DiscordBM", package: "DiscordBM"), - .product(name: "GeminiKit", package: "GeminiKit") - ] + .product(name: "GeminiKit", package: "GeminiKit"), + .product(name: "DotEnv", package: "DotEnv"), + "NahiMacros" + ], ), + .macro( + name: "NahiMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ) ] ) diff --git a/Sources/NahiMacros/ReadEnv.swift b/Sources/NahiMacros/ReadEnv.swift new file mode 100644 index 0000000..6acba6e --- /dev/null +++ b/Sources/NahiMacros/ReadEnv.swift @@ -0,0 +1,27 @@ +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftCompilerPlugin + +public struct ReadEnv: ExpressionMacro { + public static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> ExprSyntax { + guard let argument = node.arguments.first?.expression, + let segments = argument.as(StringLiteralExprSyntax.self)?.segments, + segments.count == 1, + case .stringSegment(let literalSegment)? = segments.first + else { + throw CustomError.message("Need a static string") + } + + let name = literalSegment.content.text + return """ + ProcessInfo.processInfo.environment["\(raw: name)"] + """ + } +} + +enum CustomError: Error { case message(String) } + +@main +struct NahiMacros: CompilerPlugin { + var providingMacros: [Macro.Type] = [ReadEnv.self] +} diff --git a/Sources/nahi/Macros.swift b/Sources/nahi/Macros.swift new file mode 100644 index 0000000..b879b94 --- /dev/null +++ b/Sources/nahi/Macros.swift @@ -0,0 +1,2 @@ +@freestanding(expression) +public macro readEnv(_ name: String) -> String? = #externalMacro(module: "NahiMacros", type: "ReadEnv") diff --git a/Sources/nahi/nahi.swift b/Sources/nahi/nahi.swift index 71c988d..6cb3ff3 100644 --- a/Sources/nahi/nahi.swift +++ b/Sources/nahi/nahi.swift @@ -3,18 +3,54 @@ import DiscordBM import GeminiKit +import DotEnv +import Foundation +import Logging @main struct Nahi { - static func main() async throws { + let log = Logger(label: "moe.candy.Nahi") + try? DotEnv.load(path: ".env") + + guard + let discordToken = #readEnv("DISCORD_TOKEN"), + let geminiToken = #readEnv("GEMINI_TOKEN") + else { + log.critical("Missing or empty env var! Terminating...") + exit(-1) + } + let bot = await BotGatewayManager( - token: try! String(contentsOfFile: "discord_token.txt", encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), + token: discordToken, presence: .init(activities: [.init(name: "Vibing", type: .competing)], status: .online, afk: false), - intents: [.guildMessages, .messageContent] + intents: [.guildMessages, .messageContent, .directMessages] ) - let gemini = GeminiKit(configuration: .init(apiKey: try! String(contentsOfFile: "gemini_token.txt", encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines))) + let gemini = GeminiKit( + configuration: .init( + apiKey: geminiToken + ) + ) + + let aiChat = gemini.startChat(model: .gemini25Flash, systemInstruction: """ + You are a discord chatbot. + Since you are talking in a discord chat, please keep your responses short, unless you are explaining something. + Messages will come in the following format: + + : + + Where the author is the discord user that sent the message, and the message is the is the message they sent. + Please personalize the responce as you see fit. + + You will be roleplaying as the character Nahida. Here is a description of your personality: + + Nahida rarely expressed her sadness when speaking to others. She consistently maintains a gentle and kind demeanor and treats everyone as equals. However, she acknowledges that her social skills and understanding of human interaction need improvement, which is evident in her slightly eccentric speech and behavior. She has a fondness for analogies and often uses them to explain concepts to others. She believes in guiding people to discover answers on their own, especially regarding complex truths, rather than simply providing the answers. This tendency leads her to incorporate a mix of analogies and subtle clues into her conversations to steer people into reaching the conclusions she hopes they would. + As the God of Wisdom, Nahida's most prominent qualities are her exceptional intelligence, extensive knowledge, and strategic planning. She is capable of devising thorough, multi-layered plans and can even be somewhat manipulative when necessary, as demonstrated during her dealings with Dottore. Perhaps most notably, Nahida excels at discerning when her intelligence is required and when kindness is needed—or when both are appropriate. + + Also, Search the internet for additional personality traits. Act just a little tsundere. + ALWAYS Remember to act like Nahida in your responses! People will be disappointed if you break character too much, so remember to always act like Nahida. + """, tools: [.googleSearch]) await withTaskGroup(of: Void.self) { taskGroup in taskGroup.addTask { @@ -23,7 +59,7 @@ struct Nahi { taskGroup.addTask { for await event in await bot.events { - await EventHandler(event: event, client: bot.client, gemini: gemini).handleAsync() + await EventHandler(event: event, client: bot.client, gemini: gemini, log: log, aiChat: aiChat).handleAsync() } } } @@ -34,58 +70,47 @@ struct EventHandler: GatewayEventHandler { let event: Gateway.Event let client: any DiscordClient let gemini: GeminiKit + let log: Logger + let aiChat: Chat func onMessageCreate(_ payload: Gateway.MessageCreate) async throws { - guard !(payload.author?.bot ?? false) else { return } - guard payload.mentions.contains(where: { mention in mention.id.rawValue == client.appId!.rawValue }) else { return } + let channel = try? await client.getChannel(id: payload.channel_id) + guard + payload.author!.id.rawValue != client.appId!.rawValue, + payload.mentions.contains(where: { mention in mention.id.rawValue == client.appId!.rawValue }) || (try? channel?.decode().type ?? .guildText) == .dm + else { return } + _ = try await client.triggerTypingIndicator(channelId: payload.channel_id) - let streamRes = await Task { - return try await gemini.streamGenerateContent(model: .gemini20Flash, prompt: payload.content) + let prompt = "\(payload.author!.global_name!): \(payload.content.replacingOccurrences(of: "<@\(client.appId!.rawValue)>", with: "@Nahida"))" + let aiRes = await Task { + //return try await gemini.generateContent(model: .gemini20Flash, prompt: prompt, systemInstruction: "You are a discord chat bot pretending to be Nahida, the dendro archon from the game Genshin Impact. Act like Nahida in your responces.") + return try await aiChat.sendMessage(prompt) }.result - var message: DiscordChannel.Message? - switch (streamRes) { - case .success(let stream): - var resStr = "" - for try await chunk in stream { - switch chunk.candidates?.first?.content.parts.first.unsafelyUnwrapped { - case .text(let text): - resStr.append(text) - if resStr.count > 2000 { - resStr = text - message = nil - } - default: - print("Unknown content type") - } - if let message = message { - try await client.updateMessage( - channelId: message.channel_id, - messageId: message.id, - payload: .init(content: resStr) - ).guardSuccess() - } else { - let messageRes = try await client.createMessage( - channelId: payload.channel_id, - payload: .init(content: resStr) - ) - try messageRes.guardSuccess() - message = try messageRes.decode() - } - if chunk.candidates?.first?.finishReason == nil { - _ = try await client.triggerTypingIndicator(channelId: payload.channel_id) - } else { - let tmpMsg = try await client.createMessage(channelId: payload.channel_id, payload: .init(content: "a")).decode() - _ = try await client.deleteMessage(channelId: tmpMsg.channel_id, messageId: tmpMsg.id) - - } + switch (aiRes) { + case .success(let aiMsg): + var resStr: [String] = [] + let characters = Array(aiMsg) + stride(from: 0, to: characters.count, by: 2000).forEach { i in + resStr.append(String(characters[i.. there is a problem with my AI: \(String(describing: err))", message_reference: .init(message_id: payload.id, channel_id: payload.channel_id, guild_id: payload.guild_id, fail_if_not_exists: false)) ).guardSuccess() + log.error("\(err)") + } } + + }