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