initial publication

This commit is contained in:
flyingtoasters
2025-03-28 13:31:01 -04:00
parent e72c1d4e42
commit c43212de39
32 changed files with 8895 additions and 7346 deletions

BIN
_dev_prev_versions.zip Normal file

Binary file not shown.

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "toyo-discord-bot",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,12 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

10
toyobot/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
ignore:
- dependency-name: "*"
update-types:
["version-update:semver-patch", "version-update:semver-minor"]

39
toyobot/.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,39 @@
on:
push:
branches:
- main
pull_request:
name: ci
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm install
- run: npm test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm install
- run: npm run lint
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm install
- run: npm run publish
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

172
toyobot/.gitignore vendored
View File

@@ -1,172 +1,4 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
node_modules
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/
.wrangler

1
toyobot/.prettierignore Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -1,6 +0,0 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}

3
toyobot/.prettierrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@@ -1,5 +0,0 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}

21
toyobot/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 Justin Beckwith
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

145
toyobot/README.md Normal file
View File

@@ -0,0 +1,145 @@
# Cloudflare worker example app
awwbot is an example app that brings the cuteness of `r/aww` straight to your Discord server, hosted on Cloudflare workers. Cloudflare Workers are a convenient way to host Discord bots due to the free tier, simple development model, and automatically managed environment (no VMs!).
The tutorial for building awwbot is [in the developer documentation](https://discord.com/developers/docs/tutorials/hosting-on-cloudflare-workers)
![awwbot in action](https://user-images.githubusercontent.com/534619/157503404-a6c79d1b-f0d0-40c2-93cb-164f9df7c138.gif)
## Resources used
- [Discord Interactions API](https://discord.com/developers/docs/interactions/receiving-and-responding)
- [Cloudflare Workers](https://workers.cloudflare.com/) for hosting
- [Reddit API](https://www.reddit.com/dev/api/) to send messages back to the user
---
## Project structure
Below is a basic overview of the project structure:
```
├── .github/workflows/ci.yaml -> Github Action configuration
├── src
│ ├── commands.js -> JSON payloads for commands
│ ├── reddit.js -> Interactions with the Reddit API
│ ├── register.js -> Sets up commands with the Discord API
│ ├── server.js -> Discord app logic and routing
├── test
| ├── test.js -> Tests for app
├── wrangler.toml -> Configuration for Cloudflare workers
├── package.json
├── README.md
├── .eslintrc.json
├── .prettierignore
├── .prettierrc.json
└── .gitignore
```
## Configuring project
Before starting, you'll need a [Discord app](https://discord.com/developers/applications) with the following permissions:
- `bot` with the `Send Messages` and `Use Slash Command` permissions
- `applications.commands` scope
> ⚙️ Permissions can be configured by clicking on the `OAuth2` tab and using the `URL Generator`. After a URL is generated, you can install the app by pasting that URL into your browser and following the installation flow.
## Creating your Cloudflare worker
Next, you'll need to create a Cloudflare Worker.
- Visit the [Cloudflare dashboard](https://dash.cloudflare.com/)
- Click on the `Workers` tab, and create a new service using the same name as your Discord bot
## Running locally
First clone the project:
```
git clone https://github.com/discord/cloudflare-sample-app.git
```
Then navigate to its directory and install dependencies:
```
cd cloudflare-sample-app
npm install
```
> ⚙️ The dependencies in this project require at least v18 of [Node.js](https://nodejs.org/en/)
### Local configuration
> 💡 More information about generating and fetching credentials can be found [in the tutorial](https://discord.com/developers/docs/tutorials/hosting-on-cloudflare-workers#storing-secrets)
Rename `example.dev.vars` to `.dev.vars`, and make sure to set each variable.
**`.dev.vars` contains sensitive data so make sure it does not get checked into git**.
### Register commands
The following command only needs to be run once:
```
$ npm run register
```
### Run app
Now you should be ready to start your server:
```
$ npm start
```
### Setting up ngrok
When a user types a slash command, Discord will send an HTTP request to a given endpoint. During local development this can be a little challenging, so we're going to use a tool called `ngrok` to create an HTTP tunnel.
```
$ npm run ngrok
```
![forwarding](https://user-images.githubusercontent.com/534619/157511497-19c8cef7-c349-40ec-a9d3-4bc0147909b0.png)
This is going to bounce requests off of an external endpoint, and forward them to your machine. Copy the HTTPS link provided by the tool. It should look something like `https://8098-24-22-245-250.ngrok.io`. Now head back to the Discord Developer Dashboard, and update the "Interactions Endpoint URL" for your bot:
![interactions-endpoint](https://user-images.githubusercontent.com/534619/157510959-6cf0327a-052a-432c-855b-c662824f15ce.png)
This is the process we'll use for local testing and development. When you've published your bot to Cloudflare, you will _want to update this field to use your Cloudflare Worker URL._
## Deploying app
This repository is set up to automatically deploy to Cloudflare Workers when new changes land on the `main` branch. To deploy manually, run `npm run publish`, which uses the `wrangler publish` command under the hood. Publishing via a GitHub Action requires obtaining an [API Token and your Account ID from Cloudflare](https://developers.cloudflare.com/workers/wrangler/cli-wrangler/authentication/#generate-tokens). These are stored [as secrets in the GitHub repository](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository), making them available to GitHub Actions. The following configuration in `.github/workflows/ci.yaml` demonstrates how to tie it all together:
```yaml
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- run: npm run publish
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
```
### Storing secrets
The credentials in `.dev.vars` are only applied locally. The production service needs access to credentials from your app:
```
$ wrangler secret put DISCORD_TOKEN
$ wrangler secret put DISCORD_PUBLIC_KEY
$ wrangler secret put DISCORD_APPLICATION_ID
```
## Questions?
Feel free to post an issue here, or reach out to [@justinbeckwith](https://twitter.com/JustinBeckwith)!

15
toyobot/eslint.config.js Normal file
View File

@@ -0,0 +1,15 @@
import prettier from 'eslint-plugin-prettier/recommended';
import js from '@eslint/js';
import globals from 'globals';
export default [
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
prettier,
];

View File

@@ -1,3 +1,3 @@
DISCORD_APPLICATION_ID: ".."
DISCORD_PUBLIC_KEY: ".."
DISCORD_TOKEN: ".."
DISCORD_TOKEN: "..

14277
toyobot/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,40 @@
{
"name": "toyobot",
"author": "Chris Holt <holtcm01@gmail.com>",
"description": "A complex discord bot that interacts with Google Docs and yotoplay.com",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "wrangler dev",
"dev": "wrangler dev",
"ngrok": "ngrok http 8787",
"test": "vitest",
"fix": "eslint --fix '**/*.js'",
"lint": "eslint '**/*.js'",
"register": "node src/register.js",
"publish": "wrangler deploy",
"deploy": "wrangler deploy"
},
"dependencies": {
"discord-interactions": "^4.0.0",
"itty-router": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.1.1",
"c8": "^10.1.2",
"chai": "^5.0.0",
"dotenv": "^16.0.3",
"eslint": "^9.1.0",
"eslint-config-prettier": "^10.0.2",
"eslint-plugin-prettier": "^5.1.3",
"globals": "^16.0.0",
"mocha": "^11.0.0",
"ngrok": "^5.0.0-beta.2",
"prettier": "^3.2.5",
"sinon": "^19.0.2",
"wrangler": "^4.4.1",
"@cloudflare/vitest-pool-workers": "^0.7.5",
"vitest": "~3.0.7"
}
"name": "awwbot",
"version": "1.0.0",
"description": "A simple discord bot that uses intents to post pictures from r/aww",
"type": "module",
"private": true,
"main": "src/server.js",
"scripts": {
"start": "wrangler dev",
"ngrok": "ngrok http 8787",
"test": "c8 mocha test",
"fix": "eslint --fix '**/*.js'",
"lint": "eslint '**/*.js'",
"register": "node src/register.js",
"publish": "wrangler deploy"
},
"keywords": [],
"author": "Justin Beckwith <justin.beckwith@gmail.com>",
"license": "MIT",
"dependencies": {
"axios": "^1.8.4",
"discord-interactions": "^4.0.0",
"itty-router": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.1.1",
"c8": "^10.1.2",
"chai": "^5.0.0",
"dotenv": "^16.0.3",
"eslint": "^9.1.0",
"eslint-config-prettier": "^10.0.2",
"eslint-plugin-prettier": "^5.1.3",
"globals": "^16.0.0",
"mocha": "^11.0.0",
"ngrok": "^5.0.0-beta.2",
"prettier": "^3.2.5",
"sinon": "^19.0.2",
"wrangler": "^4.5.0"
}
}

11
toyobot/renovate.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":disableDependencyDashboard",
":preserveSemverRanges"
],
"ignorePaths": [
"**/node_modules/**"
]
}

View File

@@ -3,17 +3,218 @@
* and registration.
*/
export const AWW_COMMAND = {
import { InteractionResponseFlags, InteractionResponseType, InteractionType } from 'discord-interactions';
import { JsonResponse } from './jsonresponse.js';
import { formatDataAsMarkdown } from './utilities.js';
/*******************************************
* /awwww
*******************************************/
export const AWWWW_COMMAND = {
name: 'awwww',
description: 'Drop some cuteness on this channel.',
};
import { getCuteUrl } from './reddit.js';
export async function AWWWW_EXEC(request, env, interaction) {
const cuteUrl = await getCuteUrl();
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: cuteUrl,
},
});
};
/*******************************************
* /invite
*******************************************/
export const INVITE_COMMAND = {
name: 'invite',
description: 'Get an invite link to add the bot to your server',
};
export const GET_STORE_PAGE_COMMAND = {
name: 'yoto-store-info',
description: 'Get information about a listing from the Yoto store.',
export function INVITE_EXEC(request, env, interaction) {
const applicationId = env.DISCORD_APPLICATION_ID;
const INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${applicationId}&scope=applications.commands`;
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: INVITE_URL,
flags: InteractionResponseFlags.EPHEMERAL,
},
});
};
/*******************************************
* /ping
*******************************************/
export const PING_COMMAND = {
name: 'ping',
description: 'Replies with Pong!',
};
export function PING_EXEC(request, env, interaction) {
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Pong!",
}
})
};
/*******************************************
* /server
*******************************************/
export const SERVER_COMMAND = {
name: 'server',
description: 'Replies with server info.',
};
export function SERVER_EXEC(request, env, interaction) {
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `This command is not useful yet.
Server name: ${interaction.guild.name}
Total members: ${interaction.guild.memberCount}
Created at: ${interaction.guild.createdAt}
Verification level: ${interaction.guild.verificationLevel}`,
}
})
};
/*******************************************
* /user
*******************************************/
export const USER_COMMAND = {
name: 'user',
description: 'Replies with user info.',
};
export function USER_EXEC(request, env, interaction) {
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content:
`username: ${interaction.member.user.username}
id: ${interaction.member.user.id}
nickname: ${interaction.member.nick}`,
}
})
};
/*******************************************
* /yoto-store <url>
* /yoto-store url: https://us.yotoplay.com/products/paw-patrol-pup-pack
*******************************************/
import { ReadStoreData } from './yotostore.js';
export const YOTO_STORE_COMMAND = {
name: 'yoto-store',
description: 'Get info from the store page. Note: May have geo limits.',
options: [
{
name: 'url',
description: 'URL of the store page. e.g.: https://us.yotoplay.com/products/frog-and-toad-audio-collection',
required: true,
type: 3,
}
],
};
export async function YOTO_STORE_EXEC(request, env, interaction) {
const url = interaction.data.options[0].value;
const data = await ReadStoreData(url);
const markdown = formatDataAsMarkdown(data);
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: markdown,
}
});
}
/*******************************************
* /yoto-playlist <url> <show>
* /yoto-playlist url: https://yoto.io/hMkni?84brH2BNuhyl=e79sopPfwKnBL
* /yoto-playlist url: https://yoto.io/hMkni?84brH2BNuhyl=e79sopPfwKnBL show: true
*******************************************/
import { ReadPlaylistMetadata } from './yotoplaylist.js';
export const YOTO_PLAYLIST_COMMAND = {
name: 'yoto-playlist',
description: 'Get info from a playlist URL.',
options: [
{
name: 'url',
description: 'URL of the playlist page. e.g.: https://yoto.io/hMkni?84brH2BNuhyl=e79sopPfwKnBL',
required: true,
type: 3,
},
{
name: 'show',
description: 'Share response with the channel? Note: This means the URL is public.',
required: false,
type: 5,
choices: [
{
name: 'yes',
value: true,
},
{
name: 'no',
value: false,
}
]
}
],
};
export async function YOTO_PLAYLIST_EXEC(request, env, interaction) {
const url = interaction.data.options[0].value;
const data = await ReadPlaylistMetadata(url);
const markdown = formatDataAsMarkdown(data);
const show = interaction.data.options[1]?.value;
console.log(interaction.data.options);
if (show) { //user has decided to allow the message to be public
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: markdown,
}
});
}
//only show the message to the user who invoked the command
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: InteractionResponseFlags.EPHEMERAL,
content: markdown,
}
});
};
/*******************************************
* /extract-audio <url>
* TODO -- this is incomplete
*******************************************/
import { GetTrackURLs } from './yotoplaylist.js';
export const EXTRACT_AUDIO_COMMAND = {
name: 'extract-audio',
description: 'Get track links from a playlist URL.\n this is incomplete.',
options: [
{
name: 'url',
description: 'URL of the playlist page. e.g.: https://yoto.io/hMkni?84brH2BNuhyl=e79sopPfwKnBL',
required: true,
type: 3,
}
],
};
export async function EXTRACT_AUDIO_EXEC(request, env, interaction) {
const url = interaction.data.options[0].value;
const data = await GetTrackURLs(url);
const markdown = formatDataAsMarkdown(data);
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
//flags: InteractionResponseFlags.EPHEMERAL, //only show the message to the user who invoked the command
data: {
content: markdown,
}
});
};

View File

@@ -1,15 +0,0 @@
/**
* Welcome to Cloudflare Workers! This is your first worker.
*
* - Run `npm run dev` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your worker in action
* - Run `npm run deploy` to publish your worker
*
* Learn more at https://developers.cloudflare.com/workers/
*/
export default {
async fetch(request, env, ctx) {
return new Response('Hello World!');
},
};

View File

@@ -0,0 +1,11 @@
export class JsonResponse extends Response {
constructor(body, init) {
const jsonBody = JSON.stringify(body);
init = init || {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
};
super(jsonBody, init);
}
}

0
toyobot/src/ping.js Normal file
View File

View File

@@ -1,4 +1,7 @@
import { AWW_COMMAND, INVITE_COMMAND, GET_STORE_PAGE_COMMAND } from './commands.js';
import { AWWWW_COMMAND, INVITE_COMMAND, PING_COMMAND, SERVER_COMMAND, USER_COMMAND,
YOTO_STORE_COMMAND, YOTO_PLAYLIST_COMMAND,
EXTRACT_AUDIO_COMMAND,
} from './commands.js';
import dotenv from 'dotenv';
import process from 'node:process';
@@ -26,31 +29,45 @@ if (!applicationId) {
* Register all commands globally. This can take o(minutes), so wait until
* you're sure these are the commands you want.
*/
const url = `https://discord.com/api/v10/applications/${applicationId}/commands`;
const url = `https://discord.com/api/applications/${applicationId}/commands`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bot ${token}`,
},
method: 'PUT',
body: JSON.stringify([AWW_COMMAND, INVITE_COMMAND, GET_STORE_PAGE_COMMAND]),
});
const reg_command = JSON.stringify([PING_COMMAND,
YOTO_STORE_COMMAND, YOTO_PLAYLIST_COMMAND,
EXTRACT_AUDIO_COMMAND,
]);
const del_command = JSON.stringify([]);
if (response.ok) {
console.log('Registered all commands');
const data = await response.json();
console.log(JSON.stringify(data, null, 2));
} else {
console.error('Error registering commands');
let errorText = `Error registering commands \n ${response.url}: ${response.status} ${response.statusText}`;
try {
const error = await response.text();
if (error) {
errorText = `${errorText} \n\n ${error}`;
async function send(command, note){
console.log(`${note} all existing commands...`);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bot ${token}`,
},
method: 'PUT',
body: command,
});
if (response.ok) {
console.log(`${note} all commands`);
const data = await response.json();
console.log(JSON.stringify(data, null, 2));
} else {
console.error(`Error with ${note} commands`);
let errorText = `Error ${note} commands \n ${response.url}: ${response.status} ${response.statusText}`;
try {
const error = await response.text();
if (error) {
errorText = `${errorText} \n\n ${error}`;
}
} catch (err) {
console.error('Error reading body from request:', err);
}
} catch (err) {
console.error('Error reading body from request:', err);
console.error(errorText);
}
console.error(errorText);
console.log(`${note} task finished.`);
}
//await send(del_command, "Deleting");
await send(reg_command, "Registering");

View File

@@ -8,29 +8,61 @@ import {
InteractionType,
verifyKey,
} from 'discord-interactions';
import { AWW_COMMAND, INVITE_COMMAND, GET_STORE_PAGE_COMMAND } from './commands.js';
import { getCuteUrl } from './reddit.js';
import { InteractionResponseFlags } from 'discord-interactions';
class JsonResponse extends Response {
constructor(body, init) {
const jsonBody = JSON.stringify(body);
init = init || {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
};
super(jsonBody, init);
}
}
// Import the command and execution function individually
import { AWWWW_COMMAND, AWWWW_EXEC } from './commands.js';
import { INVITE_COMMAND, INVITE_EXEC } from './commands.js';
import { PING_COMMAND, PING_EXEC } from './commands.js';
import { SERVER_COMMAND, SERVER_EXEC } from './commands.js';
import { USER_COMMAND, USER_EXEC } from './commands.js';
import { YOTO_PLAYLIST_COMMAND, YOTO_PLAYLIST_EXEC } from './commands.js';
import { YOTO_STORE_COMMAND, YOTO_STORE_EXEC } from './commands.js';
import { EXTRACT_AUDIO_COMMAND, EXTRACT_AUDIO_EXEC } from './commands.js';
// Import other local requirements
import { JsonResponse } from './jsonresponse.js';
const router = AutoRouter();
// Respond on HTTP/GET with the basic functions for debugging purposes
// TODO: Use GET parameters the same way as POST parameters so the functions can operate the same way over GET and POST
router.get('/awwww', (request, env) => {
return AWWWW_EXEC(request, env, "webget");
});
router.get('/user', (request, env) => {
return USER_EXEC(request, env, "webget");
});
router.get('/server', (request, env) => {
return SERVER_EXEC(request, env, "webget");
});
router.get('/ping', (request, env) => {
return PING_EXEC(request, env, "webget");
});
router.get('/invite', (request, env) => {
return INVITE_EXEC(request, env, "webget");
});
router.get('/yoto-store', (request, env) => {
return YOTO_STORE_EXEC(request, env, "webget");
});
router.get('/yoto-playlist', (request, env) => {
return YOTO_PLAYLIST_EXEC(request, env, "webget");
});
router.get('/extract-audio', (request, env) => {
return EXTRACT_AUDIO_EXEC(request, env, "webget");
});
/**
* A simple :wave: hello page to verify the worker is working.
*/
router.get('/', (request, env) => {
return new Response(`Hello World! This is a Discord bot meant for The Optimistic Yack Order. \n👋 ${env.DISCORD_APPLICATION_ID}`);
return new Response(`👋 ${env.DISCORD_APPLICATION_ID}`);
});
/**
@@ -58,37 +90,29 @@ router.post('/', async (request, env) => {
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
// Most user commands will come as `APPLICATION_COMMAND`.
switch (interaction.data.name.toLowerCase()) {
case AWW_COMMAND.name.toLowerCase(): {
const cuteUrl = await getCuteUrl();
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: cuteUrl,
},
});
case AWWWW_COMMAND.name.toLowerCase(): {
return AWWWW_EXEC(request, env, interaction);
}
case INVITE_COMMAND.name.toLowerCase(): {
const applicationId = env.DISCORD_APPLICATION_ID;
const INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${applicationId}&scope=applications.commands`;
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: INVITE_URL,
flags: InteractionResponseFlags.EPHEMERAL,
},
});
return INVITE_EXEC(request, env, interaction);
}
case GET_STORE_PAGE_COMMAND.name.toLowerCase(): {
// Build the response. Best to use other function calls here so this is a short bit of code.
var rawmessage = "Sorry, this function is not implemented yet.";
// Send the message out to Discord.
return new JsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: rawmessage,
},
});
case PING_COMMAND.name.toLowerCase():{
return PING_EXEC(request, env, interaction);
}
case SERVER_COMMAND.name.toLowerCase():{
return SERVER_EXEC(request, env, interaction);
}
case USER_COMMAND.name.toLowerCase():{
return USER_EXEC(request, env, interaction);
}
case YOTO_STORE_COMMAND.name.toLowerCase():{
return YOTO_STORE_EXEC(request, env, interaction);
}
case YOTO_PLAYLIST_COMMAND.name.toLowerCase():{
return YOTO_PLAYLIST_EXEC(request, env, interaction);
}
case EXTRACT_AUDIO_COMMAND.name.toLowerCase():{
return EXTRACT_AUDIO_EXEC(request, env, interaction);
}
default:
return new JsonResponse({ error: 'Unknown Type' }, { status: 400 });
@@ -111,6 +135,7 @@ async function verifyDiscordRequest(request, env) {
if (!isValidRequest) {
return { isValid: false };
}
//console.log(JSON.parse(body));
return { interaction: JSON.parse(body), isValid: true };
}

View File

@@ -1,293 +0,0 @@
const { Client, GatewayIntentBits } = require('discord.js');
const { google } = require('googleapis');
const fs = require('fs');
// Load Discord bot token from environment or config
const DISCORD_TOKEN = 'YOUR_DISCORD_BOT_TOKEN'; // Replace with your bot token
// Load Google Sheets credentials
const GOOGLE_SHEETS_CREDENTIALS = './credentials.json'; // Path to the JSON key file
const SPREADSHEET_ID = 'YOUR_SPREADSHEET_ID'; // Replace with your Google Spreadsheet ID
const POINTS_SPREADSHEET_ID = 'YOUR_POINTS_SPREADSHEET_ID'; // Replace with the spreadsheet containing points (e.g., "Card_DB")
const LOG_SPREADSHEET_ID = 'YOUR_POINTS_LOG_SPREADSHEET_ID'; // Replace with the points_fetch_log Spreadsheet ID
// Initialize the Discord client
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
});
// Authenticate with Google Sheets API
const auth = new google.auth.GoogleAuth({
keyFile: GOOGLE_SHEETS_CREDENTIALS,
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
});
const sheets = google.sheets({ version: 'v4', auth });
// Function to query the spreadsheet using the Discord user ID
async function querySpreadsheetByUserId(userId) {
try {
const range = 'Sheet1!A2:B'; // Adjust the range based on your spreadsheet structure
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: range,
});
const rows = response.data.values;
if (!rows || rows.length === 0) {
return `No data found for user ID: ${userId}`;
}
// Find the row corresponding to the Discord user ID
const userData = rows.find(row => row[0] === userId); // Assuming user IDs are in column A
if (!userData) {
return `No application status found for user ID: ${userId}`;
}
// Return the relevant data for the user
return `Application Status for User ID ${userId}: ${userData[1]}`; // Assuming status is in column B
} catch (error) {
console.error('Error querying spreadsheet:', error);
return 'There was an error querying the spreadsheet.';
}
}
// Function to store email and Discord user ID in the Google Sheet
async function storeEmailAndDiscordId(email, discordId) {
try {
const range = 'Sheet1!A:B'; // Assuming data is stored in columns A (Discord ID) and B (Email)
const values = [[discordId, email]]; // Data to append
const request = {
spreadsheetId: SPREADSHEET_ID,
range: range,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
resource: {
values: values,
},
};
// Append the data to the Google Sheet
await sheets.spreadsheets.values.append(request);
return `Successfully linked email: ${email} with Discord ID: ${discordId}`;
} catch (error) {
console.error('Error storing email and Discord ID:', error);
return 'There was an error storing the email and Discord ID.';
}
}
// Function to store URL and Discord user ID in the Google Sheet
async function storeLink(url, discordId) {
try {
const range = 'Sheet1!A:B'; // Assuming data is stored in columns A (Discord ID) and B (URL)
const values = [[discordId, url]]; // Data to append
const request = {
spreadsheetId: LINKS_SPREADSHEET_ID,
range: range,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
resource: {
values: values,
},
};
// Append the data to the Google Sheet
await sheets.spreadsheets.values.append(request);
return `Successfully submitted the URL: ${url}`;
} catch (error) {
console.error('Error storing URL and Discord ID:', error);
return 'There was an error submitting the URL.';
}
}
// Function to query the spreadsheet for a 5-character identifier or a string in the title
async function checkCard(searchTerm) {
try {
// Define the range of data in the spreadsheet (e.g., Sheet1!A:R for columns A to R)
const range = 'Sheet1!A:R';
const response = await sheets.spreadsheets.values.get({
spreadsheetId: CARD_DB_SPREADSHEET_ID,
range: range,
});
const rows = response.data.values;
if (!rows || rows.length === 0) {
return 'No data found in the Card_DB.';
}
// Check if the input is a 5-character identifier
if (searchTerm.length === 5 && /^[A-Za-z0-9]+$/.test(searchTerm)) {
// Perform an exact, case-sensitive match on column A
const matchingRow = rows.find(row => row[0] === searchTerm); // Column A is index 0
if (!matchingRow) {
return `No card found with ID: ${searchTerm}`;
}
// Ensure there is only one match
const matches = rows.filter(row => row[0] === searchTerm);
if (matches.length > 1) {
return `Error: Multiple entries found for ID: ${searchTerm}. Please check the database for duplicates.`;
}
// Format and return the data
const headers = ['ID', 'Title', 'Field C', 'Field D', 'Field E', 'Field F', 'Field G', 'Field H', 'Field I', 'Field J',
'Field K', 'Field L', 'Field M', 'Field N', 'Field O', 'Field P', 'Field Q', 'Field R'];
const data = matchingRow.slice(0, 18); // Extract columns A to R
return headers.map((header, index) => `${header}: ${data[index] || 'N/A'}`).join('\n');
} else {
// Perform a non-case-sensitive search in column C (index 2)
const matchingRows = rows.filter(row => row[2] && row[2].toLowerCase().includes(searchTerm.toLowerCase())); // Column C is index 2
if (matchingRows.length === 0) {
return `No cards found with the title containing: "${searchTerm}"`;
}
// If there are multiple matches, provide a list of IDs and prompt for ID-based search
if (matchingRows.length > 1) {
const matchingIds = matchingRows.map(row => row[0]); // Collect matching IDs from column A
return `Multiple cards found with the title containing "${searchTerm}". Matching IDs:\n${matchingIds.join(', ')}\nPlease search for one of these IDs to get detailed information.`;
}
// If there's exactly one match, return its data
const matchingRow = matchingRows[0];
const headers = ['ID', 'Title', 'Field C', 'Field D', 'Field E', 'Field F', 'Field G', 'Field H', 'Field I', 'Field J',
'Field K', 'Field L', 'Field M', 'Field N', 'Field O', 'Field P', 'Field Q', 'Field R'];
const data = matchingRow.slice(0, 18); // Extract columns A to R
return headers.map((header, index) => `${header}: ${data[index] || 'N/A'}`).join('\n');
}
} catch (error) {
console.error('Error checking card:', error);
return 'There was an error checking the card.';
}
}
// Function to fetch points for a Discord ID
async function fetchPoints(discordId, username) {
try {
// Fetch data from the points spreadsheet
const range = 'Sheet1!A:K'; // Assuming Discord ID is in column A and points are in column K
const response = await sheets.spreadsheets.values.get({
spreadsheetId: POINTS_SPREADSHEET_ID,
range: range,
});
const rows = response.data.values;
if (!rows || rows.length === 0) {
return 'No data found in the points spreadsheet.';
}
// Find the row with the matching Discord ID
const matchingRow = rows.find(row => row[0] === discordId); // Column A is index 0
if (!matchingRow) {
return `No points found for Discord ID: ${discordId}`;
}
const points = matchingRow[10]; // Column K is index 10
if (!points || isNaN(points)) {
return `Invalid points value for Discord ID: ${discordId}`;
}
// Trigger the mee6 command
const mee6Command = `/give-item member:${discordId} item:Yak Point amount:${points}`;
console.log(`Executing Mee6 Command: ${mee6Command}`); // Replace with actual Mee6 command logic
// Note: Add logic to send the mee6 command to the correct channel in Discord if necessary
// Log the action in the points_fetch_log spreadsheet
const logRange = 'Sheet1!A:C'; // Assuming columns A, B, and C are for Username, Discord ID, and Points
const logValues = [[username, discordId, points]]; // Log the action
const logRequest = {
spreadsheetId: LOG_SPREADSHEET_ID,
range: logRange,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
resource: {
values: logValues,
},
};
await sheets.spreadsheets.values.append(logRequest);
return `Successfully fetched points for ${username}. They were awarded ${points} Yak Points!`;
} catch (error) {
console.error('Error fetching points:', error);
return 'There was an error fetching points.';
}
}
// Handle messages
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
// Command: archive-application-status
if (message.content.startsWith('archive-application-status')) {
const userId = message.author.id; // Use Discord user ID for the query
const result = await querySpreadsheetByUserId(userId);
message.reply(result);
}
// Command: link-email
if (message.content.startsWith('link-email')) {
const args = message.content.split(' ');
const email = args[1]; // Get the email address from the command arguments
if (!email || !email.includes('@')) {
message.reply('Please provide a valid email address. Example: `link-email user@example.com`');
return;
}
// Store the email and Discord ID in the Google Sheet
const result = await storeEmailAndDiscordId(email, message.author.id);
message.reply(result);
}
// Command: submit-card
if (message.content.startsWith('submit-card')) {
const args = message.content.split(' ');
const url = args[1]; // Get the URL from the command arguments
if (!url || !url.startsWith('http')) {
message.reply('Please provide a valid URL. Example: `submit-card https://example.com`');
return;
}
// Store the URL and Discord ID in the Google Sheet
const result = await storeLink(url, message.author.id);
message.reply(result);
// Command: check-card
if (message.content.startsWith('check-card')) {
const args = message.content.split(' ');
const searchTerm = args[1]; // Get the search term from the command arguments
if (!searchTerm) {
message.reply('Please provide a search term. Example: `check-card ABC12` or `check-card MyCardTitle`');
return;
}
// Query the spreadsheet for the search term
const result = await checkCard(searchTerm);
message.reply(result);
}
// Command: fetch-points
if (message.content.startsWith('fetch-points')) {
const discordId = message.author.id; // Use the message author's Discord ID
const username = message.author.username; // Get the username
// Fetch points for the user
const result = await fetchPoints(discordId, username);
message.reply(result);
}
});
// Handle Discord bot ready event
client.once('ready', () => {
console.log(`Logged in as ${client.user.tag}!`);
});
// Log in to Discord
client.login(DISCORD_TOKEN);

72
toyobot/src/utilities.js Normal file
View File

@@ -0,0 +1,72 @@
import axios from 'axios';
/**
* Given a URL, return the latest copy of that URL in the Wayback machine.
*
* @param {string} url - The URL to find in the Wayback Machine.
* @returns {string} - The constructed URL to the latest snapshot without the banner.
* @throws {Error} - Throws an error if no URL is provided or no snapshot is available.
*/
export async function get_wayback_url(url) {
if (!url) {
throw new Error("No URL provided");
}
const response = await axios.get(`http://archive.org/wayback/available?url=${url}`);
const metadata = response.data;
const closest = metadata?.archived_snapshots?.closest;
if (!closest) {
return `No snapshot for ${url}`;
}
return closest.url;
}
/**
* Converts the first letter of each word in the input string to uppercase.
*
* @param {string} str - The input string to be transformed.
* @returns {string} - The transformed string with each word's first letter in uppercase.
*/
export function toUpperCaseEachWord(str) {
return str.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Converts an array of strings into a single string with each element separated by "; ".
*
* @param {Array} arr - The array of strings to be converted.
* @returns {string} - The resulting concatenated string.
*/
export function arrayToString(arr) {
return arr ? arr.map(item => item.trim()).join("; ") : "";
}
/**
* Converts the data object into a Markdown-formatted string.
*
* @param {Object} data - The data object to format.
* @returns {string} - The Markdown-formatted string.
*/
export function formatDataAsMarkdown(data) {
if (typeof data !== 'object' || data === null) {
return "Invalid data or no data to display.";
}
let markdown = "";
for (const [key, value] of Object.entries(data)) {
// Replace underscores with spaces in the key name
const formattedKey = key.replace(/_/g, ' ');
markdown += `**${formattedKey}:** ${value ?? "N/A"}\n`;
}
// Truncate the message if it exceeds 1950 characters
if (markdown.length > 1950) {
markdown = markdown.substring(0, 1922) + "\n **ERROR**: Text too long."; // Add ellipsis to indicate truncation
}
return markdown;
}

270
toyobot/src/yotoplaylist.js Normal file
View File

@@ -0,0 +1,270 @@
import axios from 'axios';
import { get_wayback_url } from './utilities.js';
/**
* Fetches and parses playlist data from the provided URL.
*
* @param {string} url - The URL to fetch the playlist data from.
* @returns {Object} - An object containing all the extracted playlist data.
*/
export async function ReadPlaylistMetadata(url) {
try {
// Fetch the content from the provided URL
const response = await axios.get(url);
const contentType = response.headers['content-type'];
let card = response.data?.card;
//let card = jsonData.card;
//console.log(card);
// Extract data
const data = {
Title: "[" + card.title + "](" + card.metadata.cover.imageL + ") ",
Author: card.metadata.author || "-",
Category: card.metadata?.category || "-",
Officiality: getOfficiality(url, card),
Is_MYO_Card: getIsMYOCard(card),
Created_At: card.createdAt || "-",
Updated_At: card.updatedAt || "-",
//Tracks: extractTracks(card),
File_Size: formatFileSize(card.metadata?.media?.fileSize),
Duration: formatDuration(card.metadata?.media?.duration),
Share_Link_Created_At: card.sharing?.shareLinkCreatedAt || "-",
Share_Count: card.sharing?.shareCount || "-",
Share_Limit: card.sharing?.shareLimit || "-",
Description: card?.metadata?.description || "-",
};
return data;
} catch (e) {
return { error: e.message || "Error fetching data" };
}
}
export async function GetTrackURLs(url){
try {
// Fetch the content from the provided URL
const response = await axios.get(url);
const contentType = response.headers['content-type'];
let card = response.data?.card;
return extractTracksWithIndex(card);
} catch (e) {
return { error: e.message || "Error fetching data" };
}
}
/**
* Extracts track URLs from the card data and formats them as index: url.
*
* @param {Object} card - The card object containing track data.
* @returns {Array} - An array of formatted track entries (index: url).
*/
function extractTracksWithIndex(card) {
const data = [];
const chapters = card.content?.chapters || [];
chapters.forEach((chapter, chapterIndex) => {
chapter.tracks?.forEach((track, trackIndex) => {
if (track.trackUrl) {
// Push the formatted entry into the data collection
data.push(`${chapterIndex}-${trackIndex}: [get](${track.trackUrl})`);
}
});
});
return data.length > 0 ? data : ["No track URLs found"];
}
/**
* Extracts track URLs from the card data.
*/
function extractTracks(card) {
const trackUrls = [];
const chapters = card.content?.chapters || [];
chapters.forEach(chapter => {
chapter.tracks?.forEach(track => {
if (track.trackUrl) {
trackUrls.push(track.trackUrl);
}
});
});
return trackUrls.length > 0 ? trackUrls : "No track URLs found";
}
/**
* Gets the song extension from the first track.
*/
async function getSongExtension(card) {
try {
const trackUrl = card.content?.chapters[0]?.tracks[0]?.trackUrl;
if (!trackUrl) {
return "No track URL found";
}
const response = await axios.head(trackUrl);
const contentType = response.headers['content-type'];
return getExtensionFromContentType(contentType) || "No extension found";
} catch {
return "Error fetching song extension";
}
}
/**
* Maps content type to file extension.
*/
function getExtensionFromContentType(contentType) {
const mapping = {
'audio/mpeg': 'mp3',
'audio/aac': 'aac',
'audio/wav': 'wav',
'audio/ogg': 'ogg',
'audio/mp4': 'm4a',
'audio/flac': 'flac',
'audio/x-m4a': 'm4a',
};
return mapping[contentType] || null;
}
/**
* Formats file size in bytes to MB.
*/
function formatFileSize(fileSizeInBytes) {
if (!fileSizeInBytes) return "0 MB";
return (fileSizeInBytes / (1024 * 1024)).toFixed(2) + " MB";
}
/**
* Formats duration in seconds to HH:MM:SS or MM:SS.
*/
function formatDuration(durationInSeconds) {
if (!durationInSeconds) return "Not found";
const hours = Math.floor(durationInSeconds / 3600);
const minutes = Math.floor((durationInSeconds % 3600) / 60);
const seconds = durationInSeconds % 60;
return hours > 0
? `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
: `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Extracts club availability from the card data.
*/
function extractClubAvailability(card) {
const clubAvailability = card.clubAvailability || [];
return clubAvailability.length > 0
? clubAvailability.map(store => store.store.toUpperCase()).join(', ')
: "Unknown";
}
/**
* Extracts card type from the card data.
*/
function extractCardType(card) {
const tracks = card.content?.chapters[0]?.tracks;
return tracks && tracks.length > 0 ? tracks[0].type : "No tracks found";
}
/**
* Extracts ambient data from the card.
*/
function extractAmbient(card) {
const ambient = card.content?.chapters[0]?.tracks[0]?.ambient || {};
return Object.keys(ambient).length > 0
? Object.keys(ambient).map(key => key.toUpperCase()).join(', ')
: "No";
}
/**
* Determines if a card is an MYO (Make Your Own) card based on the JSON data.
*
* @param {Object} jsonData - The parsed JSON data of the card.
* @returns {boolean|string} - Returns `true` if the card is an MYO card, `false` otherwise, or an error message.
*/
function getIsMYOCard(card) {
try {
// Safely access the creator email and user ID using optional chaining
const email = card?.creatorEmail || "Not found";
const user = card?.userId || null;
// Determine if the card is an MYO card
if (email !== "Not found" && user !== "yoto") {
return true;
} else {
return false;
}
} catch (e) {
console.error("Error determining if card is MYO:", e);
return "Waiting for URL...";
}
}
/**
* Determines the officiality of a card based on the URL and JSON data.
*
* @param {string} url - The URL of the card.
* @param {Object} jsonData - The parsed JSON data of the card.
* @returns {string} - The officiality of the card (e.g., "Yoto", "MYO", "Demo", "Free", or "unknown card type").
*/
function getOfficiality(url, card) {
try {
// Check if the URL contains "?84", indicating a physical card
if (url.includes("?84")) {
return "Yoto";
}
// Check if the card has a creator email, indicating an MYO card
const email = card?.creatorEmail || null;
if (email) {
return "MYO"; // Could also be a Yoto Space
}
// Check if the description indicates a demo card
const description = card?.metadata?.description || null;
const discoverRegex = /(Discover(?!: The full card| ten| fascinating| the science)|(: A preview)|(: The first)|(: Prologue)|(Chapter 1)|(Chapter one)|[0-9]+ track[s]? Full Card: [0-9]+ tracks. Available: Until)/g;
if (description && discoverRegex.test(description)) {
return "Demo";
}
// Check if the card availability is "free"
const cardAvailability = card?.availability || null;
if (cardAvailability === "free") {
return "Free";
}
// Check if the user ID indicates an official or MYO card
const userId = card?.userId || null;
if (userId === "yoto") {
return "Yoto";
}
if (userId && userId.startsWith("auth0")) {
return "MYO"; // Could also be a Yoto Space
}
// Check if the card has a category, indicating an official or Yoto Space card
const category = card?.category || null;
if (category) {
return "Yoto";
}
// Check if the card has club availability, indicating a Yoto digital or club card
const clubAvailability = card?.clubAvailability || null;
if (clubAvailability) {
return "Yoto";
}
// Check if the card has streams, indicating a free card
const hasStreams = card?.metadata?.media?.hasStreams || false;
if (hasStreams) {
return "Free";
}
// Default to "unknown card type" if no conditions are met
return "unknown card type";
} catch (e) {
console.error("Error determining officiality:", e);
return "Waiting for URL...";
}
}

102
toyobot/src/yotostore.js Normal file
View File

@@ -0,0 +1,102 @@
import axios from 'axios';
import { toUpperCaseEachWord, arrayToString, get_wayback_url } from './utilities.js';
/**
* This asynchronous function retrieves and processes product data from a provided store URL.
* It attempts to extract metadata such as tags, content IDs, and product attributes embedded
* within the page's HTML or retrieves the data from the Wayback Machine as a fallback.
*
* @param {string} url - The URL of the product page to fetch and analyze.
* @param {boolean} [wayback=false] - A flag to determine if the Wayback Machine should be used as a fallback.
* @returns {Array|string} An array containing various product details (e.g., title, content IDs, price, etc.)
* or error messages in case of issues.
*/
export async function ReadStoreData(url, wayback = false) {
try {
// Fetch the HTML content from the provided URL
const response = await axios.get(url);
const html = response.data;
// Locate the JSON data embedded in a <script> tag within the HTML
const jsonMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/);
if (!jsonMatch) {
// If JSON data isn't found and Wayback Machine hasn't been tried yet, attempt to fetch from there
if (!wayback) {
return await ReadStoreData(await get_wayback_url(url), true);
}
return "Error: No JSON found";
}
// Parse the embedded JSON data
const jsonData = JSON.parse(jsonMatch[1]);
const product = jsonData.props.pageProps.product;
// Attempt to retrieve the product's tags; fallback to Wayback if absent
const tags = product.tags;
if (!tags) {
if (!wayback) {
return await ReadStoreData(await get_wayback_url(url), true);
}
return "Error: No tags found";
}
// Initialize variables to store product metadata
let read_by, accent, language, author, age_min, age_max, club_credits, credit_cost, copyright, media_type;
let is_digital = false;
let ids = [], content_types = [];
// Process each tag to extract relevant product information
for (const tag of tags) {
if (tag.startsWith("read-by:")) read_by = tag.substring(8).trim();
if (tag.startsWith("accent:")) accent = toUpperCaseEachWord(tag.substring(7).trim());
if (tag.startsWith("language:")) language = toUpperCaseEachWord(tag.substring(9).trim());
if (tag.startsWith("content-id:")) ids.push(tag.substring(11).trim());
if (tag.startsWith("author:")) author = toUpperCaseEachWord(tag.substring(7).trim());
if (tag.startsWith("age-min:")) age_min = tag.substring(8).trim();
if (tag.startsWith("age-max:")) age_max = tag.substring(8).trim();
if (tag.startsWith("club-credits:")) club_credits = tag.substring(13).trim();
if (tag.startsWith("credit-cost:")) credit_cost = tag.substring(12).trim();
if (tag.startsWith("copyright:")) copyright = tag.substring(10).trim();
if (tag.startsWith("media:digital")) is_digital = true;
if (tag.startsWith("content-type:")) content_types.push(tag.substring(13).trim());
if (tag.startsWith("media:")) media_type = tag.substring(6).trim();
}
// Calculate the count of content IDs and convert arrays to strings
const card_count = ids.length;
ids = arrayToString(ids);
content_types = arrayToString(content_types);
// Retrieve additional product details from the JSON structure
const age_Range = product.ageRange ? `${product.ageRange[0]} - ${product.ageRange[1]}` : age_min + " - " + age_max;
const title = product.title ? product.title.trim() : "No title found";
const price = product.price ? product.price.trim() : "Discontinued";
const handle = product.handle ? product.handle.trim() : "No handle found";
const description = product.description ? product.description.trim() : "No description found";
const description_markdown = product.descriptionMarkdown ? product.descriptionMarkdown.trim() : "No descriptionMarkdown found";
const art_url = product.images[0] ? product.images[0]?.url : "No images found";
const is_bundle = product.isBundle || false;
const geo = url.substring(8, 10).toUpperCase();
const data = {
Title: title, // Product title
IDs: ids + "(" + card_count + " cards)", // Content IDs as a string
URL: "[[art](" + art_url + ")] [geo: " + geo + "] <" + url + ">", // The original URL
Content_Types: content_types, // Content types as a string
Age_Range: age_Range, // Age range as a string
Author: author, // Author name
Read_By: read_by, // Narrator or reader
Language: language + " (" + accent + ")", // Language of the product
Price: price + "(Club: " + club_credits + " credits)", // Product price
Description: description_markdown, // Markdown version of the description
};
// Return an array of extracted product details
return data;
} catch (e) {
// Handle unexpected errors by returning an error message
console.error(e);
return "Error: Waiting for URL...";
}
}

View File

@@ -1,20 +0,0 @@
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src';
describe('Hello World worker', () => {
it('responds with Hello World! (unit style)', async () => {
const request = new Request('http://example.com');
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx);
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
it('responds with Hello World! (integration style)', async () => {
const response = await SELF.fetch('http://example.com');
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
});
});

171
toyobot/test/server.test.js Normal file
View File

@@ -0,0 +1,171 @@
import { expect } from 'chai';
import { describe, it, beforeEach, afterEach } from 'mocha';
import {
InteractionResponseType,
InteractionType,
InteractionResponseFlags,
} from 'discord-interactions';
import { AWW_COMMAND, INVITE_COMMAND } from '../src/commands.js';
import sinon from 'sinon';
import server from '../src/server.js';
import { redditUrl } from '../src/reddit.js';
describe('Server', () => {
describe('GET /', () => {
it('should return a greeting message with the Discord application ID', async () => {
const request = {
method: 'GET',
url: new URL('/', 'http://discordo.example'),
};
const env = { DISCORD_APPLICATION_ID: '123456789' };
const response = await server.fetch(request, env);
const body = await response.text();
expect(body).to.equal('👋 123456789');
});
});
describe('POST /', () => {
let verifyDiscordRequestStub;
beforeEach(() => {
verifyDiscordRequestStub = sinon.stub(server, 'verifyDiscordRequest');
});
afterEach(() => {
verifyDiscordRequestStub.restore();
});
it('should handle a PING interaction', async () => {
const interaction = {
type: InteractionType.PING,
};
const request = {
method: 'POST',
url: new URL('/', 'http://discordo.example'),
};
const env = {};
verifyDiscordRequestStub.resolves({
isValid: true,
interaction: interaction,
});
const response = await server.fetch(request, env);
const body = await response.json();
expect(body.type).to.equal(InteractionResponseType.PONG);
});
it('should handle an AWW command interaction', async () => {
const interaction = {
type: InteractionType.APPLICATION_COMMAND,
data: {
name: AWW_COMMAND.name,
},
};
const request = {
method: 'POST',
url: new URL('/', 'http://discordo.example'),
};
const env = {};
verifyDiscordRequestStub.resolves({
isValid: true,
interaction: interaction,
});
// mock the fetch call to reddit
const result = sinon
// eslint-disable-next-line no-undef
.stub(global, 'fetch')
.withArgs(redditUrl)
.resolves({
status: 200,
ok: true,
json: sinon.fake.resolves({ data: { children: [] } }),
});
const response = await server.fetch(request, env);
const body = await response.json();
expect(body.type).to.equal(
InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
);
expect(result.calledOnce);
});
it('should handle an invite command interaction', async () => {
const interaction = {
type: InteractionType.APPLICATION_COMMAND,
data: {
name: INVITE_COMMAND.name,
},
};
const request = {
method: 'POST',
url: new URL('/', 'http://discordo.example'),
};
const env = {
DISCORD_APPLICATION_ID: '123456789',
};
verifyDiscordRequestStub.resolves({
isValid: true,
interaction: interaction,
});
const response = await server.fetch(request, env);
const body = await response.json();
expect(body.type).to.equal(
InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
);
expect(body.data.content).to.include(
'https://discord.com/oauth2/authorize?client_id=123456789&scope=applications.commands',
);
expect(body.data.flags).to.equal(InteractionResponseFlags.EPHEMERAL);
});
it('should handle an unknown command interaction', async () => {
const interaction = {
type: InteractionType.APPLICATION_COMMAND,
data: {
name: 'unknown',
},
};
const request = {
method: 'POST',
url: new URL('/', 'http://discordo.example'),
};
verifyDiscordRequestStub.resolves({
isValid: true,
interaction: interaction,
});
const response = await server.fetch(request, {});
const body = await response.json();
expect(response.status).to.equal(400);
expect(body.error).to.equal('Unknown Type');
});
});
describe('All other routes', () => {
it('should return a "Not Found" response', async () => {
const request = {
method: 'GET',
url: new URL('/unknown', 'http://discordo.example'),
};
const response = await server.fetch(request, {});
expect(response.status).to.equal(404);
const body = await response.text();
expect(body).to.equal('Not Found.');
});
});
});

View File

@@ -1,11 +0,0 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
});

View File

@@ -1,47 +0,0 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "toyobot",
"main": "src/server.js",
"compatibility_date": "2025-03-21",
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}

41
toyobot/wrangler.toml Normal file
View File

@@ -0,0 +1,41 @@
#:schema node_modules/wrangler/config-schema.json
# For more details on how to configure Wrangler, refer to:
# https://developers.cloudflare.com/workers/wrangler/configuration/
name = "toyobot"
main = "./src/server.js"
compatibility_date = "2023-05-18"
[observability]
enabled = true
# Smart Placement
# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
# [placement]
# mode = "smart"
###
# Bindings
# Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
# databases, object storage, AI inference, real-time communication and more.
# https://developers.cloudflare.com/workers/runtime-apis/bindings/
###
# Environment Variables
# https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
# [vars]
# MY_VARIABLE = "production_value"
# Note: Use secrets to store sensitive data.
# https://developers.cloudflare.com/workers/configuration/secrets/
# Static Assets
# https://developers.cloudflare.com/workers/static-assets/binding/
# [assets]
# directory = "./public/"
# binding = "ASSETS"
# Service Bindings (communicate between multiple Workers)
# https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
# [[services]]
# binding = "MY_SERVICE"
# service = "my-service"