From 1aeb95dc161fb0a88e081a0aec28140f7b9c64b9 Mon Sep 17 00:00:00 2001 From: Tymon Date: Wed, 14 Dec 2022 00:05:32 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + .vscode/settings.json | 8 ++ LICENSE | 24 ++++ README.md | 3 + deno.json | 8 ++ mod.ts | 4 + src/Chat.ts | 139 ++++++++++++++++++++++ src/Client.ts | 267 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 455 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deno.json create mode 100644 mod.ts create mode 100644 src/Chat.ts create mode 100644 src/Client.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8560a4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +cert.pem +test.ts \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0cf1ca8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "deno.enable": true, + "deno.unstable": true, + "[typescript]": { + "editor.tabSize": 2, + "editor.detectIndentation": false + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b0a72fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +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 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. + +For more information, please refer to \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f0191a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# chaideno (name pending (probably forever)) + +The most blazingly good (not really) character.ai library for Deno! \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..1d3578d --- /dev/null +++ b/deno.json @@ -0,0 +1,8 @@ +{ + "fmt": { + "options": { + "indentWidth": 2, + "useTabs": false + } + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..97bb1bb --- /dev/null +++ b/mod.ts @@ -0,0 +1,4 @@ +import Client from "./src/Client.ts"; +import Chat from "./src/Chat.ts"; + +export { Client, Chat }; \ No newline at end of file diff --git a/src/Chat.ts b/src/Chat.ts new file mode 100644 index 0000000..f6f1c1f --- /dev/null +++ b/src/Chat.ts @@ -0,0 +1,139 @@ +// deno-lint-ignore-file no-explicit-any + +import Client from "./Client.ts"; + +interface MessageHistory { + messages: { + id: number; + text: string; + src__name: string; + src__user__username: string; + src__user__id: number; + tgt__user__id: number; + src__is_human: boolean; + src__character__avatar_file_name: string | null; + is_alternative: boolean; + image_rel_path: string; + image_prompt_text: string; + image_description_type: string; + responsible_user__username: string | null; + deleted: boolean | null; // This is probably a boolean??? + src_char: { + participant: { name: string }; + avatar_file_name: string | null; + }; + }[]; + + has_more: boolean; + next_page: number; +} + +interface Reply { + replies: { + text: string; + id: number; + }[]; + src_char: { + participant: { name: string }; + avatar_file_name: string; + }; + is_final_chunk: boolean; + last_user_msg_id: number; +} + +export default class Chat { + #client: Client; + + public characterId: string; + public characterInternalId: string; + public historyId: string; + + constructor(client: Client, id: string, history: any) { + this.#client = client; + + this.characterId = id; + this.historyId = history.external_id; + this.characterInternalId = history.participants.find( + (participant: any) => participant.is_human === false + ).user.username; + } + + /** + * Fetches message history + * @returns Message history + */ + public async fetchMessageHistory(): Promise { + const url = new URL("https://beta.character.ai/chat/history/msgs/user/"); + url.searchParams.set("history_external_id", this.historyId); + + const res = await this.#client.fetch(url, { + headers: { + Authorization: `Token ${this.#client.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + }); + const messageHistory = await res.json(); + + return messageHistory; + } + + /** + * Sends a message to the character + * @param message The message you want to send + * @returns Object with replies + */ + public async sendMessage(message: string): Promise { + const payload = { + history_external_id: this.historyId, + character_external_id: this.characterId, + text: message, + tgt: this.characterInternalId, + ranking_method: "random", + faux_chat: false, + staging: false, + model_server_address: null, + override_prefix: null, + override_rank: null, + rank_candidates: null, + filter_candidates: null, + prefix_limit: null, + prefix_token_limit: null, + livetune_coeff: null, + stream_params: null, + enable_tti: true, + initial_timeout: null, + insert_beginning: null, + translate_candidates: null, + stream_every_n_steps: 16, + chunks_to_pad: 8, + is_proactive: false, + }; + + const res = await this.#client.fetch( + "https://beta.character.ai/chat/streaming/", + { + body: JSON.stringify(payload), + method: "POST", + headers: { + Authorization: `Token ${this.#client.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + const reader = res.body?.getReader(); + + if (!reader) throw new Error("How."); // Let's hope no one ever sees this + + let reply = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + reply = new TextDecoder("utf-8").decode(value); + } + + return JSON.parse(reply); + } +} diff --git a/src/Client.ts b/src/Client.ts new file mode 100644 index 0000000..c8091f7 --- /dev/null +++ b/src/Client.ts @@ -0,0 +1,267 @@ +import Chat from "./Chat.ts"; + +interface FetchOptions extends RequestInit { + client?: Deno.HttpClient; +} + +interface Category { + name: string; + description: string; +} + +interface Featured { + characters: string[]; + name: string; +} + +interface CharacterInfo { + external_id: string; + title: string; + name: string; + visibility: string; + copyable: false; + greeting: string; + description: string; + identifier: string; + avatar_file_name: string; + songs: unknown[]; + img_gen_enabled: false; + base_img_prompt: string; + img_prompt_regex: string; + strip_img_prompt_from_msg: boolean; + user__username: string; + participant__name: string; + participant__num_interactions: number; + participant__user__username: string; + voice_id: number; + usage: string; +} + +interface SearchResult { + external_id: string; + title: string; + greeting: string; + description: string; + avatar_file_name: string; + visibility: string; + copyable: boolean; + participant__name: string; + participant__num_interactions: number; + user__id: number; + user__username: string; + img_gen_enabled: boolean; + priority: number; + search_score: number; +} + +export default class Client { + public token?: string; + #client?: Deno.HttpClient; + + /** + * Initalizes a HTTP(S) proxy for each request (Requires --unstable) + * @param proxyUrl URL of the proxy + * @param certPath Path to a certificate file + */ + public async intitalizeProxy(proxyUrl: string, certPath: string | URL) { + this.#client = Deno.createHttpClient({ + caCerts: [await Deno.readTextFile(certPath)], + proxy: { url: proxyUrl }, + }); + } + + async fetch( + input: string | URL | Request, + options?: FetchOptions | undefined + ) { + if (this.#client) + options = { + client: this.#client, + ...options, + }; + return await fetch(input, options); + } + + /** + * Authenticates with character.ai + * @param authToken The authorization token + */ + public async authenticate(authToken: string) { + const res = await this.fetch( + "https://beta.character.ai/dj-rest-auth/auth0/", + { + body: JSON.stringify({ + access_token: authToken, + }), + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + const json = await res.json(); + + this.token = json.key; + if (!this.token) + throw new Error("Authentication failed, your token might be invalid"); + } + + /** + * Fetches a list of categories + * @returns List of categories + */ + public async fetchCategories(): Promise { + const res = await this.fetch( + "https://beta.character.ai/chat/character/categories/" + ); + const { categories } = await res.json(); + + if (!Array.isArray(categories)) + throw new Error("API provided invalid category data"); + + return categories; + } + + /** + * Fetches a list of featured characters + * @returns List of featured characters + */ + public async fetchFeaturedCharacters(): Promise { + const res = await this.fetch( + "https://beta.character.ai/chat/characters/featured/", + { + headers: { + Authorization: `Token ${this.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + const { featured_characters } = await res.json(); + + if (!Array.isArray(featured_characters)) + throw new Error("API provided invalid featured characters data"); + + return featured_characters; + } + + /** + * Fetches information for a character + * @param id Id of the character + * @returns Character information + */ + public async fetchCharacterInfo(id: string): Promise { + const res = await this.fetch( + "https://beta.character.ai/chat/character/info/", + { + body: JSON.stringify({ + external_id: id, + }), + method: "POST", + headers: { + Authorization: `Token ${this.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + const { character } = await res.json(); + + return character; + } + + /** + * Search for characters + * @param query Search query + * @returns Array of characters + */ + public async search(query: string): Promise { + const url = new URL( + "https://beta.character.ai/chat/characters/search/" + ); + url.searchParams.set("query", query); + + const res = await this.fetch(url, { + headers: { + Authorization: `Token ${this.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + }); + const { characters } = await res.json(); + + if (!Array.isArray(characters)) + throw new Error("API provided invalid search response"); + + return characters; + } + + /** + * Creates a new chat history + * @param id Character id + * @returns The raw history to be used with the Chat class + */ + public async createChat(id: string) { + const res = await this.fetch( + "https://beta.character.ai/chat/history/create/", + { + body: JSON.stringify({ + character_external_id: id, + history_external_id: null, + }), + method: "POST", + headers: { + Authorization: `Token ${this.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + const history = await res.json(); + + return history; + } + + /** + * Continues a chat + * @param id Character id + * @returns The raw history to be used with the Chat class or false if the chat doesn't exist + */ + public async continueChat(id: string): Promise { + const res = await this.fetch( + "https://beta.character.ai/chat/history/continue/", + { + body: JSON.stringify({ + character_external_id: id, + history_external_id: null, + }), + method: "POST", + headers: { + Authorization: `Token ${this.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + + const history = await res.json(); + + if (history.status === "No Such History") return false; + + return history; + } + + /** + * Continues a chat or creates a new one if it doesn't exist + * @param id Character id + * @returns The Chat class + */ + public async continueOrCreateChat(id: string): Promise { + let history = await this.continueChat(id); + + if (!history) history = await this.createChat(id); + + return new Chat(this, id, history); + } +}