Merge pull request #18 from bertybuttface/main

A large, non-atomic pull request to get us back in sync
This commit is contained in:
Jake Coppinger 2023-01-01 21:59:19 +11:00 committed by GitHub
commit 80b6927fbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 192 additions and 131 deletions

View File

@ -68,6 +68,8 @@ USER pptruser
VOLUME /storage VOLUME /storage
ENV DATA_PATH="/storage"
# We run a fake display and run our script. # We run a fake display and run our script.
# Start script on Xvfb # Start script on Xvfb
CMD xvfb-run --server-args="-screen 0 1024x768x24" yarn start CMD xvfb-run --server-args="-screen 0 1024x768x24" yarn start

View File

@ -17,6 +17,8 @@ You should not be using this ChatGPT account while the bot is using it, because
If your OpenAI account uses Google Auth, you shouldn't encounter any of the more complicated Recaptchas — and can avoid using paid third-party CAPTCHA solving providers. To use Google auth, make sure your OpenAI account is using Google and then set IS_GOOGLE_LOGIN to true. If your OpenAI account uses Google Auth, you shouldn't encounter any of the more complicated Recaptchas — and can avoid using paid third-party CAPTCHA solving providers. To use Google auth, make sure your OpenAI account is using Google and then set IS_GOOGLE_LOGIN to true.
If you want to get an access token without exposing your password to this bot you can follow [how-to-get-an-access-token-for-element-riot-matrix](https://webapps.stackexchange.com/questions/131056/how-to-get-an-access-token-for-element-riot-matrix).
# Usage # Usage
- Create an (encrypted if enabled) room - Create an (encrypted if enabled) room
- Add the bot - Add the bot
@ -24,31 +26,36 @@ If your OpenAI account uses Google Auth, you shouldn't encounter any of the more
# Features # Features
- Shows typing indicator as ChatGPT is thinking! - Shows typing indicator as ChatGPT is thinking!
- Doesn't yet support encryption - Supports encryption
- Two lines of code can be uncommented to enable it, however "unable to decrypt" messages appear
- If you have time to look into fixing this PRs very welcome :)
# Setting up the account # Setting up the account
- Create a new Matrix account on Matrix.org (or your favourite server) - Create a new Matrix account on Matrix.org (or your favourite server)
- Go to the settings and get the access token - Go to the settings and get the access token
- Add the details to your environment vars. One way of doing this is adding this to a file called `.env`: - Add the details to your environment vars. One way of doing this is adding this to a file called `.env`:
``` ```
# https://matrix.org if your account is on matrix.org. # ChatGPT Settings (required)
MATRIX_HOMESERVER_URL=
MATRIX_ACCESS_TOKEN=
OPENAI_EMAIL= OPENAI_EMAIL=
OPENAI_PASSWORD= OPENAI_PASSWORD=
IS_GOOGLE_LOGIN=true OPENAI_LOGIN_TYPE="google"
# With the @ and :DOMAIN, ie @SOMETHING:DOMAIN # Matrix Static Settings (required, see notes)
# Defaults to "https://matrix.org"
MATRIX_HOMESERVER_URL=
# With the @ and :DOMAIN, ie @SOMETHING:DOMAIN, needs to always be set
MATRIX_BOT_USERNAME= MATRIX_BOT_USERNAME=
# Set `MATRIX_BOT_PASSWORD` the bot will print an `MATRIX_ACCESS_TOKEN` to the terminal
MATRIX_ACCESS_TOKEN=
# Once `MATRIX_ACCESS_TOKEN` is set this is no longer used.
MATRIX_BOT_PASSWORD= MATRIX_BOT_PASSWORD=
MATRIX_AUTO_JOIN=true
MATRIX_ENCRYPTION=true
# needs to be ./storage/ if you aren't using Docker or /storage/ if you are. # Matrix Configurable Settings Defaults (optional)
DATA_PATH=/storage/ MATRIX_DEFAULT_PREFIX="!chatgpt " # Leave prefix blank to reply to all messages
MATRIX_DEFAULT_PREFIX_REPLY=false
# Matrix Feature Flags (optional)
MATRIX_AUTOJOIN=true
MATRIX_ENCRYPTION=true
MATRIX_THREADS=true
``` ```
# Discussion # Discussion
@ -70,7 +77,7 @@ recomend following the prompts at https://element.io/get-started to download and
``` ```
docker build . -t matrix-chatgpt-bot docker build . -t matrix-chatgpt-bot
docker run --cap-add=SYS_ADMIN -it -v ./storage:/storage matrix-chatgpt-bot docker run -it -v /full-path-not-relative-path/storage:/storage matrix-chatgpt-bot
``` ```
Note: Without -it flags in the command above you won't be able to stop the container using Ctrl-C Note: Without -it flags in the command above you won't be able to stop the container using Ctrl-C

