Improve Telegram formatting and web synthesis

This commit is contained in:
hbrain 2026-05-27 13:47:28 +00:00
parent 605c135b08
commit 0675f9a441
8 changed files with 425 additions and 508 deletions

View file

@ -1,10 +1,10 @@
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
GEMINI_API_KEY=your_gemini_api_key_here
OPENROUTER_API_KEY=your_openrouter_api_key_here OPENROUTER_API_KEY=your_openrouter_api_key_here
SEARXNG_URL=http://192.168.0.225:8080
# Optional: # Optional:
# GEMINI_MODEL=gemini-2.5-flash-lite # OPENROUTER_MODEL=openai/gpt-5.4-mini
# OPENROUTER_MODEL=openrouter/free # Use a vision-capable OpenRouter model if you want image analysis.
# PERSONALITY_FILE=personality.md # PERSONALITY_FILE=personality.md
# MEMORY_DB=marvin.sqlite # MEMORY_DB=marvin.sqlite
# MAX_HISTORY_MESSAGES=20 # MAX_HISTORY_MESSAGES=20

1
.gitignore vendored
View file

@ -4,5 +4,6 @@ node_modules/
*.sqlite-shm *.sqlite-shm
*.sqlite-wal *.sqlite-wal
npm-debug.log* npm-debug.log*
*.log
.DS_Store .DS_Store
personality.md personality.md

View file

@ -1,6 +1,6 @@
# Marvin Telegram Bot # Marvin Telegram Bot
Simple Telegram bot server that sends your Telegram messages to Gemini and replies back. OpenRouter can be configured as a fallback. Recent conversation memory is stored persistently in SQLite. Simple Telegram bot server that sends your Telegram messages and photos to OpenRouter and replies back. Recent conversation memory is stored persistently in SQLite.
## Setup ## Setup
@ -14,16 +14,13 @@ cp .env.example .env
```env ```env
TELEGRAM_BOT_TOKEN=... TELEGRAM_BOT_TOKEN=...
GEMINI_API_KEY=...
OPENROUTER_API_KEY=... OPENROUTER_API_KEY=...
SEARXNG_URL=http://192.168.0.225:8080
``` ```
Gemini is the primary provider. OpenRouter is optional, but useful as fallback. Get your key from:
Get keys from:
```text ```text
https://aistudio.google.com/app/apikey
https://openrouter.ai/keys https://openrouter.ai/keys
``` ```
@ -38,8 +35,7 @@ You are Marvin, a funny, slightly sarcastic assistant. Keep replies short and pl
Optional settings in `.env`: Optional settings in `.env`:
```env ```env
GEMINI_MODEL=gemini-2.5-flash-lite OPENROUTER_MODEL=openai/gpt-5.4-mini
OPENROUTER_MODEL=openrouter/free
PERSONALITY_FILE=personality.md PERSONALITY_FILE=personality.md
MEMORY_DB=marvin.sqlite MEMORY_DB=marvin.sqlite
MAX_HISTORY_MESSAGES=20 MAX_HISTORY_MESSAGES=20
@ -69,18 +65,51 @@ Long-term memory commands:
/forget_memories /forget_memories
``` ```
## Web browsing
Marvin will automatically search the web when a message looks like it needs current or live information, and it will return a short sourced summary directly. It uses your self-hosted SearXNG instance.
It will also automatically generate an image when a message looks like an image-generation request.
Use `/web` if you want to force a web search.
Use `/image` if you want to force image generation.
Example:
```text
/web latest OpenRouter gpt-5.4-mini model ID
```
## Images ## Images
Send the bot a photo with an optional caption/question and it will analyze it using the configured OpenRouter model.
Generate and send an image using Pollinations: Generate and send an image using Pollinations:
```text ```text
/image a sad robot drinking coffee in Zagreb, noir style /image a sad robot drinking coffee in Zagreb, noir style
``` ```
4. Start the bot: 4. Start the bot locally:
```bash ```bash
npm start npm start
``` ```
Then open Telegram and message your bot. Then open Telegram and message your bot.
## Production setup
Recommended production setup:
1. Run Marvin as a **systemd** service
2. Keep using **polling**
3. Store config in `.env`
4. Run it as a dedicated unprivileged user
5. View logs with:
```bash
journalctl -u marvin -f
```
See `deploy/README.md` and `deploy/marvin.service` for an example service configuration.

