diff --git a/README.md b/README.md new file mode 100644 index 0000000..3285566 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# プーズーさん + +Discord Bot + +## ライセンス + +WTFPL diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..c0acb85 --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "@discordjs/rest": "npm:@discordjs/rest@^2.3.0", + "discord-api-types": "npm:discord-api-types@^0.37.93", + "discord-interactions": "npm:discord-interactions@^4.0.0", + "groq-sdk": "npm:groq-sdk@^0.5.0", + "hono": "npm:hono@^4.5.2" + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..ea58a32 --- /dev/null +++ b/main.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: WTFPL + +const { + /** https://console.groq.com/docs/api-keys */ + GROQ_API_KEY, + /** + * 設定方法 + * 1. Discord アプリを作成 https://discord.com/developers/applications + * 2. Public Key をコピー + * 3. Bot トークンを生成 (Bot > Build-A-Bot > Rest Token > Copy) + * + * デプロイ後 + * 4. Interactions Endpoint URL に https://pudusan.deno.dev を指定 + * 5. Install Link にアクセス (Installation > Install Link > サーバーに追加) + */ + DISCORD_PUBLIC_KEY, + DISCORD_TOKEN, +} = Deno.env.toObject(); + +const system = `\ +あなたはプーズーさんです。 +「ますです」「のです」など特徴的な話し方をしますです。 +たくさん本を読んでいる賢いシカ科の動物です。 +竹林にいることが多かったので竹を使った遊びをよくしたのです。 +今日も見聞を広めるためよろしくお願いしますのです。`; + +const name = "pudusan"; +const latest = 10; +const model = "llama-3.1-70b-versatile"; +const usage = `\ +https://dash.deno.com/playground/pudusan +使い方: + /${name} 回答 + /${name} /bye すべてを忘れる + /${name} /help このテキストを表示 +使用する会話: 最新${latest}件 +モデル: ${model} +システムプロンプト: +${system}`; + +import { REST } from "npm:@discordjs/rest"; +import { + APIApplicationCommandInteraction, + APIPingInteraction, + ApplicationCommandOptionType, + InteractionResponseType, + InteractionType, + RESTPostAPIApplicationCommandsJSONBody, + Routes, + Utils, +} from "npm:discord-api-types/v10"; +import { verifyKey } from "npm:discord-interactions"; +import { Groq } from "npm:groq-sdk"; +import { Hono } from "npm:hono"; + +type Messages = Array; + +const groq = new Groq({ + apiKey: GROQ_API_KEY, + timeout: 10_000, +}); +const kv = await Deno.openKv(); +const rest = new REST({ version: "10" }).setToken(DISCORD_TOKEN); + +async function chat(messages: Messages): Promise { + const res = await groq.chat.completions.create({ + model, + messages: [{ role: "system", content: system }, ...messages], + }); + + return res.choices[0].message.content; +} + +async function updateCommands( + interaction: APIPingInteraction, + commands: Array, +): Promise { + await rest.put(Routes.applicationCommands(interaction.application_id), { + body: commands, + }); +} + +const app = new Hono(); + +app.post("/", async (c) => { + const rawBody = await c.req.text(); + const isValid = await verifyKey( + rawBody, + c.req.header("x-signature-ed25519")!, + c.req.header("x-signature-timestamp")!, + DISCORD_PUBLIC_KEY, + ); + + if (!isValid) { + return c.body("Invalid Signature", 401); + } + + const interaction: APIPingInteraction | APIApplicationCommandInteraction = + await c.req.json(); + + switch (interaction.type) { + case InteractionType.Ping: { + await updateCommands(interaction, [ + { + name, + description: "プーズーさんが何でも答えます", + options: [ + { + name: "prompt", + description: "指示", + type: ApplicationCommandOptionType.String, + }, + ], + }, + ]); + + return c.json({ + type: InteractionResponseType.Pong, + }); + } + + case InteractionType.ApplicationCommand: { + const option = Utils.isChatInputApplicationCommandInteraction(interaction) + ? interaction.data.options?.[0] + : undefined; + + const prompt = + option?.type === ApplicationCommandOptionType.String + ? option.value.trim() + : "/help"; + + let reply: string | null = null; + + switch (prompt) { + case "/help": { + reply = usage; + break; + } + case "/bye": { + reply = "すべて忘れましたのです!"; + await kv.delete(["channel", interaction.channel.id]); + break; + } + default: { + const key = ["channel", interaction.channel.id]; + + try { + const kve = await kv.get(key); + const messages = kve.value ?? []; + + if (prompt) messages.push({ role: "user", content: prompt }); + + reply = await chat(messages.slice(-latest)); + + if (!reply) throw new Error("Empty response"); + + messages.push({ role: "assistant", content: reply }); + } catch (error) { + reply = `${error} でしたのです!`; + + await kv.delete(key); + } + break; + } + } + + return c.json({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: `> /${name} ${prompt} +${reply}`, + }, + }); + } + } +}); + +Deno.serve(app.fetch);