initial commit

This commit is contained in:
ale 2024-07-15 18:36:55 +02:00
commit 4d98e3e67a
6 changed files with 291 additions and 0 deletions

23
README.md Normal file
View File

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

11
config.js Normal file
View File

@ -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
}

185
geminibot.ts Normal file
View File

@ -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)
}
})()

40
mapping.json Normal file
View File

@ -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"
}
}
}
}

19
package.json Normal file
View File

@ -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"
}
}

13
tsconfig.json Normal file
View File

@ -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
}
}