31
deploy/README.md Normal file
View file

@ -0,0 +1,31 @@
# Marvin deployment (systemd)
This project is intended to run as a systemd service using polling, a `.env` file for configuration, and a dedicated unprivileged user.
## Recommended setup
- Create a dedicated user: `marvin`
- Place the project at: `/home/marvin/marvin`
- Store secrets in: `/home/marvin/marvin/.env`
- Run the bot with systemd
- View logs with:
```bash
journalctl -u marvin -f
```
## Service file
Install the provided service file:
```bash
sudo cp deploy/marvin.service /etc/systemd/system/marvin.service
sudo systemctl daemon-reload
sudo systemctl enable --now marvin
```
## Notes
- The bot uses Telegram polling (`getUpdates`), so only one instance should run at a time.
- If another copy is running, you may see a Telegram 409 conflict.
- Use `journalctl -u marvin -f` to follow logs live.

18
deploy/marvin.service Normal file
View file

@ -0,0 +1,18 @@
[Unit]
Description=Marvin Telegram Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=marvin
Group=marvin
WorkingDirectory=/home/marvin/marvin
EnvironmentFile=/home/marvin/marvin/.env
ExecStart=/usr/bin/node --no-warnings src/bot.js
Restart=always
RestartSec=5
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

449
package-lock.json generated
View file