View File

@ -15,11 +15,13 @@
"typecheck": "npx tsc" "typecheck": "npx tsc"
}, },
"dependencies": { "dependencies": {
"chatgpt": "^3.3.6", "chatgpt": "^3.3.8",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"matrix-bot-sdk": "^0.6.2", "matrix-bot-sdk": "^0.6.3",
"puppeteer": "^19.4.1", "puppeteer": "^19.4.1",
"typescript": "^4.5.2" "typescript": "^4.5.2",
"znv": "^0.3.2",
"zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
} }

View File

@ -1,65 +0,0 @@
import * as dotenv from 'dotenv';
// Support .env file
dotenv.config();
/**
* How to get access token:
* https://webapps.stackexchange.com/questions/131056/how-to-get-an-access-token-for-element-riot-matrix
*/
export const accessToken = process.env.MATRIX_ACCESS_TOKEN as string;
export const homeserverUrl = process.env.MATRIX_HOMESERVER_URL as string;
/** The full username: eg @bot:server.com */
export const matrixBotUsername = process.env.MATRIX_BOT_USERNAME as string;
export const matrixBotPassword = process.env.MATRIX_BOT_PASSWORD as string;
export const matrixAutojoin = process.env.MATRIX_AUTO_JOIN && process.env.MATRIX_AUTO_JOIN.toLowerCase() === "true" as string;
export const matrixEncryption = process.env.MATRIX_ENCRYPTION && process.env.MATRIX_ENCRYPTION.toLowerCase() === "true" as string;
export const dataPath = process.env.DATA_PATH as string;
/** ChatGPT specific stuff */
export const openAiEmail = process.env.OPENAI_EMAIL as string;
export const openAiPassword = process.env.OPENAI_PASSWORD as string;
export const isGoogleLogin = process.env.IS_GOOGLE_LOGIN && process.env.IS_GOOGLE_LOGIN.toLowerCase() === "true";
if(dataPath === undefined) {
console.error("DATA_PATH env variable is undefined");
process.exit(1);
}
if(homeserverUrl === undefined) {
console.error("MATRIX_HOMESERVER_URL env variable is undefined");
process.exit(1);
}
if(accessToken === undefined) {
console.error("MATRIX_ACCESS_TOKEN env variable is undefined, set it to empty string to use username and password");
process.exit(1);
if(matrixBotUsername === undefined) {
console.error("MATRIX_BOT_USERNAME env variable is undefined, set it to empty string to use access token");
process.exit(1);
}
if(matrixBotPassword === undefined) {
console.error("MATRIX_BOT_PASSWORD env variable is undefined, set it to empty string to use access token");
process.exit(1);
}
}
if(matrixAutojoin === undefined) {
console.error("MATRIX_AUTO_JOIN env variable is undefined");
process.exit(1);
}
if(matrixEncryption === undefined) {
console.error("MATRIX_ENCRYPTION env variable is undefined");
process.exit(1);
}
if(openAiEmail === undefined) {
console.error("OPENAI_EMAIL env variable is undefined");
process.exit(1);
}
if(openAiPassword === undefined) {
console.error("OPENAI_PASSWORD env variable is undefined");
process.exit(1);
}
if(isGoogleLogin === undefined) {
console.error("IS_GOOGLE_LOGIN env variable is undefined");
process.exit(1);
}

50
src/env.ts Normal file
View File

