diff --git a/examples/anime-adventure/.gitignore b/examples/anime-adventure/.gitignore new file mode 100644 index 0000000..43e4c5f --- /dev/null +++ b/examples/anime-adventure/.gitignore @@ -0,0 +1 @@ +wrangler.toml \ No newline at end of file diff --git a/examples/anime-adventure/package.json b/examples/anime-adventure/package.json new file mode 100644 index 0000000..cb9015b --- /dev/null +++ b/examples/anime-adventure/package.json @@ -0,0 +1,39 @@ +{ + "name": "chatgpt-plugin-example-anime-adventure", + "version": "0.1.2", + "private": true, + "description": "A neverending choose-your-own-adventure Anime game.", + "author": "Travis Fischer ", + "repository": "transitive-bullshit/chatgpt-plugin-ts", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=14" + }, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler publish" + }, + "dependencies": { + "@cloudflare/itty-router-openapi": "^0.0.15", + "anilist-node": "^1.13.2", + "chatgpt-plugin": "workspace:../../packages/chatgpt-plugin", + "zod": "^3.21.4" + }, + "aiPlugin": { + "name": "Anime Adventure", + "description_for_model": "Plugin which enables the user to play a neverending choose-your-own-adventure Anime game. Use this plugin to generate a story about an anime of the user's choosing. The game starts with the \"start\" command, and the user can then use the \"continue\" command to continue the story. The user can also use the \"restart\" command to start a new story.", + "logo_url": "TODO", + "contact_email": "travis@transitivebullsh.it", + "legal_info_url": "https://transitivebullsh.it" + }, + "keywords": [ + "openai", + "chatgpt", + "plugin", + "openapi", + "anime", + "game", + "choose your own adventure" + ] +} diff --git a/examples/anime-adventure/readme.md b/examples/anime-adventure/readme.md new file mode 100644 index 0000000..2c0e551 --- /dev/null +++ b/examples/anime-adventure/readme.md @@ -0,0 +1,18 @@ +# ChatGPT Plugin Example - Anime Adventure + +> TODO: WIP + +[![Build Status](https://github.com/transitive-bullshit/chatgpt-plugin-ts/actions/workflows/test.yml/badge.svg)](https://github.com/transitive-bullshit/chatgpt-plugin-ts/actions/workflows/test.yml) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/transitive-bullshit/chatgpt-plugin-ts/blob/main/license) [![Prettier Code Formatting](https://img.shields.io/badge/code_style-prettier-brightgreen.svg)](https://prettier.io) + +- [Intro](#intro) +- [License](#license) + +## Intro + +TODO: WIP + +## License + +MIT © [Travis Fischer](https://transitivebullsh.it) + +If you found this project interesting, please consider [sponsoring me](https://github.com/sponsors/transitive-bullshit) or following me on twitter twitter diff --git a/examples/anime-adventure/src/index.ts b/examples/anime-adventure/src/index.ts new file mode 100644 index 0000000..3cf7602 --- /dev/null +++ b/examples/anime-adventure/src/index.ts @@ -0,0 +1,47 @@ +import { OpenAPIRouter } from '@cloudflare/itty-router-openapi' +import { defineAIPluginManifest } from 'chatgpt-plugin' + +import * as routes from './routes' +import pkg from '../package.json' + +export interface Env { + ANILIST_ACCESS_TOKEN: string + ENVIRONMENT: string +} + +const router = OpenAPIRouter({ + schema: { + info: { + title: pkg.aiPlugin.name, + version: pkg.version + } + } +}) + +router.get('/start', routes.StartGame) + +router.get('/.well-known/ai-plugin.json', (request: Request) => { + const host = request.headers.get('host') + const pluginManifest = defineAIPluginManifest( + { + description_for_human: pkg.description, + name_for_human: pkg.aiPlugin.name, + ...pkg.aiPlugin + }, + { openAPIUrl: `https://${host}/openapi.json` } + ) + + return new Response(JSON.stringify(pluginManifest, null, 2), { + headers: { + 'content-type': 'application/json;charset=UTF-8' + } + }) +}) + +// 404 for everything else +router.all('*', () => new Response('Not Found.', { status: 404 })) + +export default { + fetch: (request: Request, env: Env, ctx: ExecutionContext) => + router.handle(request, env, ctx) +} diff --git a/examples/anime-adventure/src/routes.ts b/examples/anime-adventure/src/routes.ts new file mode 100644 index 0000000..8684453 --- /dev/null +++ b/examples/anime-adventure/src/routes.ts @@ -0,0 +1,115 @@ +import { OpenAPIRoute, Query, Str } from '@cloudflare/itty-router-openapi' +import Anilist from 'anilist-node' + +// import { isValidChatGPTIPAddress } from 'chatgpt-plugin' +import * as types from './types' +import * as utils from './utils' + +export class StartGame extends OpenAPIRoute { + static schema = { + summary: + 'Starts a new game of Anime Adventure. The user must provide the name of an Anime that they want to base the game around. You should use the anime metadata that is returned to start a story set in the world of this anime.', + parameters: { + anime: Query( + new Str({ + description: 'Name of the anime show', + example: 'cowboy bebop' + }), + { + required: true + } + ) + }, + responses: { + '200': { + schema: { + anime: { + id: new Str(), + title: new Str({ + description: 'Official title of the anime' + }), + + description: new Str({ + description: "Overview of the show and it's plot" + }), + + genres: [new Str()], + tags: [new Str()], + characters: [new Str()], + reviews: [ + { + summary: new Str(), + body: new Str() + } + ] + } + } + } + } + } + + async handle(request: Request, env: any, _ctx, data: Record) { + const anilistAccessToken = env.ANILIST_ACCESS_TOKEN + if (!anilistAccessToken) { + return new Response('ANILIST_ACCESS_TOKEN not set', { status: 500 }) + } + + const ip = request.headers.get('Cf-Connecting-Ip') + if (!ip) { + console.warn('search error missing IP address') + return new Response('invalid source IP', { status: 500 }) + } + + // TODO: protect against abuse in prod + // if (env.ENVIRONMENT === 'production' && !isValidChatGPTIPAddress(ip)) { + // // console.warn('search error invalid IP address', ip) + // return new Response(`Forbidden`, { status: 403 }) + // } + + const openaiUserLocaleInfo = request.headers.get( + 'openai-subdivision-1-iso-code' + ) + const { anime: query } = data + console.log() + console.log() + console.log('>>> search', `${query} (${openaiUserLocaleInfo}, ${ip})`) + console.log() + + const anilist = new Anilist(anilistAccessToken) + const r = (await anilist.search('anime', query)) as types.AnimeSearchResults + + if (!r.media?.length) { + return new Response(`Anime "${query}" not found`, { status: 404 }) + } + + const media = r.media[0] + const anime = await anilist.media.anime(media.id) + + const animeMetadata = { + ...utils.pick(anime, 'id', 'description', 'genres'), + title: + anime.title?.userPreferred || + anime.title?.english || + anime.title?.native || + anime.title?.romaji || + query, + // coverImage: anime.coverImage?.large, + // bannerImage: anime.bannerImage, + tags: anime.tags?.map((tag) => tag.name), + characters: anime.characters?.map((character) => character.name), + reviews: anime.reviews + ?.slice(0, 2) + .map((review) => utils.pick(review, 'summary', 'body')) + } + + console.log() + console.log() + console.log('<<< search', `${query} (${openaiUserLocaleInfo}, ${ip})`) + + return new Response(JSON.stringify({ anime: animeMetadata }, null, 2), { + headers: { + 'content-type': 'application/json;charset=UTF-8' + } + }) + } +} diff --git a/examples/anime-adventure/src/types.ts b/examples/anime-adventure/src/types.ts new file mode 100644 index 0000000..098b664 --- /dev/null +++ b/examples/anime-adventure/src/types.ts @@ -0,0 +1,24 @@ +export interface AnimeSearchResults { + pageInfo: PageInfo + media: Media[] +} + +export interface Media { + id: number + title: Title +} + +export interface Title { + romaji: string + english: null | string + native: string + userPreferred: string +} + +export interface PageInfo { + total: number + currentPage: number + lastPage: number + hasNextPage: boolean + perPage: number +} diff --git a/examples/anime-adventure/src/utils.ts b/examples/anime-adventure/src/utils.ts new file mode 100644 index 0000000..681766a --- /dev/null +++ b/examples/anime-adventure/src/utils.ts @@ -0,0 +1,11 @@ +export function pick(obj: T, ...keys: string[]): U { + return Object.fromEntries( + keys.filter((key) => key in obj).map((key) => [key, obj[key]]) + ) as U +} + +export function omit(obj: T, ...keys: string[]): U { + return Object.fromEntries( + Object.entries(obj).filter(([key]) => !keys.includes(key)) + ) as U +} diff --git a/examples/anime-adventure/tsconfig.json b/examples/anime-adventure/tsconfig.json new file mode 100644 index 0000000..59babea --- /dev/null +++ b/examples/anime-adventure/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "composite": true, + "outDir": "build", + "tsBuildInfoFile": "build/.tsbuildinfo", + "emitDeclarationOnly": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts", "package.json"], + "exclude": ["node_modules"], + "references": [{ "path": "../../packages/chatgpt-plugin/tsconfig.json" }] +} diff --git a/examples/anime-adventure/wrangler.example.toml b/examples/anime-adventure/wrangler.example.toml new file mode 100644 index 0000000..a481c05 --- /dev/null +++ b/examples/anime-adventure/wrangler.example.toml @@ -0,0 +1,6 @@ +name = "chatgpt-plugin-anime-adventure" +main = "src/index.ts" +compatibility_date = "2023-04-04" + +[vars] +ANILIST_ACCESS_TOKEN="TODO" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35c1c1e..a1a0473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,21 @@ importers: specifier: ^2.14.0 version: 2.14.0 + examples/anime-adventure: + dependencies: + '@cloudflare/itty-router-openapi': + specifier: ^0.0.15 + version: 0.0.15 + anilist-node: + specifier: ^1.13.2 + version: link:../../../../temp/anilist-node + chatgpt-plugin: + specifier: workspace:../../packages/chatgpt-plugin + version: link:../../packages/chatgpt-plugin + zod: + specifier: ^3.21.4 + version: 3.21.4 + examples/ascii-art: dependencies: '@cloudflare/itty-router-openapi': @@ -1404,7 +1419,7 @@ packages: '@octokit/request-error': 3.0.3 '@octokit/types': 9.0.0 is-plain-object: 5.0.0 - node-fetch: 2.6.7 + node-fetch: 2.6.9 universal-user-agent: 6.0.0 transitivePeerDependencies: - encoding @@ -4578,6 +4593,18 @@ packages: whatwg-url: 5.0.0 dev: true + /node-fetch@2.6.9: + resolution: {integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: true + /node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'}