@ -9,119 +9,16 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@google/genai": "^2.6.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"telegraf": "^4.16.3" "telegraf": "^4.16.3"
} }
}, },
"node_modules/@google/genai": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.6.0.tgz",
"integrity": "sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^4.6.2",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@telegraf/types": { "node_modules/@telegraf/types": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz",
"integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT"
},
"node_modules/abort-controller": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@ -134,44 +31,6 @@
"node": ">=6.5" "node": ">=6.5"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-alloc": { "node_modules/buffer-alloc": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
@ -188,27 +47,12 @@
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-fill": { "node_modules/buffer-fill": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
"integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -238,15 +82,6 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/event-target-shim": { "node_modules/event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -256,150 +91,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -415,57 +106,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-timeout": { "node_modules/p-timeout": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz",
@ -475,59 +115,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/protobufjs": {
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz",
"integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-compare": { "node_modules/safe-compare": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz",
@ -594,21 +181,6 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -624,27 +196,6 @@
"tr46": "~0.0.3", "tr46": "~0.0.3",
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"
} }
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "marvin", "name": "marvin",
"version": "1.0.0", "version": "1.0.0",
"description": "Telegram AI bot powered by Gemini", "description": "Telegram AI bot powered by OpenRouter",
"type": "module", "type": "module",
"main": "src/bot.js", "main": "src/bot.js",
"scripts": { "scripts": {
@ -11,7 +11,6 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@google/genai": "^2.6.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"telegraf": "^4.16.3" "telegraf": "^4.16.3"
} }

View file

@ -2,14 +2,12 @@ import 'dotenv/config';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { Telegraf } from 'telegraf'; import { Telegraf } from 'telegraf';
import { GoogleGenAI } from '@google/genai';
const { const {
TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_TOKEN,
GEMINI_API_KEY,
GEMINI_MODEL = 'gemini-2.5-flash-lite',
OPENROUTER_API_KEY, OPENROUTER_API_KEY,
OPENROUTER_MODEL = 'openrouter/free', OPENROUTER_MODEL = 'openai/gpt-5.4-mini',
SEARXNG_URL = 'http://192.168.0.225:8080',
PERSONALITY_FILE = 'personality.md', PERSONALITY_FILE = 'personality.md',
MEMORY_DB = 'marvin.sqlite', MEMORY_DB = 'marvin.sqlite',
MAX_HISTORY_MESSAGES = '20', MAX_HISTORY_MESSAGES = '20',
@ -19,12 +17,14 @@ if (!TELEGRAM_BOT_TOKEN) {
throw new Error('Missing TELEGRAM_BOT_TOKEN in environment'); throw new Error('Missing TELEGRAM_BOT_TOKEN in environment');
} }
if (!GEMINI_API_KEY && !OPENROUTER_API_KEY) { if (!OPENROUTER_API_KEY) {
throw new Error('Missing AI provider key: set GEMINI_API_KEY or OPENROUTER_API_KEY'); throw new Error('Missing OPENROUTER_API_KEY in environment');
} }
const bot = new Telegraf(TELEGRAM_BOT_TOKEN); const bot = new Telegraf(TELEGRAM_BOT_TOKEN);
const ai = GEMINI_API_KEY ? new GoogleGenAI({ apiKey: GEMINI_API_KEY }) : null; const webUserAgent = 'Mozilla/5.0 (compatible; MarvinBot/1.0; +https://github.com/hbrain/marvin)';
const webSearchResultLimit = 3;
const searxngBaseUrl = new URL(SEARXNG_URL);
const systemInstruction = readFileSync(PERSONALITY_FILE, 'utf8').trim(); const systemInstruction = readFileSync(PERSONALITY_FILE, 'utf8').trim();
const maxHistoryMessages = Number.parseInt(MAX_HISTORY_MESSAGES, 10); const maxHistoryMessages = Number.parseInt(MAX_HISTORY_MESSAGES, 10);
@ -109,30 +109,212 @@ function buildSystemInstruction(chatId) {
} }
function cleanReply(reply) { function cleanReply(reply) {
return reply.replace(/^\s*Marvin:\s*/i, '').trim(); return reply
.replace(/^\s*Marvin:\s*/i, '')
// Remove label variants anywhere in the reply, e.g.:
// **Personal comment:**, **Personal comment**:, Personal comment:, (Personal comment: ...)
.replace(/\(?[ \t]*\*{0,3}[ \t]*personal[ \t]+comment[ \t]*\*{0,3}[ \t]*(?::|[-–—])?[ \t]*\*{0,3}[ \t]*/giu, '')
.trim();
} }
function formatHistoryForGemini(history) { function escapeHtml(text) {
return history return text
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.content}`) .replace(/&/g, '&amp;')
.join('\n\n'); .replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
async function askGemini(chatId, history) { function formatTelegramReply(reply) {
if (!ai) throw new Error('Gemini is not configured'); const cleaned = cleanReply(reply).replace(/\r\n/g, '\n').trim();
const response = await ai.models.generateContent({ const codeBlocks = [];
model: GEMINI_MODEL, const withPlaceholders = cleaned.replace(/```([\s\S]*?)```/g, (_, code) => {
contents: formatHistoryForGemini(history), const token = `__CODEBLOCK_${codeBlocks.length}__`;
config: { codeBlocks.push(`<pre><code>${escapeHtml(code.trim())}</code></pre>`);
systemInstruction: buildSystemInstruction(chatId), return token;
});
let html = escapeHtml(withPlaceholders)
.replace(/^###\s+(.+)$/gm, '<b>$1</b>')
.replace(/^##\s+(.+)$/gm, '<b>$1</b>')
.replace(/^#\s+(.+)$/gm, '<b>$1</b>')
.replace(/^\*\*(.+?)\*\*/gm, '<b>$1</b>')
.replace(/^__(.+?)__$/gm, '<i>$1</i>')
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\n{3,}/g, '\n\n');
codeBlocks.forEach((block, index) => {
html = html.replace(`__CODEBLOCK_${index}__`, block);
});
if (html.length > 180 && !html.includes('\n')) {
html = html.replace(/(?<=[.!?])\s+(?=[A-Z0-9(])/g, '\n');
}
return html.trim();
}
async function sendAssistantReply(ctx, reply, extra = {}) {
const html = formatTelegramReply(reply);
return ctx.reply(html, {
parse_mode: 'HTML',
disable_web_page_preview: true,
...extra,
});
}
function decodeHtmlEntities(text) {
return text
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>');
}
function stripHtml(html) {
return decodeHtmlEntities(
html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
);
}
function resolveSearxngUrl(rawUrl) {
try {
return new URL(rawUrl, searxngBaseUrl).toString();
} catch {
return rawUrl;
}
}
async function searchWeb(query) {
const searchUrl = new URL('/search', searxngBaseUrl);
searchUrl.searchParams.set('q', query);
searchUrl.searchParams.set('language', 'auto');
searchUrl.searchParams.set('time_range', '');
searchUrl.searchParams.set('safesearch', '0');
searchUrl.searchParams.set('categories', 'general');
const response = await fetch(searchUrl, {
headers: {
'User-Agent': webUserAgent,
Accept: 'text/html,application/xhtml+xml',
}, },
}); });
return cleanReply(response.text?.trim() || 'I got an empty response. Try again?'); if (!response.ok) {
throw new Error(`Web search failed: ${response.status} ${response.statusText}`);
}
const html = await response.text();
const results = [];
const articleRegex = /<article\b[\s\S]*?<\/article>/gi;
let articleMatch;
while ((articleMatch = articleRegex.exec(html)) && results.length < webSearchResultLimit) {
const article = articleMatch[0];
const titleMatch = article.match(/<h3><a href="([^"]+)"[^>]*>([\s\S]*?)<\/a><\/h3>/i);
const snippetMatch = article.match(/<p class="content">([\s\S]*?)<\/p>/i);
if (!titleMatch) continue;
const url = resolveSearxngUrl(titleMatch[1]);
const title = stripHtml(titleMatch[2]);
const snippet = snippetMatch ? stripHtml(snippetMatch[1]) : '';
if (!title || !url) continue;
results.push({ title, url, snippet });
}
return results;
} }
async function askOpenRouter(chatId, history) { function formatWebEvidence(results) {
if (results.length === 0) return 'No web search results were found.';
return results
.map((result, index) => `${index + 1}. ${result.title}\n Snippet: ${result.snippet || 'No snippet available.'}`)
.join('\n\n');
}
async function fetchWebPageEvidence(url) {
const response = await fetch(url, {
headers: {
'User-Agent': webUserAgent,
Accept: 'text/html,application/xhtml+xml',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch page: ${response.status} ${response.statusText}`);
}
const html = await response.text();
const text = stripHtml(html)
.replace(/\s+/g, ' ')
.trim();
return text.slice(0, 5000);
}
function formatFetchedPageEvidence(results) {
if (results.length === 0) return 'No page evidence was collected.';
return results
.map((result, index) => {
const parts = [`${index + 1}. ${result.title}`];
if (result.snippet) parts.push(` Search snippet: ${result.snippet}`);
if (result.pageText) parts.push(` Page text: ${result.pageText}`);
if (result.fetchError) parts.push(` Fetch error: ${result.fetchError}`);
return parts.join('\n');
})
.join('\n\n');
}
function formatMessagesForOpenRouter(history, image) {
if (!image) return history;
const messages = history.slice(0, -1);
const lastMessage = history.at(-1) || { role: 'user', content: 'Describe this image.' };
messages.push({
role: lastMessage.role,
content: [
{ type: 'text', text: lastMessage.content },
{ type: 'image_url', image_url: { url: `data:${image.mimeType};base64,${image.data}` } },
],
});
return messages;
}
async function fetchTelegramImage(ctx, fileId) {
const fileUrl = await ctx.telegram.getFileLink(fileId);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to download Telegram image: ${response.status} ${response.statusText}`);
}
const mimeType = response.headers.get('content-type') || 'image/jpeg';
const buffer = Buffer.from(await response.arrayBuffer());
return {
mimeType,
data: buffer.toString('base64'),
};
}
async function askOpenRouter(chatId, history, image = null) {
if (!OPENROUTER_API_KEY) throw new Error('OpenRouter is not configured'); if (!OPENROUTER_API_KEY) throw new Error('OpenRouter is not configured');
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
@ -147,7 +329,7 @@ async function askOpenRouter(chatId, history) {
model: OPENROUTER_MODEL, model: OPENROUTER_MODEL,
messages: [ messages: [
{ role: 'system', content: buildSystemInstruction(chatId) }, { role: 'system', content: buildSystemInstruction(chatId) },
...history, ...formatMessagesForOpenRouter(history, image),
], ],
}), }),
}); });
@ -163,17 +345,47 @@ async function askOpenRouter(chatId, history) {
return cleanReply(data.choices?.[0]?.message?.content?.trim() || 'I got an empty response. Try again?'); return cleanReply(data.choices?.[0]?.message?.content?.trim() || 'I got an empty response. Try again?');
} }
async function askAi(chatId, history) { async function askAi(chatId, history, image = null) {
if (!GEMINI_API_KEY) return askOpenRouter(chatId, history); return askOpenRouter(chatId, history, image);
}
try { async function askWebAi(chatId, query) {
return await askGemini(chatId, history); const searchResults = await searchWeb(query);
} catch (error) {
if (!OPENROUTER_API_KEY) throw error;
console.error('Gemini failed, falling back to OpenRouter:', error); if (searchResults.length === 0) {
return askOpenRouter(chatId, history); return null;
} }
const resultsWithPages = [];
for (const result of searchResults) {
try {
result.pageText = await fetchWebPageEvidence(result.url);
} catch (error) {
result.fetchError = error.message;
result.pageText = '';
}
resultsWithPages.push(result);
}
const webContext = [
'Answer the user directly and naturally using the evidence below.',
'Synthesize the page content into one concise answer.',
'Do not mention browsing, searching, source lists, or limitations.',
'Do not output raw URLs unless the user explicitly asks for sources.',
'If evidence is incomplete, give the best approximate answer and only mention uncertainty if absolutely necessary.',
'Prefer current facts in the evidence over memory.',
'Keep the answer concise and useful.',
'',
`User question: ${query}`,
'',
formatFetchedPageEvidence(resultsWithPages),
].join('\n');
const reply = await askOpenRouter(chatId, [
{ role: 'user', content: webContext },
]);
return cleanReply(reply);
} }
bot.start((ctx) => { bot.start((ctx) => {
@ -181,9 +393,10 @@ bot.start((ctx) => {
}); });
bot.command('help', (ctx) => { bot.command('help', (ctx) => {
ctx.reply(`Just send me a text message. I remember recent conversation using SQLite. ctx.reply(`Just send me a text message, a photo with an optional caption, or use /web for browsing. I remember recent conversation using SQLite.
Commands: Commands:
/web query - search the web and summarize current info
/image prompt - generate and send an image /image prompt - generate and send an image
/remember key = value - save a permanent fact /remember key = value - save a permanent fact
/memories - show permanent facts /memories - show permanent facts
@ -233,6 +446,38 @@ bot.command('forget', (ctx) => {
ctx.reply('Recent chat history cleared for this chat. Long-term memories were kept.'); ctx.reply('Recent chat history cleared for this chat. Long-term memories were kept.');
}); });
bot.command('web', async (ctx) => {
const query = ctx.message.text.replace(/^\/web(@\w+)?\s*/i, '').trim();
if (!query) {
await ctx.reply('Use: /web query\nExample: /web latest OpenRouter gpt-5.4-mini model ID');
return;
}
try {
await ctx.sendChatAction('typing');
rememberMessage(ctx.chat.id, 'user', `[web] ${query}`);
const reply = await askWebAi(ctx.chat.id, query);
rememberMessage(ctx.chat.id, 'assistant', reply);
await ctx.reply(reply);
} catch (error) {
await handleAiError(ctx, error);
}
});
function shouldGenerateImage(text) {
const normalized = text.toLowerCase();
const cues = [
/\b(image|picture|photo|illustration|art|drawing|draw|paint|painting|sketch|generate an image|create an image|make an image|generate a picture|create a picture|make a picture)\b/i,
/^\/image(@\w+)?\b/i,
];
return cues.some((pattern) => pattern.test(normalized));
}
bot.command('image', async (ctx) => { bot.command('image', async (ctx) => {
const prompt = ctx.message.text.replace(/^\/image(@\w+)?\s*/i, '').trim(); const prompt = ctx.message.text.replace(/^\/image(@\w+)?\s*/i, '').trim();
@ -251,21 +496,7 @@ bot.command('image', async (ctx) => {
} }
}); });
bot.on('text', async (ctx) => { async function handleAiError(ctx, error) {
const text = ctx.message.text;
if (!text || text.startsWith('/')) return;
try {
await ctx.sendChatAction('typing');
rememberMessage(ctx.chat.id, 'user', text);
const history = getHistory(ctx.chat.id);
const reply = await askAi(ctx.chat.id, history);
rememberMessage(ctx.chat.id, 'assistant', reply);
await ctx.reply(reply);
} catch (error) {
console.error('Failed to handle message:', error); console.error('Failed to handle message:', error);
if (error?.status === 429) { if (error?.status === 429) {
@ -274,6 +505,63 @@ bot.on('text', async (ctx) => {
} }
await ctx.reply('Sorry, something went wrong. Check the server logs.'); await ctx.reply('Sorry, something went wrong. Check the server logs.');
}
async function handleImageMessage(ctx, fileId, caption) {
try {
await ctx.sendChatAction('typing');
const image = await fetchTelegramImage(ctx, fileId);
rememberMessage(ctx.chat.id, 'user', `[image] ${caption}`);
const history = getHistory(ctx.chat.id);
const reply = await askAi(ctx.chat.id, history, image);
rememberMessage(ctx.chat.id, 'assistant', reply);
await sendAssistantReply(ctx, reply);
} catch (error) {
await handleAiError(ctx, error);
}
}
bot.on('photo', async (ctx) => {
const largestPhoto = ctx.message.photo.at(-1);
const caption = ctx.message.caption?.trim() || 'Describe this image.';
await handleImageMessage(ctx, largestPhoto.file_id, caption);
});
bot.on('document', async (ctx) => {
const document = ctx.message.document;
if (!document.mime_type?.startsWith('image/')) return;
const caption = ctx.message.caption?.trim() || 'Describe this image.';
await handleImageMessage(ctx, document.file_id, caption);
});
bot.on('text', async (ctx) => {
const text = ctx.message.text;
if (!text || text.startsWith('/')) return;
try {
if (shouldGenerateImage(text)) {
await ctx.sendChatAction('upload_photo');
const imageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(text)}?width=1024&height=1024&nologo=true&safe=true`;
await ctx.replyWithPhoto({ url: imageUrl }, { caption: text.slice(0, 1024) });
return;
}
await ctx.sendChatAction('typing');
rememberMessage(ctx.chat.id, 'user', text);
const reply = (await askWebAi(ctx.chat.id, text)) ?? await askAi(ctx.chat.id, getHistory(ctx.chat.id));
rememberMessage(ctx.chat.id, 'assistant', reply);
await sendAssistantReply(ctx, reply);
} catch (error) {
await handleAiError(ctx, error);
} }
}); });