@ -0,0 +1,50 @@
import { parseEnv } from "znv";
import { z } from "zod";
import * as dotenv from 'dotenv';
dotenv.config();
export const {
DATA_PATH,
/** Matrix Bot Settings */
MATRIX_HOMESERVER_URL,
MATRIX_ACCESS_TOKEN,
MATRIX_BOT_USERNAME,
MATRIX_BOT_PASSWORD,
/** Matrix Bot Features */
MATRIX_AUTOJOIN,
MATRIX_ENCRYPTION,
MATRIX_THREADS,
/** Matrix Bot Runtime Config */
MATRIX_DEFAULT_PREFIX,
MATRIX_DEFAULT_PREFIX_REPLY,
MATRIX_DEFAULT_REQUIRE_MENTION,
MATRIX_DEFAULT_REQUIRE_MENTION_IN_DM,
MATRIX_DEFAULT_REQUIRE_MENTION_IN_REPLY,
/** ChatGPT Settings */
OPENAI_EMAIL,
OPENAI_PASSWORD,
OPENAI_LOGIN_TYPE,
CHATGPT_TIMEOUT
} = parseEnv(process.env, {
DATA_PATH: {schema: z.string().default("./storage"), description: "Set to /storage/ if using docker, ./storage if running without"},
/** Matrix Bot Settings */
MATRIX_HOMESERVER_URL: {schema: z.string().default("https://matrix.org")},
MATRIX_ACCESS_TOKEN: {schema: z.string().optional(), description: "Set MATRIX_BOT_USERNAME & MATRIX_BOT_PASSWORD to print ACCESS_TOKEN or follow https://webapps.stackexchange.com/questions/131056/how-to-get-an-access-token-for-element-riot-matrix"},
MATRIX_BOT_USERNAME: {schema: z.string().min(3), description: "Set full username: eg @bot:server.com"},
MATRIX_BOT_PASSWORD: {schema: z.string().optional(), description: "Set AccessToken which supersedes MATRIX_BOT_PASSWORD"},
/** Matrix Bot Features */
MATRIX_AUTOJOIN: {schema: z.boolean().default(true)},
MATRIX_ENCRYPTION: {schema: z.boolean().default(true)},
MATRIX_THREADS: {schema: z.boolean().default(true)},
/** Matrix Bot Runtime Config */
MATRIX_DEFAULT_PREFIX: {schema: z.string().default(""), description: "Set this to empty string if you don't want to use it. Trailing space matters."},
MATRIX_DEFAULT_PREFIX_REPLY: {schema: z.boolean().default(false)},
MATRIX_DEFAULT_REQUIRE_MENTION: {schema: z.boolean().default(false)},
MATRIX_DEFAULT_REQUIRE_MENTION_IN_DM: {schema: z.boolean().default(false)},
MATRIX_DEFAULT_REQUIRE_MENTION_IN_REPLY: {schema: z.boolean().default(false)},
/** ChatGPT Settings */
OPENAI_EMAIL: {schema: z.string().min(3)},
OPENAI_PASSWORD: {schema: z.string().min(1)},
OPENAI_LOGIN_TYPE: {schema: z.enum(["google", "openai", "microsoft"]).default("google")},
CHATGPT_TIMEOUT: {schema: z.number().default(2 * 60 * 1000)}
});

View File

@ -1,7 +1,8 @@
import { ChatGPTAPIBrowser } from "chatgpt"; import { ChatGPTAPIBrowser, ChatResponse } from "chatgpt";
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixClient } from "matrix-bot-sdk";
import { matrixBotUsername } from "./config.js"; import { CHATGPT_TIMEOUT, MATRIX_BOT_USERNAME, MATRIX_DEFAULT_PREFIX_REPLY, MATRIX_DEFAULT_PREFIX} from "./env.js";
import { isEventAMessage } from "./utils.js"; import { RelatesTo, StoredConversation } from "./interfaces.js";
import { isEventAMessage, sendError, sendThreadReply } from "./utils.js";
/** /**
* Run when *any* room event is received. The bot only sends a message if needed. * Run when *any* room event is received. The bot only sends a message if needed.
@ -10,41 +11,51 @@ import { isEventAMessage } from "./utils.js";
*/ */
export async function handleRoomEvent(client: MatrixClient, chatGPT: ChatGPTAPIBrowser): Promise<(roomId: string, event: any) => Promise<void>> { export async function handleRoomEvent(client: MatrixClient, chatGPT: ChatGPTAPIBrowser): Promise<(roomId: string, event: any) => Promise<void>> {
return async (roomId: string, event: any) => { return async (roomId: string, event: any) => {
if (event.sender === matrixBotUsername) {
return;
}
if (Date.now() - event.origin_server_ts > 10000) {
// Don't send notifications for old events if the bot shuts down for some reason.
return;
}
if (isEventAMessage(event)) { if (isEventAMessage(event)) {
const question: string = event.content.body;
if (question === undefined) {
await client.sendReadReceipt(roomId, event.event_id);
await client.sendText(roomId,
`Question is undefined. I don't currently support encrypted chats, maybe that's the issue?
Please add me to an unencrypted chat.`);
await client.sendReadReceipt(roomId, event.event_id);
return;
}
await client.sendReadReceipt(roomId, event.event_id);
await client.setTyping(roomId, true, 10000)
try { try {
// timeout after 2 minutes (which will also abort the underlying HTTP request) const relatesTo: RelatesTo | undefined = event.content["m.relates_to"];
const result = await chatGPT.sendMessage(question, { const rootEventId: string = (relatesTo !== undefined && relatesTo.event_id !== undefined) ? relatesTo.event_id : event.event_id;
timeoutMs: 2 * 60 * 1000 const storedValue: string = await client.storageProvider.readValue('gpt-' + rootEventId)
}) const storedConversation: StoredConversation = (storedValue !== undefined) ? JSON.parse(storedValue) : undefined;
await client.setTyping(roomId, false, 500) const config = (storedConversation !== undefined && storedConversation.config !== undefined) ? storedConversation.config : undefined;
await client.sendText(roomId, `${result.response}`); const MATRIX_PREFIX_REPLY = (config === undefined) ? MATRIX_DEFAULT_PREFIX_REPLY : config.MATRIX_PREFIX_REPLY
await client.sendReadReceipt(roomId, event.event_id); const shouldBePrefixed: boolean = ((Boolean(MATRIX_DEFAULT_PREFIX)) && (MATRIX_PREFIX_REPLY || (relatesTo === undefined)));
if (event.sender === MATRIX_BOT_USERNAME) return; // Don't reply to ourself
if (Date.now() - event.origin_server_ts > 10000) return; // Don't reply to old messages
if (shouldBePrefixed && !event.content.body.startsWith(MATRIX_DEFAULT_PREFIX)) return; // Don't reply without prefix if prefixed
await Promise.all([client.sendReadReceipt(roomId, event.event_id), client.setTyping(roomId, true, 10000)]);
const trimLength: number = shouldBePrefixed ? MATRIX_DEFAULT_PREFIX.length : 0
const question: string = event.content.body.slice(trimLength).trimStart();
if ((question === undefined) || !question) {
await sendError(client, "Error with question: " + question, roomId, event.event_id);
return;
}
let result: ChatResponse
if (storedConversation !== undefined) {
result = await chatGPT.sendMessage(question, {
timeoutMs: CHATGPT_TIMEOUT,
conversationId: storedConversation.conversationId,
parentMessageId: storedConversation.messageId
});
} else {
result = await chatGPT.sendMessage(question, {timeoutMs: CHATGPT_TIMEOUT});
}
await Promise.all([client.setTyping(roomId, false, 500), sendThreadReply(client, `${result.response}`, roomId, rootEventId)]);
await client.storageProvider.storeValue('gpt-' + rootEventId, JSON.stringify({
conversationId: result.conversationId,
messageId: result.messageId,
config: ((storedConversation !== undefined && storedConversation.config !== undefined) ? storedConversation.config : {}),
}));
} catch (e) { } catch (e) {
await client.setTyping(roomId, false, 500)
await client.sendText(roomId, `ChatGPT returned an error :(`);
await client.sendReadReceipt(roomId, event.event_id);
console.error("ChatGPS returned an error:");
console.error(e); console.error(e);
await sendError(client, "Bot error, terminating.", roomId, event.event_id);
} }
} }
} }

View File

@ -7,7 +7,7 @@ import {
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
import * as path from "path"; import * as path from "path";
import { dataPath, openAiEmail, openAiPassword, isGoogleLogin, homeserverUrl, accessToken, matrixAutojoin, matrixBotPassword, matrixBotUsername, matrixEncryption } from './config.js' import { DATA_PATH, OPENAI_EMAIL, OPENAI_PASSWORD, OPENAI_LOGIN_TYPE, MATRIX_HOMESERVER_URL, MATRIX_ACCESS_TOKEN, MATRIX_AUTOJOIN, MATRIX_BOT_PASSWORD, MATRIX_BOT_USERNAME, MATRIX_ENCRYPTION } from './env.js'
import { parseMatrixUsernamePretty } from './utils.js'; import { parseMatrixUsernamePretty } from './utils.js';
import { handleRoomEvent } from './handlers.js'; import { handleRoomEvent } from './handlers.js';
import { ChatGPTAPIBrowser } from 'chatgpt' import { ChatGPTAPIBrowser } from 'chatgpt'
@ -22,34 +22,35 @@ LogService.setLevel(LogLevel.INFO);
// LogService.muteModule("Metrics"); // LogService.muteModule("Metrics");
LogService.trace = LogService.debug; LogService.trace = LogService.debug;
const storage = new SimpleFsStorageProvider(path.join(dataPath, "bot.json")); // /storage/bot.json const storage = new SimpleFsStorageProvider(path.join(DATA_PATH, "bot.json")); // /storage/bot.json
// Prepare a crypto store if we need that // Prepare a crypto store if we need that
let cryptoStore: ICryptoStorageProvider; let cryptoStore: ICryptoStorageProvider;
if (matrixEncryption) { if (MATRIX_ENCRYPTION) {
cryptoStore = new RustSdkCryptoStorageProvider(path.join(dataPath, "encrypted")); // /storage/encrypted cryptoStore = new RustSdkCryptoStorageProvider(path.join(DATA_PATH, "encrypted")); // /storage/encrypted
} }
async function main() { async function main() {
const botUsernameWithoutDomain = parseMatrixUsernamePretty(matrixBotUsername); const botUsernameWithoutDomain = parseMatrixUsernamePretty(MATRIX_BOT_USERNAME);
if (!accessToken){ if (!MATRIX_ACCESS_TOKEN){
const authedClient = await (new MatrixAuth(homeserverUrl)).passwordLogin(botUsernameWithoutDomain, matrixBotPassword); const authedClient = await (new MatrixAuth(MATRIX_HOMESERVER_URL)).passwordLogin(botUsernameWithoutDomain, MATRIX_BOT_PASSWORD);
console.log(authedClient.homeserverUrl + " token: \n" + authedClient.accessToken) console.log(authedClient.homeserverUrl + " token: \n" + authedClient.accessToken)
console.log("Set MATRIX_ACCESS_TOKEN to above token, MATRIX_ACCESS_USERNAME and MATRIX_ACCESS_PASSWORD can now be blank") console.log("Set MATRIX_ACCESS_TOKEN to above token, MATRIX_BOT_PASSWORD can now be blank")
return; return;
} }
const client = new MatrixClient(homeserverUrl, accessToken, storage); const client = new MatrixClient(MATRIX_HOMESERVER_URL, MATRIX_ACCESS_TOKEN, storage, cryptoStore);
// use puppeteer to bypass cloudflare (headful because of captchas) // use puppeteer to bypass cloudflare (headful because of captchas)
const chatGPT = new ChatGPTAPIBrowser({ const chatGPT = new ChatGPTAPIBrowser({
email: openAiEmail, email: OPENAI_EMAIL,
password: openAiPassword, password: OPENAI_PASSWORD,
isGoogleLogin: isGoogleLogin isGoogleLogin: (OPENAI_LOGIN_TYPE == "google"),
isMicrosoftLogin: (OPENAI_LOGIN_TYPE == "microsoft")
}) })
await chatGPT.initSession() await chatGPT.initSession()
// Automatically join rooms the bot is invited to // Automatically join rooms the bot is invited to
if (matrixAutojoin) { if (MATRIX_AUTOJOIN) {
AutojoinRoomsMixin.setupOnClient(client); AutojoinRoomsMixin.setupOnClient(client);
} }
@ -61,10 +62,9 @@ async function main() {
client.on("room.join", async (roomId: string, _event: any) => { client.on("room.join", async (roomId: string, _event: any) => {
LogService.info("index", `Bot joined room ${roomId}`); LogService.info("index", `Bot joined room ${roomId}`);
await client.sendMessage(roomId, { await client.sendMessage(roomId, {
"msgtype": "m.notice", "msgtype": "m.notice",
"body": `👋 Hello, I'm the ChatGPT bot! Encrypted message support: ${ matrixEncryption }`, "body": `👋 Hello, I'm the ChatGPT bot! Encrypted message support: ${MATRIX_ENCRYPTION }`,
}); });
}); });

View File

@ -4,12 +4,28 @@ type CommonMatrixEventFields = {
event_id: string event_id: string
} }
export type RelatesTo = {
// The m.thread relationship structure
event_id: string // note: always references the *thread root*
rel_type: "m.thread" | string
// The rich reply structure (for non thread aware client fallback)
"m.in_reply_to"?: {
// The most recent message known to the client in the thread.
// Something easy to render for other client like a `m.room.message` event.
"event_id": string
},
// A flag to denote that this is a thread with reply fallback
"is_falling_back": string
}
export type MessageEvent = CommonMatrixEventFields & { export type MessageEvent = CommonMatrixEventFields & {
content: { content: {
body: string, body: string,
msgtype: "m.text" | string msgtype: "m.text" | string
"org.matrix.msc1767.text"?: string "org.matrix.msc1767.text"?: string,
"m.relates_to"?: RelatesTo
}, },
raw: any,
"type": "m.room.message", "type": "m.room.message",
unsigned: Object; unsigned: Object;
} }
@ -51,3 +67,13 @@ export type MatrixInviteEvent = CommonMatrixEventFields & {
event_id: string event_id: string
} }
export type MembershipType = 'leave' | 'invite' | 'join' export type MembershipType = 'leave' | 'invite' | 'join'
export type StoredConversationConfig = {
MATRIX_PREFIX_REPLY?: boolean;
}
export type StoredConversation = {
conversationId: string;
messageId: string;
config: StoredConversationConfig;
}

View File

@ -1,3 +1,4 @@
import { MatrixClient } from "matrix-bot-sdk";
import { MessageEvent } from "./interfaces.js"; import { MessageEvent } from "./interfaces.js";
export function parseMatrixUsernamePretty(matrix_username: string): string { export function parseMatrixUsernamePretty(matrix_username: string): string {
@ -11,3 +12,30 @@ export function parseMatrixUsernamePretty(matrix_username: string): string {
export function isEventAMessage(event: any): event is MessageEvent { export function isEventAMessage(event: any): event is MessageEvent {
return event.type === 'm.room.message' return event.type === 'm.room.message'
} }
export async function sendError(client: MatrixClient, text: string, roomId: string, eventId: string): Promise<void> {
Promise.all([client.setTyping(roomId, false, 500), client.sendText(roomId, text), client.sendReadReceipt(roomId, eventId)]);
}
/**
* Send a thread reply.
* @param client Matrix client
* @param param1 Object containing text, root_event_id and roomId. root_event_id is the event_id
* of the message the thread "replying" to.
*/
export async function sendThreadReply(client: MatrixClient, text: string, roomId: string, root_event_id: string): Promise<void> {
const content = {
body: text,
msgtype: "m.text",
"org.matrix.msc1767.text": text,
"m.relates_to": {
event_id: root_event_id,
is_falling_back: true,
"m.in_reply_to": {
"event_id": root_event_id
},
rel_type: "m.thread"
}
}
await client.sendEvent(roomId, "m.room.message", content);
}