Created
June 14, 2023 17:45
-
-
Save Kycermann/e0a387caabc1076e8487d20a1ac66dc3 to your computer and use it in GitHub Desktop.
Storybro - Deno KV Hackathon Entry
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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