mirror of
https://github.com/LukeHagar/toyo-discord-bot.git
synced 2025-12-06 04:21:49 +00:00
initial publication
This commit is contained in:
BIN
_dev_prev_versions.zip
Normal file
BIN
_dev_prev_versions.zip
Normal file
Binary file not shown.
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "toyo-discord-bot",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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
10
toyobot/.github/dependabot.yml
vendored
Normal 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
39
toyobot/.github/workflows/ci.yaml
vendored
Normal 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
172
toyobot/.gitignore
vendored
@@ -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
1
toyobot/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"useTabs": true
|
||||
}
|
||||
3
toyobot/.prettierrc.json
Normal file
3
toyobot/.prettierrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
5
toyobot/.vscode/settings.json
vendored
5
toyobot/.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"wrangler.json": "jsonc"
|
||||
}
|
||||
}
|
||||
21
toyobot/LICENSE
Normal file
21
toyobot/LICENSE
Normal 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
145
toyobot/README.md
Normal 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)
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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
15
toyobot/eslint.config.js
Normal 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,
|
||||
];
|
||||
@@ -1,3 +1,3 @@
|
||||
DISCORD_APPLICATION_ID: ".."
|
||||
DISCORD_PUBLIC_KEY: ".."
|
||||
DISCORD_TOKEN: ".."
|
||||
DISCORD_TOKEN: "..
|
||||
|
||||
14277
toyobot/package-lock.json
generated
14277
toyobot/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
11
toyobot/renovate.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":disableDependencyDashboard",
|
||||
":preserveSemverRanges"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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!');
|
||||
},
|
||||
};
|
||||
11
toyobot/src/jsonresponse.js
Normal file
11
toyobot/src/jsonresponse.js
Normal 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
0
toyobot/src/ping.js
Normal 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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
72
toyobot/src/utilities.js
Normal 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
270
toyobot/src/yotoplaylist.js
Normal 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
102
toyobot/src/yotostore.js
Normal 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...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
171
toyobot/test/server.test.js
Normal 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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
41
toyobot/wrangler.toml
Normal 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"
|
||||
Reference in New Issue
Block a user