From 4d98e3e67a7a3bc3730bb520fc0674f92cf81fd4 Mon Sep 17 00:00:00 2001 From: ale Date: Mon, 15 Jul 2024 18:36:55 +0200 Subject: [PATCH] initial commit --- README.md | 23 +++++++ config.js | 11 +++ geminibot.ts | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++ mapping.json | 40 +++++++++++ package.json | 19 ++++++ tsconfig.json | 13 ++++ 6 files changed, 291 insertions(+) create mode 100644 README.md create mode 100644 config.js create mode 100644 geminibot.ts create mode 100644 mapping.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c4af18 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# GeminiBot + +### Simple Bot to connect GeminiAI API over one Mastodon instance. + +## Install + + $ yarn + +## Build + + $ yarn build + +## Config + +Edit `config.js` settings + +## Run + + $ yarn start + +## License + +MIT \ No newline at end of file diff --git a/config.js b/config.js new file mode 100644 index 0000000..e6357b5 --- /dev/null +++ b/config.js @@ -0,0 +1,11 @@ +module.exports = { + nick: 'geminibot', // Bot account + index: 'geminibot', // ElasticSearch index + accessToken: '', // API key mastodon + url: 'mastodon.manalejandro.com', // Mastodon domain + elasticNode: 'http://elasticsearch:9200', // ElasticSearch instance + apiKey: '', // API key GeminiAI + model: 'gemini-1.5-pro-latest', // GeminiAI model + initialize: '@geminibot eres un bot de mastodon que puede interactuar con los demás usuarios, el primer parámetro que recibes es la cuenta del usuario y luego el contenido, todos los usuarios pueden contactar contigo y nos lee todo el mundo.', // Prompt for initialize context + maxSize: 20971520 // Max size API request +} \ No newline at end of file diff --git a/geminibot.ts b/geminibot.ts new file mode 100644 index 0000000..7048b5a --- /dev/null +++ b/geminibot.ts @@ -0,0 +1,185 @@ +import { createRestAPIClient, createStreamingAPIClient } from 'masto'; +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { Client } from '@elastic/elasticsearch'; +import Jimp from "jimp"; +import * as config from './config.js'; + +(async () => { + try { + const index = config.nick, + nick = config.index, + rest = createRestAPIClient({ + url: `https://${config.url}`, + accessToken: config.accessToken, + requestInit: { + headers: [ + ['User-Agent', nick] + ] + } + }), + ws = createStreamingAPIClient({ + streamingApiUrl: `wss://${config.url}`, + accessToken: config.accessToken, + retry: true + }), + genAI = new GoogleGenerativeAI(config.apiKey), + model = genAI.getGenerativeModel({ model: config.model }), + client = new Client({ node: config.elasticNode }), + initialize = config.initialize, + getMediaAttachments = async (status) => { + return (await Promise.all(status.mediaAttachments.map(async (attachment: { url: string | URL | Request; type: string; }) => { + const attach = await fetch(attachment.url) + if (attach.ok) { + let body = await attach.arrayBuffer(), + resized: Buffer + if (attachment.type === 'image' && (attachment.url.toString().toLowerCase().endsWith('.png') || attachment.url.toString().toLowerCase().match(/.*\.(jpe?g)$/))) { + const image = await Jimp.read(Buffer.from(body)) + if (image.getWidth() > 512) { + if (image.getMIME() === 'image/jpeg') { + resized = await image.resize(512, Jimp.AUTO).getBufferAsync(Jimp.MIME_JPEG) + } else { + resized = await image.resize(512, Jimp.AUTO).getBufferAsync(Jimp.MIME_PNG) + } + } + } + return { + inlineData: { + data: Buffer.from(resized || body).toString('base64'), + mimeType: attachment.type === 'image' ? attachment.url.toString().toLowerCase().endsWith('.png') ? + 'image/png' : attachment.url.toString().toLowerCase().endsWith('.gif') ? + 'image/gif' : 'image/jpeg' : attachment.type === 'video' ? + 'video/mp4' : attachment.type === 'audio' ? + 'audio/mpeg' : 'application/octet-stream' + } + } + } + }))).filter(attach => attach?.inlineData?.data) + } + for await (const notification of ws.user.notification.subscribe()) { + if (notification.payload['type'] === 'mention' && notification.payload['status']) { + const status = notification.payload['status'] + if (status && status.visibility !== 'direct' && status.account.acct !== nick && status.content.trim() !== '') { + try { + const query = await client.search({ + index: index, + size: 100, + sort: [{ timestamp: { order: 'desc' } }], + query: { + term: { + user: status.account.acct + } + } + }), + hist: any = query.hits && query.hits.hits && query.hits.hits.length > 0 ? query.hits.hits : null + let history = [], + entries = [] + if (hist && hist.length > 0) { + history = hist.map((h: any) => h._source).reduce((acc, h) => h && h.entry && h.entry.length > 0 ? + acc.concat(h.entry.sort((a, b) => a.role < b.role ? 1 : -1)) : acc, []) + } + const content = ('' + status.content).replace(/<[^>]*>?/gm, '').trim() + let parts = [] + if (content.match(new RegExp('^@' + nick + ' .* hilo.*$')) && status.inReplyToId) { + let id: string, s = await rest.v1.statuses.$select(status.id).fetch() + do { + id = s.inReplyToId + s = await rest.v1.statuses.$select(id).fetch() + } while (!!s && s.inReplyToId) + const descendants = [...(await rest.v1.statuses.$select(s.id).context.fetch()).descendants.reverse(), s] + await Promise.all(descendants.map(async s => { + if (s.id !== status.id) { + parts = [] + if (s.mediaAttachments.length > 0) { + const imageParts = await getMediaAttachments(s) + if (imageParts.length > 0) { + parts = parts.concat(imageParts) + } + } + entries.push({ + role: s.account.acct === nick ? 'model' : 'user', parts: parts.length > 0 ? + [{ text: `${s.account.acct}: ${s.content}` }, ...parts] : [{ text: `${s.account.acct}: ${s.content}` }] + }) + } + })) + } + parts = [] + const question = `${status.account.acct}: ${status.content}` + if (status.mediaAttachments.length > 0) { + const imageParts = await getMediaAttachments(status) + if (imageParts.length > 0) { + parts = parts.concat(imageParts) + } + } + while (new Blob([JSON.stringify([...history, ...entries])]).size >= config.maxSize) { + if (history.length === 0) { + entries.shift() + } else { + history.shift() + } + } + const chat = model.startChat({ + history: history && history.length > 0 ? + entries.length > 0 ? + [...history, ...entries] : + history : + [{ role: 'user', parts: [{ text: initialize }] }], + generationConfig: { maxOutputTokens: 220, temperature: 0.7, topP: 0.95, topK: 40 } + }), + result = await chat.sendMessage(parts.length > 0 ? [{ text: question }, ...parts] : [{ text: question }]), + answer = result.response.text() + if (answer && answer.length > 0) { + let statusResponse + for (let i = 0; i < answer.length; i = i + 500) { + statusResponse = await rest.v1.statuses.create({ + status: answer.slice(i, i + 500), + sensitive: false, + visibility: 'public', + inReplyToId: statusResponse && statusResponse.id ? statusResponse.id : status.id, + language: status.language + }) + } + const user = { + role: 'user', parts: parts.length > 0 ? [{ text: question }, ...parts.filter(p => + p.text || p.inlineData?.mimeType === 'image/png' || p.inlineData?.mimeType === 'image/jpeg')] + : [{ text: question }] + }, + bot = { + role: 'model', parts: [{ text: answer }] + } + await client.index({ + index: index, + body: { + user: status.account.acct, + entry: entries ? [user].concat(entries.map(entry => ({ + role: entry.role, parts: entry.parts.filter((p: { text: any; inlineData: { mimeType: string; }; }) => + p.text || p.inlineData?.mimeType === 'image/png' || p.inlineData?.mimeType === 'image/jpeg') + }))).concat([bot]) : [user, bot], + timestamp: new Date() + } + }) + } else { + await rest.v1.statuses.create({ + status: 'No response.', + sensitive: false, + visibility: 'public', + inReplyToId: status.id, + language: status.language + }) + } + } catch (e) { + await rest.v1.statuses.create({ + status: e.message, + sensitive: false, + visibility: 'public', + inReplyToId: status.id, + language: status.language + }) + } + } + } + } + } catch (e) { + console.error(e) + process.exit(1) + } +})() diff --git a/mapping.json b/mapping.json new file mode 100644 index 0000000..7b5f988 --- /dev/null +++ b/mapping.json @@ -0,0 +1,40 @@ +{ + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "@version": { + "type": "text" + }, + "user": { + "type": "keyword" + }, + "role": { + "type": "keyword" + }, + "parts": { + "type": "nested", + "properties": { + "text": { + "type": "text" + }, + "inlineData": { + "type": "object", + "properties": { + "data": { + "type": "text" + }, + "mimeType": { + "type": "keyword" + } + } + } + } + }, + "timestamp": { + "type": "date" + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2f4e22 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "geminibot", + "version": "1.0.0", + "main": "geminibot.js", + "license": "MIT", + "private": true, + "scripts": { + "build": "tsc --build tsconfig.json", + "start": "node geminibot.js" + }, + "dependencies": { + "@elastic/elasticsearch": "^8.14.0", + "@google/generative-ai": "^0.14.1", + "@types/node": "^20.14.10", + "jimp": "^0.22.12", + "masto": "^6.8.0", + "typescript": "^5.5.3" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..739c4ad --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ + "module": "CommonJS", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "strict": false, /* Enable all strict type-checking options. */ + "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": false, /* Disallow inconsistently-cased references to the same file. */ + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + } +}