initial commit
This commit is contained in:
commit
4d98e3e67a
23
README.md
Normal file
23
README.md
Normal 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
11
config.js
Normal 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
185
geminibot.ts
Normal 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
40
mapping.json
Normal 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
19
package.json
Normal 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
13
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user