Skip to content

Instantly share code, notes, and snippets.

@Kycermann
Created June 14, 2023 17:45
Show Gist options
  • Save Kycermann/e0a387caabc1076e8487d20a1ac66dc3 to your computer and use it in GitHub Desktop.
Save Kycermann/e0a387caabc1076e8487d20a1ac66dc3 to your computer and use it in GitHub Desktop.
Storybro - Deno KV Hackathon Entry
import { serve, json, validateRequest } from "https://deno.land/x/[email protected]/mod.ts";
import { InteractionResponseTypes, InteractionTypes, createBot } from "https://deno.land/x/[email protected]/mod.ts";
import nacl from "https://cdn.skypack.dev/[email protected]?dts";
import { withSafeAtomics } from "https://deno.land/x/[email protected]/mod.ts";
// Try it out here: https://discord.gg/5nBCAa45
const kv = withSafeAtomics(await Deno.openKv());
serve({
"/interactions": handleInteractionRequest,
"*": () => new Response("Hello :)"),
});
async function handleInteractionRequest(req) {
const publicKey = "88fff99354bbab4f81d715214ba7314ba4cfd16e092b148286e6e673ad502384";
if (!publicKey) {
return json({ error: "Public key is missing" }, { status: 500 });
}
// Validate request
const { error } = await validateRequest(req, {
POST: { headers: ["X-Signature-Ed25519", "X-Signature-Timestamp"] },
});
if (error) {
return json({ error: error.message }, { status: error.status });
}
// Verify Discord's signature
const { valid, body } = await verifySignature(req, publicKey);
if (!valid) {
return json({ error: "Invalid request :(" }, { status: 401 });
}
// Always respond to pings
if (body.type === InteractionTypes.Ping) {
return json({ type: InteractionResponseTypes.Pong });
}
console.log(body);
// /story
if(body.type === InteractionTypes.ApplicationCommand && body.data.name === "story") {
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"# :books: Discord Story Time",
"Come together to write your community shared story, one word at a time.\n",
"**How it works**:",
":one: There's one shared story for the whole server",
":two: You can add one word at a time",
":three: You can't go twice in a row",
"\nTry the `/write` command :rocket:"
].join("\n"),
components: [
{
"type": 1,
"components": [
{
"type": 2,
"label": "Add a word",
"style": 1,
"custom_id": "add_a_word",
"emoji": {
"id": null,
"name": "✏️"
}
}
]
}
],
}
});
}
const key = ["storiesInProgress", body.guild_id || body.channel_id];
// /read
if(body.type === InteractionTypes.ApplicationCommand && body.data.name === "read") {
const { value } = await kv.get(key);
if(!value) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"# :books: Discord Story Time",
"\nThere are no words yet. Try typing `/write` :rocket:"
].join("\n"),
}
});
if(value.entries.length === 0) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"# :books: Discord Story Time",
"\nUse `/write` to write the first word :rocket:"
].join("\n"),
}
});
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"# :books: Discord Story Time",
"> " + value.entries.slice(-200).map(entry => entry.word).join(" "),
`\n:star: **Last word added by <@${value.entries.at(-1).authorId}> <t:${Math.trunc(value.entries.at(-1).addedAt / 1000)}:R>**`,
"\nUse `/write` to add more :rocket:"
].join("\n"),
allowed_mentions: { parse: [] },
}
});
}
// /write
if(body.type === InteractionTypes.ApplicationCommand && body.data.name === "write") {
const word = body.data.options[0].value;
if(!/^[a-zA-Z0-9\.,\!\-\+\*\/\$\£\€\@\:\;]+$/g.test(word)) {
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"Well done! But your word must only contain letters (A-Z), numbers (0-9) and punctuation (.,!;:-)."
].join("\n"),
},
flags: 64
});
}
const { ok, value } = await kv.setSafeAtomic(key, (value, abort) => {
if(!value) value = {
entries: [],
};
if(value.entries.length > 0 && value.entries.at(-1).authorId === body.member.user.id) {
return abort();
}
value.entries.push({
word: word,
authorId: body.member.user.id,
addedAt: Date.now()
});
return value;
});
if(!ok) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"You must wait for someone else to go before you do. Ask people to join in!"
].join("\n"),
flags: 64
},
});
if(value.webhookUrl) {
try {
await fetch(value.webhookUrl, {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: `<@${body.member.user.id}> wrote "${word}"`, allowed_mentions: { parse: [] } }),
});
} catch(error) {
console.log("webhook error, removing");
kv.setSafeAtomic(key, (value, abort) => {
if(!value) value = {
entries: [],
};
value.webhookUrl = null;
return value;
});
}
}
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"# :books: Discord Story Time",
"> " + value.entries.slice(-200).map(entry => entry.word).join(" "),
`\n:star: **Last word added by <@${value.entries.at(-1).authorId}> <t:${Math.trunc(value.entries.at(-1).addedAt / 1000)}:R>**`,
"\nUse `/write` to add more :rocket:"
].join("\n"),
allowed_mentions: { parse: [] },
}
});
}
// /undo
if(body.type === InteractionTypes.ApplicationCommand && body.data.name === "undo") {
if(!Deno.env.get("ADMINS").includes(body.member.user.id)) if((BigInt(body.member.permissions) & 8200n) === 0n) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"You need the `MANAGE_MESSAGES` permission to do this."
].join("\n"),
flags: 64
}
});
const numberToUndo = body.data.options[0].value;
let numberUndone = 0;
if(numberToUndo <= 0) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"You need to undo at least 1 word."
].join("\n"),
flags: 64
}
});
const { value, ok } = await kv.setSafeAtomic(key, (value, abort) => {
if(!value) value = {
entries: [],
};
numberUndone = Math.min(numberToUndo, value.entries.length);
value.entries = value.entries.slice(0, value.entries.length - numberToUndo);
if(numberUndone === 0) return abort();
return value;
});
if(!ok) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: `There are no words to undo.`,
flags: 64
}
});
if(numberUndone === 1) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: `Deleted the last word.`
}
});
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: `Deleted the last ${numberUndone} words.`
}
});
}
// /set_log_webhook
if(body.type === InteractionTypes.ApplicationCommand && body.data.name === "set_log_webhook") {
if(!Deno.env.get("ADMINS").includes(body.member.user.id)) if((BigInt(body.member.permissions) & 536870920n) === 0n) return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: [
"You need the `MANAGE_WEBHOOKS` permission to do this."
].join("\n"),
flags: 64
}
});
const webhookUrl = body.data.options[0].value;
try {
const response = await fetch(webhookUrl, {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: `This channel will now receive logs, courtesy of <@${body.member.user.id}>.`,
allowed_mentions: { parse: [] } }),
});
const { value, ok } = await kv.setSafeAtomic(key, (value, abort) => {
if(!value) value = {
entries: [],
webhookUrl: webhookUrl
};
value.webhookUrl = webhookUrl;
return value;
});
console.log(222222)
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: `Done. If that worked, you should have received a message in that channel.`,
flags: 64
}
});
} catch(error) {
console.log("webhookUrl error", error);
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: `I was unable to set the webhook to that. Are you sure it's a valid Discord webhook URL?`,
flags: 64
}
});
}
}
}
/*
return json({
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: "",
components: [
{
"type": 1,
"components": [
{
"type": 2,
"label": "Add a word",
"style": 1,
"custom_id": "add_a_word",
"emoji": {
"id": null,
"name": "✏️"
}
}
]
}
],
}
});
*/
async function verifySignature(req, publicKey) {
const signature = req.headers.get("X-Signature-Ed25519");
const timestamp = req.headers.get("X-Signature-Timestamp");
const body = await req.text();
const valid = nacl.sign.detached.verify(
new TextEncoder().encode(timestamp + body),
hexToUint8Array(signature),
hexToUint8Array(publicKey),
);
return { valid, body: JSON.parse(body) };
}
/** Converts a hexadecimal string to Uint8Array. */
function hexToUint8Array(hex) {
return new Uint8Array(
hex.match(/.{1,2}/g).map((val) => parseInt(val, 16)),
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment