Improve Telegram formatting and web synthesis
This commit is contained in:
parent
605c135b08
commit
0675f9a441
8 changed files with 425 additions and 508 deletions
|
|
@ -1,10 +1,10 @@
|
|||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
SEARXNG_URL=http://192.168.0.225:8080
|
||||
|
||||
# Optional:
|
||||
# GEMINI_MODEL=gemini-2.5-flash-lite
|
||||
# OPENROUTER_MODEL=openrouter/free
|
||||
# OPENROUTER_MODEL=openai/gpt-5.4-mini
|
||||
# Use a vision-capable OpenRouter model if you want image analysis.
|
||||
# PERSONALITY_FILE=personality.md
|
||||
# MEMORY_DB=marvin.sqlite
|
||||
# MAX_HISTORY_MESSAGES=20
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,5 +4,6 @@ node_modules/
|
|||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
npm-debug.log*
|
||||
*.log
|
||||
.DS_Store
|
||||
personality.md
|
||||
|
|
|
|||
47
README.md
47
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -14,16 +14,13 @@ cp .env.example .env
|
|||
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=...
|
||||
GEMINI_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 keys from:
|
||||
Get your key from:
|
||||
|
||||
```text
|
||||
https://aistudio.google.com/app/apikey
|
||||
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`:
|
||||
|
||||
```env
|
||||
GEMINI_MODEL=gemini-2.5-flash-lite
|
||||
OPENROUTER_MODEL=openrouter/free
|
||||
OPENROUTER_MODEL=openai/gpt-5.4-mini
|
||||
PERSONALITY_FILE=personality.md
|
||||
MEMORY_DB=marvin.sqlite
|
||||
MAX_HISTORY_MESSAGES=20
|
||||
|
|
@ -69,18 +65,51 @@ Long-term memory commands:
|
|||
/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
|
||||
|
||||
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:
|
||||
|
||||
```text
|
||||
/image a sad robot drinking coffee in Zagreb, noir style
|
||||
```
|
||||
|
||||
4. Start the bot:
|
||||
4. Start the bot locally:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
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
31
deploy/README.md
Normal 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
18
deploy/marvin.service
Normal 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
449
package-lock.json
generated
|
|
@ -9,119 +9,16 @@
|
|||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.6.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"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": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz",
|
||||
"integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
|
@ -134,44 +31,6 @@
|
|||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
|
||||
|
|
@ -188,27 +47,12 @@
|
|||
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
||||
"integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -238,15 +82,6 @@
|
|||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
|
|
@ -256,150 +91,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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
|
|
@ -415,57 +106,6 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz",
|
||||
|
|
@ -475,59 +115,6 @@
|
|||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz",
|
||||
|
|
@ -594,21 +181,6 @@
|
|||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
|
@ -624,27 +196,6 @@
|
|||
"tr46": "~0.0.3",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "marvin",
|
||||
"version": "1.0.0",
|
||||
"description": "Telegram AI bot powered by Gemini",
|
||||
"description": "Telegram AI bot powered by OpenRouter",
|
||||
"type": "module",
|
||||
"main": "src/bot.js",
|
||||
"scripts": {
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.6.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"telegraf": "^4.16.3"
|
||||
}
|
||||
|
|
|
|||
380
src/bot.js
380
src/bot.js
|
|
@ -2,14 +2,12 @@ import 'dotenv/config';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Telegraf } from 'telegraf';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
const {
|
||||
TELEGRAM_BOT_TOKEN,
|
||||
GEMINI_API_KEY,
|
||||
GEMINI_MODEL = 'gemini-2.5-flash-lite',
|
||||
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',
|
||||
MEMORY_DB = 'marvin.sqlite',
|
||||
MAX_HISTORY_MESSAGES = '20',
|
||||
|
|
@ -19,12 +17,14 @@ if (!TELEGRAM_BOT_TOKEN) {
|
|||
throw new Error('Missing TELEGRAM_BOT_TOKEN in environment');
|
||||
}
|
||||
|
||||
if (!GEMINI_API_KEY && !OPENROUTER_API_KEY) {
|
||||
throw new Error('Missing AI provider key: set GEMINI_API_KEY or OPENROUTER_API_KEY');
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
throw new Error('Missing OPENROUTER_API_KEY in environment');
|
||||
}
|
||||
|
||||
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 maxHistoryMessages = Number.parseInt(MAX_HISTORY_MESSAGES, 10);
|
||||
|
|
@ -109,30 +109,212 @@ function buildSystemInstruction(chatId) {
|
|||
}
|
||||
|
||||
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) {
|
||||
return history
|
||||
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.content}`)
|
||||
.join('\n\n');
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function askGemini(chatId, history) {
|
||||
if (!ai) throw new Error('Gemini is not configured');
|
||||
function formatTelegramReply(reply) {
|
||||
const cleaned = cleanReply(reply).replace(/\r\n/g, '\n').trim();
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: GEMINI_MODEL,
|
||||
contents: formatHistoryForGemini(history),
|
||||
config: {
|
||||
systemInstruction: buildSystemInstruction(chatId),
|
||||
const codeBlocks = [];
|
||||
const withPlaceholders = cleaned.replace(/```([\s\S]*?)```/g, (_, code) => {
|
||||
const token = `__CODEBLOCK_${codeBlocks.length}__`;
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.trim())}</code></pre>`);
|
||||
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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/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');
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
|
|
@ -147,7 +329,7 @@ async function askOpenRouter(chatId, history) {
|
|||
model: OPENROUTER_MODEL,
|
||||
messages: [
|
||||
{ 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?');
|
||||
}
|
||||
|
||||
async function askAi(chatId, history) {
|
||||
if (!GEMINI_API_KEY) return askOpenRouter(chatId, history);
|
||||
async function askAi(chatId, history, image = null) {
|
||||
return askOpenRouter(chatId, history, image);
|
||||
}
|
||||
|
||||
try {
|
||||
return await askGemini(chatId, history);
|
||||
} catch (error) {
|
||||
if (!OPENROUTER_API_KEY) throw error;
|
||||
async function askWebAi(chatId, query) {
|
||||
const searchResults = await searchWeb(query);
|
||||
|
||||
console.error('Gemini failed, falling back to OpenRouter:', error);
|
||||
return askOpenRouter(chatId, history);
|
||||
if (searchResults.length === 0) {
|
||||
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) => {
|
||||
|
|
@ -181,9 +393,10 @@ bot.start((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:
|
||||
/web query - search the web and summarize current info
|
||||
/image prompt - generate and send an image
|
||||
/remember key = value - save a permanent fact
|
||||
/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.');
|
||||
});
|
||||
|
||||
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) => {
|
||||
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) => {
|
||||
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) {
|
||||
async function handleAiError(ctx, error) {
|
||||
console.error('Failed to handle message:', error);
|
||||
|
||||
if (error?.status === 429) {
|
||||
|
|
@ -274,6 +505,63 @@ bot.on('text', async (ctx) => {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue