Merge 30e9eec9fb into bc432d8435
This commit is contained in:
commit
db15f10d13
225
SECURE-BOT.md
Normal file
225
SECURE-BOT.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# AssureBot Edition
|
||||||
|
|
||||||
|
A lean, secure, self-hosted AI assistant for Railway deployment.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
**Your AI agent that runs on your infrastructure, answers only to you, and you can actually audit.**
|
||||||
|
|
||||||
|
- No SaaS middleman
|
||||||
|
- No data harvesting
|
||||||
|
- Your keys, your server, your rules
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
| Principle | Implementation |
|
||||||
|
|-----------|----------------|
|
||||||
|
| **Allowlist-only** | Nobody talks to it unless explicitly approved |
|
||||||
|
| **Env-var config** | No config files to leak, no filesystem secrets |
|
||||||
|
| **Audit log** | Every interaction logged, inspectable |
|
||||||
|
| **No phone-home** | Zero telemetry, no central service |
|
||||||
|
| **Minimal surface** | Small codebase, few deps, easy to read |
|
||||||
|
| **Your keys** | Direct to Anthropic/OpenAI, no proxy |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ ASSUREBOT │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Telegram │ │ Webhooks │ │ Scheduler │ │
|
||||||
|
│ │ Channel │ │ Receiver │ │ (Cron) │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └─────────────────┼─────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────▼───────┐ │
|
||||||
|
│ │ Agent │ │
|
||||||
|
│ │ Core │ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────┼─────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
|
||||||
|
│ │ AI Model │ │ Sandbox │ │ Audit │ │
|
||||||
|
│ │ (Direct) │ │ (Docker) │ │ Logger │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Telegram (Primary UI)
|
||||||
|
- Chat with AI (text, voice transcription, images)
|
||||||
|
- Forward anything for analysis
|
||||||
|
- Upload docs for Q&A
|
||||||
|
- `/commands` for quick actions
|
||||||
|
- **Allowlist-only**: Must be in `ALLOWED_USERS`
|
||||||
|
|
||||||
|
### Webhooks (Inbound)
|
||||||
|
- Authenticated endpoint at `/hooks/*`
|
||||||
|
- Receive from GitHub, Stripe, uptime monitors, etc.
|
||||||
|
- AI summarizes and forwards to Telegram
|
||||||
|
- Bearer token or `X-Moltbot-Token` header auth
|
||||||
|
|
||||||
|
### Scheduler (Cron)
|
||||||
|
- Built-in cron expressions
|
||||||
|
- Morning briefings, monitors, recurring tasks
|
||||||
|
- `at:` one-shot scheduling
|
||||||
|
- `every:` interval scheduling
|
||||||
|
|
||||||
|
### Sandbox (Isolated Execution)
|
||||||
|
- Docker container for code/script execution
|
||||||
|
- Network isolated by default
|
||||||
|
- Resource limits (CPU, memory, time)
|
||||||
|
- Read-only root filesystem
|
||||||
|
- Ephemeral - destroyed after use
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration via environment variables. No config files.
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bot Identity
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||||
|
|
||||||
|
# AI Provider (pick one)
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# or
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# Access Control
|
||||||
|
ALLOWED_USERS=123456789,987654321 # Telegram user IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Webhook Authentication
|
||||||
|
WEBHOOK_SECRET=your-random-32-char-secret
|
||||||
|
|
||||||
|
# Gateway Auth (for internal API)
|
||||||
|
MOLTBOT_GATEWAY_TOKEN=another-random-secret
|
||||||
|
|
||||||
|
# Sandbox Settings
|
||||||
|
SANDBOX_ENABLED=true
|
||||||
|
SANDBOX_NETWORK=none # none | bridge
|
||||||
|
SANDBOX_MEMORY=512m
|
||||||
|
SANDBOX_CPUS=1
|
||||||
|
|
||||||
|
# Audit Logging
|
||||||
|
AUDIT_LOG_PATH=/data/audit.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Railway Deployment
|
||||||
|
|
||||||
|
### One-Click Deploy
|
||||||
|
|
||||||
|
[](https://railway.app/template/moltbot-secure)
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
1. Create new Railway project
|
||||||
|
2. Add from GitHub repo
|
||||||
|
3. Set environment variables:
|
||||||
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
|
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
|
||||||
|
- `ALLOWED_USERS`
|
||||||
|
- `WEBHOOK_SECRET` (recommended)
|
||||||
|
4. Add volume at `/data` for persistence
|
||||||
|
5. Deploy
|
||||||
|
|
||||||
|
### railway.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://railway.app/railway.schema.json",
|
||||||
|
"build": {
|
||||||
|
"builder": "DOCKERFILE",
|
||||||
|
"dockerfilePath": "Dockerfile.secure"
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"healthcheckPath": "/health",
|
||||||
|
"healthcheckTimeout": 30,
|
||||||
|
"restartPolicyType": "ON_FAILURE",
|
||||||
|
"restartPolicyMaxRetries": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
### What We Block
|
||||||
|
|
||||||
|
- **Unauthorized users**: Only `ALLOWED_USERS` can interact
|
||||||
|
- **Unauthenticated webhooks**: Require valid token
|
||||||
|
- **Network in sandbox**: Disabled by default
|
||||||
|
- **Filesystem access**: Read-only root, tmpfs only
|
||||||
|
- **Privilege escalation**: All caps dropped
|
||||||
|
- **Secret leakage**: Automatic redaction in logs
|
||||||
|
|
||||||
|
### What We Log
|
||||||
|
|
||||||
|
Every interaction is logged to `AUDIT_LOG_PATH`:
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"ts":"2024-01-15T10:30:00Z","type":"message","user":123456789,"text":"...","response":"..."}
|
||||||
|
{"ts":"2024-01-15T10:30:05Z","type":"webhook","path":"/hooks/github","status":200}
|
||||||
|
{"ts":"2024-01-15T10:30:10Z","type":"sandbox","command":"python script.py","exit":0}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Threat Model
|
||||||
|
|
||||||
|
| Threat | Mitigation |
|
||||||
|
|--------|------------|
|
||||||
|
| Unauthorized access | Telegram user ID allowlist |
|
||||||
|
| Webhook abuse | Bearer token auth, rate limits |
|
||||||
|
| Code execution escape | Docker isolation, no network, caps dropped |
|
||||||
|
| Secret exposure | Env-only config, log redaction |
|
||||||
|
| Model prompt injection | Sandboxed tool execution |
|
||||||
|
|
||||||
|
## What's NOT Included
|
||||||
|
|
||||||
|
Intentionally removed for security/simplicity:
|
||||||
|
|
||||||
|
- Web UI / Setup wizard
|
||||||
|
- WebSocket device pairing
|
||||||
|
- Plugin/extension system
|
||||||
|
- WhatsApp/Signal/iMessage/Discord
|
||||||
|
- Multi-account support
|
||||||
|
- Browser automation sandbox
|
||||||
|
- File-based configuration
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Run in dev mode
|
||||||
|
TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 pnpm dev:secure
|
||||||
|
|
||||||
|
# Build
|
||||||
|
pnpm build:secure
|
||||||
|
|
||||||
|
# Test
|
||||||
|
pnpm test:secure
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure (Secure Edition)
|
||||||
|
|
||||||
|
```
|
||||||
|
secure/
|
||||||
|
├── index.ts # Entry point
|
||||||
|
├── config.ts # Env-only config loader
|
||||||
|
├── telegram.ts # Telegram bot (grammy)
|
||||||
|
├── webhooks.ts # Webhook receiver
|
||||||
|
├── scheduler.ts # Cron service
|
||||||
|
├── sandbox.ts # Docker sandbox
|
||||||
|
├── audit.ts # Audit logger
|
||||||
|
├── agent.ts # AI agent core
|
||||||
|
└── Dockerfile # Minimal container
|
||||||
|
```
|
||||||
174
pnpm-lock.yaml
generated
174
pnpm-lock.yaml
generated
@ -314,7 +314,7 @@ importers:
|
|||||||
specifier: ^10.5.0
|
specifier: ^10.5.0
|
||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -322,7 +322,7 @@ importers:
|
|||||||
|
|
||||||
extensions/line:
|
extensions/line:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -348,7 +348,7 @@ importers:
|
|||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -356,7 +356,7 @@ importers:
|
|||||||
|
|
||||||
extensions/memory-core:
|
extensions/memory-core:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -386,7 +386,7 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
proper-lockfile:
|
proper-lockfile:
|
||||||
@ -397,12 +397,12 @@ importers:
|
|||||||
|
|
||||||
extensions/nostr:
|
extensions/nostr:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
moltbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: ^2.20.0
|
specifier: ^2.20.0
|
||||||
version: 2.20.0(typescript@5.9.3)
|
version: 2.20.0(typescript@5.9.3)
|
||||||
openclaw:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../..
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@ -439,7 +439,7 @@ importers:
|
|||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -459,7 +459,7 @@ importers:
|
|||||||
|
|
||||||
extensions/zalo:
|
extensions/zalo:
|
||||||
dependencies:
|
dependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
undici:
|
undici:
|
||||||
@ -471,21 +471,40 @@ importers:
|
|||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: 0.34.47
|
specifier: 0.34.47
|
||||||
version: 0.34.47
|
version: 0.34.47
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
packages/clawdbot:
|
packages/clawdbot:
|
||||||
dependencies:
|
dependencies:
|
||||||
openclaw:
|
moltbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
packages/moltbot:
|
secure:
|
||||||
dependencies:
|
dependencies:
|
||||||
openclaw:
|
'@anthropic-ai/sdk':
|
||||||
specifier: workspace:*
|
specifier: ^0.39.0
|
||||||
version: link:../..
|
version: 0.39.0
|
||||||
|
cron:
|
||||||
|
specifier: ^3.1.7
|
||||||
|
version: 3.5.0
|
||||||
|
grammy:
|
||||||
|
specifier: ^1.21.1
|
||||||
|
version: 1.39.3
|
||||||
|
openai:
|
||||||
|
specifier: ^4.77.0
|
||||||
|
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.10.2
|
||||||
|
version: 22.19.7
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.3.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -525,6 +544,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.0 || ^4.0.0
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
|
'@anthropic-ai/sdk@0.39.0':
|
||||||
|
resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==}
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.71.2':
|
'@anthropic-ai/sdk@0.71.2':
|
||||||
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -2725,6 +2747,9 @@ packages:
|
|||||||
'@types/long@4.0.2':
|
'@types/long@4.0.2':
|
||||||
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
|
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
|
||||||
|
|
||||||
|
'@types/luxon@3.4.2':
|
||||||
|
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||||
|
|
||||||
@ -2740,12 +2765,21 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/node-fetch@2.6.13':
|
||||||
|
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||||
|
|
||||||
'@types/node@10.17.60':
|
'@types/node@10.17.60':
|
||||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||||
|
|
||||||
'@types/node@20.19.30':
|
'@types/node@20.19.30':
|
||||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||||
|
|
||||||
|
'@types/node@22.19.7':
|
||||||
|
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||||
|
|
||||||
'@types/node@24.10.9':
|
'@types/node@24.10.9':
|
||||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||||
|
|
||||||
@ -2958,6 +2992,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
agentkeepalive@4.6.0:
|
||||||
|
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||||
|
engines: {node: '>= 8.0.0'}
|
||||||
|
|
||||||
ajv-formats@3.0.1:
|
ajv-formats@3.0.1:
|
||||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -3325,6 +3363,9 @@ packages:
|
|||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
|
cron@3.5.0:
|
||||||
|
resolution: {integrity: sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==}
|
||||||
|
|
||||||
croner@9.1.0:
|
croner@9.1.0:
|
||||||
resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==}
|
resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==}
|
||||||
engines: {node: '>=18.0'}
|
engines: {node: '>=18.0'}
|
||||||
@ -3633,6 +3674,9 @@ packages:
|
|||||||
forever-agent@0.6.1:
|
forever-agent@0.6.1:
|
||||||
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
|
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
|
||||||
|
|
||||||
|
form-data-encoder@1.7.2:
|
||||||
|
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||||
|
|
||||||
form-data@2.3.3:
|
form-data@2.3.3:
|
||||||
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
||||||
engines: {node: '>= 0.12'}
|
engines: {node: '>= 0.12'}
|
||||||
@ -3645,6 +3689,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
formdata-node@4.4.1:
|
||||||
|
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||||
|
engines: {node: '>= 12.20'}
|
||||||
|
|
||||||
formdata-polyfill@4.0.10:
|
formdata-polyfill@4.0.10:
|
||||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@ -3839,6 +3887,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
humanize-ms@1.2.1:
|
||||||
|
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -4232,6 +4283,10 @@ packages:
|
|||||||
lucide@0.563.0:
|
lucide@0.563.0:
|
||||||
resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
|
resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
|
||||||
|
|
||||||
|
luxon@3.5.0:
|
||||||
|
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@ -4520,6 +4575,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
openai@4.104.0:
|
||||||
|
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
zod: ^3.23.8
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
openai@6.10.0:
|
openai@6.10.0:
|
||||||
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
|
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -5313,6 +5380,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
undici-types@5.26.5:
|
||||||
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@ -5462,6 +5532,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
web-streams-polyfill@4.0.0-beta.3:
|
||||||
|
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
webidl-conversions@3.0.1:
|
webidl-conversions@3.0.1:
|
||||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
@ -5583,6 +5657,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
|
'@anthropic-ai/sdk@0.39.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 18.19.130
|
||||||
|
'@types/node-fetch': 2.6.13
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
agentkeepalive: 4.6.0
|
||||||
|
form-data-encoder: 1.7.2
|
||||||
|
formdata-node: 4.4.1
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
|
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema-to-ts: 3.1.1
|
json-schema-to-ts: 3.1.1
|
||||||
@ -8502,6 +8588,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/long@4.0.2': {}
|
'@types/long@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/luxon@3.4.2': {}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/linkify-it': 5.0.0
|
'@types/linkify-it': 5.0.0
|
||||||
@ -8515,12 +8603,25 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/node-fetch@2.6.13':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.7
|
||||||
|
form-data: 4.0.5
|
||||||
|
|
||||||
'@types/node@10.17.60': {}
|
'@types/node@10.17.60': {}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
|
||||||
'@types/node@20.19.30':
|
'@types/node@20.19.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@22.19.7':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@24.10.9':
|
'@types/node@24.10.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@ -8808,6 +8909,10 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
|
agentkeepalive@4.6.0:
|
||||||
|
dependencies:
|
||||||
|
humanize-ms: 1.2.1
|
||||||
|
|
||||||
ajv-formats@3.0.1(ajv@8.17.1):
|
ajv-formats@3.0.1(ajv@8.17.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.17.1
|
ajv: 8.17.1
|
||||||
@ -9210,6 +9315,11 @@ snapshots:
|
|||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
|
cron@3.5.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/luxon': 3.4.2
|
||||||
|
luxon: 3.5.0
|
||||||
|
|
||||||
croner@9.1.0: {}
|
croner@9.1.0: {}
|
||||||
|
|
||||||
cross-fetch@4.1.0:
|
cross-fetch@4.1.0:
|
||||||
@ -9573,6 +9683,8 @@ snapshots:
|
|||||||
|
|
||||||
forever-agent@0.6.1: {}
|
forever-agent@0.6.1: {}
|
||||||
|
|
||||||
|
form-data-encoder@1.7.2: {}
|
||||||
|
|
||||||
form-data@2.3.3:
|
form-data@2.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
@ -9596,6 +9708,11 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
formdata-node@4.4.1:
|
||||||
|
dependencies:
|
||||||
|
node-domexception: 1.0.0
|
||||||
|
web-streams-polyfill: 4.0.0-beta.3
|
||||||
|
|
||||||
formdata-polyfill@4.0.10:
|
formdata-polyfill@4.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
fetch-blob: 3.2.0
|
fetch-blob: 3.2.0
|
||||||
@ -9836,6 +9953,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
humanize-ms@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@ -10236,6 +10357,8 @@ snapshots:
|
|||||||
|
|
||||||
lucide@0.563.0: {}
|
lucide@0.563.0: {}
|
||||||
|
|
||||||
|
luxon@3.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@ -10553,6 +10676,21 @@ snapshots:
|
|||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 18.19.130
|
||||||
|
'@types/node-fetch': 2.6.13
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
agentkeepalive: 4.6.0
|
||||||
|
form-data-encoder: 1.7.2
|
||||||
|
formdata-node: 4.4.1
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.19.0
|
||||||
|
zod: 3.25.76
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
|
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ws: 8.19.0
|
ws: 8.19.0
|
||||||
@ -11500,6 +11638,8 @@ snapshots:
|
|||||||
|
|
||||||
uint8array-extras@1.5.0: {}
|
uint8array-extras@1.5.0: {}
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
@ -11614,6 +11754,8 @@ snapshots:
|
|||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
|
web-streams-polyfill@4.0.0-beta.3: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
whatwg-fetch@3.6.20: {}
|
whatwg-fetch@3.6.20: {}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- ui
|
- ui
|
||||||
|
- secure
|
||||||
- packages/*
|
- packages/*
|
||||||
- extensions/*
|
- extensions/*
|
||||||
|
|
||||||
|
|||||||
58
railway-template.json
Normal file
58
railway-template.json
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://railway.app/railway.schema.json",
|
||||||
|
"name": "AssureBot",
|
||||||
|
"description": "Lean, secure, self-hosted AI assistant with Telegram, document analysis, and scheduled tasks",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "assurebot",
|
||||||
|
"build": {
|
||||||
|
"builder": "DOCKERFILE",
|
||||||
|
"dockerfilePath": "secure/Dockerfile",
|
||||||
|
"watchPatterns": ["secure/**"]
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"startCommand": "node dist/index.js",
|
||||||
|
"healthcheckPath": "/health",
|
||||||
|
"healthcheckTimeout": 60,
|
||||||
|
"restartPolicyType": "ON_FAILURE",
|
||||||
|
"restartPolicyMaxRetries": 3
|
||||||
|
},
|
||||||
|
"variables": {
|
||||||
|
"DATABASE_URL": "${{Postgres.DATABASE_URL}}",
|
||||||
|
"REDIS_URL": "${{Redis.REDIS_URL}}",
|
||||||
|
"TELEGRAM_BOT_TOKEN": {
|
||||||
|
"description": "Telegram bot token from @BotFather",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"ALLOWED_USERS": {
|
||||||
|
"description": "Comma-separated Telegram user IDs",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"ANTHROPIC_API_KEY": {
|
||||||
|
"description": "Anthropic API key (or use OPENAI_API_KEY or OPENROUTER_API_KEY)",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"OPENAI_API_KEY": {
|
||||||
|
"description": "OpenAI API key",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"OPENROUTER_API_KEY": {
|
||||||
|
"description": "OpenRouter API key (100+ models)",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"AI_MODEL": {
|
||||||
|
"description": "Model override (e.g., claude-3-5-sonnet-20241022)",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Postgres",
|
||||||
|
"plugin": "postgresql"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Redis",
|
||||||
|
"plugin": "redis"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
50
secure/Dockerfile
Normal file
50
secure/Dockerfile
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# AssureBot - Standalone Docker Image
|
||||||
|
# Lean, secure, self-hosted AI assistant for Railway
|
||||||
|
#
|
||||||
|
# Build from repo root: docker build -f secure/Dockerfile .
|
||||||
|
# Or set Railway root directory to: secure/
|
||||||
|
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files (handles both root and secure/ as context)
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY tsconfig.json* ./
|
||||||
|
COPY *.ts ./
|
||||||
|
COPY *.d.ts ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install --omit=dev=false
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM node:22-slim AS runner
|
||||||
|
|
||||||
|
# Security: Run as non-root user (use different UID since 1000 exists)
|
||||||
|
RUN useradd -m -u 1001 -s /bin/bash assurebot
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built files and production deps
|
||||||
|
COPY --from=builder --chown=assurebot:assurebot /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=assurebot:assurebot /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=assurebot:assurebot /app/package.json ./
|
||||||
|
|
||||||
|
# Create data directory for audit logs (before switching user)
|
||||||
|
RUN mkdir -p /app/data && chown assurebot:assurebot /app/data
|
||||||
|
|
||||||
|
USER assurebot
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1))" || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
241
secure/README.md
Normal file
241
secure/README.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# AssureBot
|
||||||
|
|
||||||
|
**Lean, secure, self-hosted AI assistant for Railway.**
|
||||||
|
|
||||||
|
Your AI agent that runs on your infrastructure, answers only to you, and you can actually audit.
|
||||||
|
|
||||||
|
## Why AssureBot?
|
||||||
|
|
||||||
|
| Full Moltbot | AssureBot |
|
||||||
|
|--------------|----------------|
|
||||||
|
| 12+ channels | Telegram only |
|
||||||
|
| File-based config | Env vars only |
|
||||||
|
| Plugins/extensions | None (locked down) |
|
||||||
|
| Desktop/mobile apps | Headless server |
|
||||||
|
| Complex setup | One-click deploy |
|
||||||
|
|
||||||
|
**Trade-off**: Less features, more trust.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ TELEGRAM (your secure UI) │
|
||||||
|
│ ├── Chat with AI (text, images, documents) │
|
||||||
|
│ ├── Code execution (15+ languages) │
|
||||||
|
│ ├── Forward anything → get analysis │
|
||||||
|
│ └── /commands for actions │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ CODE EXECUTION │
|
||||||
|
│ ├── /js, /python, /ts, /bash - Quick execute │
|
||||||
|
│ ├── /run <lang> <code> - Any language │
|
||||||
|
│ ├── Docker (local) or Piston API (cloud) │
|
||||||
|
│ └── Isolated, no network, resource limits │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ WEBHOOKS IN (authenticated) │
|
||||||
|
│ ├── GitHub → "PR merged, here's the summary" │
|
||||||
|
│ ├── Uptime → "Site down, checking why..." │
|
||||||
|
│ └── Anything → AI-summarized to Telegram │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ SCHEDULED TASKS (cron) │
|
||||||
|
│ ├── Morning briefing │
|
||||||
|
│ ├── Monitor RSS/sites │
|
||||||
|
│ └── Recurring research │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ PERSISTENCE (optional) │
|
||||||
|
│ ├── PostgreSQL - Tasks, user profiles │
|
||||||
|
│ ├── Redis - Conversations, cache │
|
||||||
|
│ └── Personality learning per user │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/js <code>` | Run JavaScript |
|
||||||
|
| `/python <code>` | Run Python |
|
||||||
|
| `/ts <code>` | Run TypeScript |
|
||||||
|
| `/bash <code>` | Run shell commands |
|
||||||
|
| `/run <lang> <code>` | Run any language |
|
||||||
|
| `/status` | Bot & sandbox status |
|
||||||
|
| `/clear` | Clear conversation |
|
||||||
|
| `/schedule` | Schedule AI tasks |
|
||||||
|
| `/tasks` | List scheduled tasks |
|
||||||
|
| `/help` | Full command list |
|
||||||
|
|
||||||
|
**Supported Languages**: python, javascript, typescript, bash, rust, go, c, cpp, java, ruby, php
|
||||||
|
|
||||||
|
## Deploy to Railway
|
||||||
|
|
||||||
|
### One-Click (Recommended)
|
||||||
|
|
||||||
|
[](https://railway.app/new/template?template=https://github.com/TNovs1/moltbot/tree/main&envs=TELEGRAM_BOT_TOKEN,ALLOWED_USERS,ANTHROPIC_API_KEY)
|
||||||
|
|
||||||
|
This auto-provisions PostgreSQL and Redis for persistence.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. Fork this repo
|
||||||
|
2. Create Railway project from GitHub
|
||||||
|
3. **Set Root Directory to `secure`**
|
||||||
|
4. Set environment variables (see below)
|
||||||
|
5. Optionally add PostgreSQL and Redis services
|
||||||
|
6. Deploy
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**All config via environment variables. No files.**
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF... # From @BotFather
|
||||||
|
ALLOWED_USERS=123456789,987654321 # Telegram user IDs
|
||||||
|
|
||||||
|
# Pick ONE AI provider:
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-... # Claude
|
||||||
|
OPENAI_API_KEY=sk-... # GPT-4
|
||||||
|
OPENROUTER_API_KEY=sk-or-... # 100+ models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AI Model (optional - uses sensible defaults)
|
||||||
|
AI_MODEL=claude-sonnet-4-20250514 # or gpt-4o, etc.
|
||||||
|
|
||||||
|
# Storage (auto-wired on Railway template)
|
||||||
|
DATABASE_URL=postgres://... # PostgreSQL
|
||||||
|
REDIS_URL=redis://... # Redis
|
||||||
|
|
||||||
|
# Sandbox (enabled by default)
|
||||||
|
SANDBOX_ENABLED=true # Auto-detects Docker or Piston API
|
||||||
|
SANDBOX_NETWORK=none # none | bridge
|
||||||
|
SANDBOX_MEMORY=512m
|
||||||
|
SANDBOX_CPUS=1
|
||||||
|
SANDBOX_TIMEOUT_MS=60000
|
||||||
|
|
||||||
|
# Webhooks
|
||||||
|
WEBHOOK_SECRET=random-32-chars # Auto-generated if missing
|
||||||
|
WEBHOOK_BASE_PATH=/hooks # Default: /hooks
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
SCHEDULER_ENABLED=true # Default: true
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
AUDIT_ENABLED=true # Default: true
|
||||||
|
AUDIT_LOG_PATH=/data/audit.jsonl
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080 # Railway sets this
|
||||||
|
HOST=0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
### What's Enforced
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| **Access** | Telegram user ID allowlist |
|
||||||
|
| **Auth** | Timing-safe token comparison |
|
||||||
|
| **Sandbox** | Docker (local) or Piston API (cloud), isolated |
|
||||||
|
| **Secrets** | Env-only, auto-redacted in logs |
|
||||||
|
| **Audit** | Every interaction logged |
|
||||||
|
|
||||||
|
### Sandbox Backends
|
||||||
|
|
||||||
|
AssureBot auto-detects the best available backend:
|
||||||
|
|
||||||
|
1. **Docker** - Full isolation, no network, caps dropped (requires Docker socket)
|
||||||
|
2. **Piston API** - Free cloud execution, 15+ languages (works on Railway/Render/Fly)
|
||||||
|
3. **None** - Sandbox disabled if neither available
|
||||||
|
|
||||||
|
### What's NOT Included
|
||||||
|
|
||||||
|
Intentionally removed:
|
||||||
|
|
||||||
|
- Web UI / setup wizard
|
||||||
|
- Plugin system
|
||||||
|
- WhatsApp/Signal/Discord/Slack
|
||||||
|
- File-based configuration
|
||||||
|
- Multi-account support
|
||||||
|
- Desktop/mobile apps
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd secure
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Dev mode
|
||||||
|
TELEGRAM_BOT_TOKEN=xxx \
|
||||||
|
ANTHROPIC_API_KEY=xxx \
|
||||||
|
ALLOWED_USERS=123456789 \
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/health` | Health check (JSON) |
|
||||||
|
| `/ready` | Readiness probe |
|
||||||
|
| `/hooks/*` | Webhook receiver (POST, auth required) |
|
||||||
|
|
||||||
|
## Webhook Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Send a webhook
|
||||||
|
curl -X POST https://your-app.up.railway.app/hooks/github \
|
||||||
|
-H "Authorization: Bearer YOUR_WEBHOOK_SECRET" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"action": "opened", "pull_request": {"title": "Fix bug"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
All webhooks are:
|
||||||
|
1. Authenticated (token required)
|
||||||
|
2. Summarized by AI
|
||||||
|
3. Forwarded to all allowed Telegram users
|
||||||
|
|
||||||
|
## Audit Log Format
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"ts":"2024-01-15T10:30:00Z","type":"message","userId":123,"text":"Hello","response":"Hi!"}
|
||||||
|
{"ts":"2024-01-15T10:30:05Z","type":"webhook","path":"/hooks/github","status":200}
|
||||||
|
{"ts":"2024-01-15T10:30:10Z","type":"sandbox","command":"[python] print(1)","exitCode":0}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────┐ ┌────────────────────┐
|
||||||
|
│ AssureBot │────▶│ Sandbox │
|
||||||
|
│ (main container) │ │ (Docker/Piston) │
|
||||||
|
│ │ │ │
|
||||||
|
│ • Telegram bot │ │ • Code execution │
|
||||||
|
│ • Webhook recv │ │ • 15+ languages │
|
||||||
|
│ • Scheduler │ │ • Isolated │
|
||||||
|
│ • Personality │ │ • No network │
|
||||||
|
└────────────────────┘ └────────────────────┘
|
||||||
|
│
|
||||||
|
├────▶ [PostgreSQL] - Tasks, profiles
|
||||||
|
├────▶ [Redis] - Conversations, cache
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Anthropic/OpenAI/OpenRouter]
|
||||||
|
(Direct API calls)
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT - Same as Moltbot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Full Moltbot**: [github.com/moltbot/moltbot](https://github.com/moltbot/moltbot)
|
||||||
391
secure/agent.ts
Normal file
391
secure/agent.ts
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Agent Core
|
||||||
|
*
|
||||||
|
* Minimal AI agent that handles conversations with image support.
|
||||||
|
* Direct API calls to Anthropic or OpenAI - no intermediaries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import type { SecureConfig } from "./config.js";
|
||||||
|
import type { AuditLogger } from "./audit.js";
|
||||||
|
|
||||||
|
export type ImageContent = {
|
||||||
|
type: "image";
|
||||||
|
data: string; // base64
|
||||||
|
mediaType: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextContent = {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageContent = string | (TextContent | ImageContent)[];
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: MessageContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentResponse = {
|
||||||
|
text: string;
|
||||||
|
usage?: {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentCore = {
|
||||||
|
chat: (messages: Message[], systemPrompt?: string) => Promise<AgentResponse>;
|
||||||
|
analyzeImage: (imageData: string, mediaType: ImageContent["mediaType"], prompt?: string) => Promise<AgentResponse>;
|
||||||
|
provider: "anthropic" | "openai" | "openrouter";
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
|
||||||
|
const DEFAULT_OPENAI_MODEL = "gpt-4o";
|
||||||
|
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-3.5-sonnet";
|
||||||
|
|
||||||
|
const DEFAULT_SYSTEM_PROMPT = `You are AssureBot, a helpful AI assistant running as a secure Telegram bot.
|
||||||
|
|
||||||
|
You are direct, concise, and helpful. You can:
|
||||||
|
- Answer questions and have conversations
|
||||||
|
- Analyze images and documents shared with you
|
||||||
|
- Help with coding and technical tasks
|
||||||
|
- Summarize content and extract information
|
||||||
|
|
||||||
|
## Available Commands (tell users about these when relevant)
|
||||||
|
- /js <code> - Run JavaScript
|
||||||
|
- /python <code> - Run Python
|
||||||
|
- /ts <code> - Run TypeScript
|
||||||
|
- /bash <code> - Run shell commands
|
||||||
|
- /run <lang> <code> - Run code in any language (python, js, ts, bash, rust, go, c, cpp, java, ruby, php)
|
||||||
|
- /status - Check bot status
|
||||||
|
- /clear - Clear conversation history
|
||||||
|
|
||||||
|
When users ask to run or test code, guide them to use the appropriate command.
|
||||||
|
Example: "Use /js console.log('hello')" or "Try /python print('hello')"
|
||||||
|
|
||||||
|
Be security-conscious:
|
||||||
|
- Never reveal API keys, tokens, or secrets
|
||||||
|
- Don't execute commands that could harm the system
|
||||||
|
- Warn users about potentially dangerous operations`;
|
||||||
|
|
||||||
|
function createAnthropicAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||||
|
const client = new Anthropic({
|
||||||
|
apiKey: config.ai.apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = config.ai.model || DEFAULT_ANTHROPIC_MODEL;
|
||||||
|
|
||||||
|
function convertContent(content: MessageContent): Anthropic.MessageParam["content"] {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return content.map((part) => {
|
||||||
|
if (part.type === "text") {
|
||||||
|
return { type: "text" as const, text: part.text };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "image" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: part.mediaType,
|
||||||
|
data: part.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "anthropic",
|
||||||
|
|
||||||
|
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model,
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: systemPrompt || DEFAULT_SYSTEM_PROMPT,
|
||||||
|
messages: messages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: convertContent(m.content),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.content
|
||||||
|
.filter((block): block is Anthropic.TextBlock => block.type === "text")
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: {
|
||||||
|
inputTokens: response.usage.input_tokens,
|
||||||
|
outputTokens: response.usage.output_tokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
audit.error({
|
||||||
|
error: `Anthropic API error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeImage(
|
||||||
|
imageData: string,
|
||||||
|
mediaType: ImageContent["mediaType"],
|
||||||
|
prompt = "What's in this image? Describe it in detail."
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
const messages: Message[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image", data: imageData, mediaType },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return this.chat(messages);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpenAIAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: config.ai.apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = config.ai.model || DEFAULT_OPENAI_MODEL;
|
||||||
|
|
||||||
|
type OpenAIContent = OpenAI.ChatCompletionContentPart[];
|
||||||
|
|
||||||
|
function convertContent(content: MessageContent): string | OpenAIContent {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return content.map((part) => {
|
||||||
|
if (part.type === "text") {
|
||||||
|
return { type: "text" as const, text: part.text };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "image_url" as const,
|
||||||
|
image_url: {
|
||||||
|
url: `data:${part.mediaType};base64,${part.data}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "openai",
|
||||||
|
|
||||||
|
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||||
|
try {
|
||||||
|
const openaiMessages: OpenAI.ChatCompletionMessageParam[] = [
|
||||||
|
{ role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: "user",
|
||||||
|
content: convertContent(m.content),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Assistant messages are always text
|
||||||
|
openaiMessages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: typeof m.content === "string" ? m.content : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
max_tokens: 4096,
|
||||||
|
messages: openaiMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.choices[0]?.message?.content || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: response.usage
|
||||||
|
? {
|
||||||
|
inputTokens: response.usage.prompt_tokens,
|
||||||
|
outputTokens: response.usage.completion_tokens,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
audit.error({
|
||||||
|
error: `OpenAI API error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeImage(
|
||||||
|
imageData: string,
|
||||||
|
mediaType: ImageContent["mediaType"],
|
||||||
|
prompt = "What's in this image? Describe it in detail."
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
const messages: Message[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image", data: imageData, mediaType },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return this.chat(messages);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpenRouterAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||||
|
// OpenRouter uses OpenAI-compatible API
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: config.ai.apiKey,
|
||||||
|
baseURL: "https://openrouter.ai/api/v1",
|
||||||
|
defaultHeaders: {
|
||||||
|
"HTTP-Referer": "https://github.com/TNovs1/moltbot",
|
||||||
|
"X-Title": "AssureBot",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = config.ai.model || DEFAULT_OPENROUTER_MODEL;
|
||||||
|
|
||||||
|
type OpenAIContent = OpenAI.ChatCompletionContentPart[];
|
||||||
|
|
||||||
|
function convertContent(content: MessageContent): string | OpenAIContent {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return content.map((part) => {
|
||||||
|
if (part.type === "text") {
|
||||||
|
return { type: "text" as const, text: part.text };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "image_url" as const,
|
||||||
|
image_url: {
|
||||||
|
url: `data:${part.mediaType};base64,${part.data}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "openrouter",
|
||||||
|
|
||||||
|
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||||
|
try {
|
||||||
|
const openaiMessages: OpenAI.ChatCompletionMessageParam[] = [
|
||||||
|
{ role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: "user",
|
||||||
|
content: convertContent(m.content),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: typeof m.content === "string" ? m.content : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
max_tokens: 4096,
|
||||||
|
messages: openaiMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.choices[0]?.message?.content || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: response.usage
|
||||||
|
? {
|
||||||
|
inputTokens: response.usage.prompt_tokens,
|
||||||
|
outputTokens: response.usage.completion_tokens,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
audit.error({
|
||||||
|
error: `OpenRouter API error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeImage(
|
||||||
|
imageData: string,
|
||||||
|
mediaType: ImageContent["mediaType"],
|
||||||
|
prompt = "What's in this image? Describe it in detail."
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
const messages: Message[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image", data: imageData, mediaType },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return this.chat(messages);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||||
|
if (config.ai.provider === "anthropic") {
|
||||||
|
return createAnthropicAgent(config, audit);
|
||||||
|
}
|
||||||
|
if (config.ai.provider === "openrouter") {
|
||||||
|
return createOpenRouterAgent(config, audit);
|
||||||
|
}
|
||||||
|
return createOpenAIAgent(config, audit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory conversation store
|
||||||
|
* For Railway, consider using Redis or persistent storage
|
||||||
|
*/
|
||||||
|
export type ConversationStore = {
|
||||||
|
get: (userId: number) => Message[];
|
||||||
|
add: (userId: number, message: Message) => void;
|
||||||
|
clear: (userId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_HISTORY = 20;
|
||||||
|
|
||||||
|
export function createConversationStore(): ConversationStore {
|
||||||
|
const conversations = new Map<number, Message[]>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(userId: number): Message[] {
|
||||||
|
return conversations.get(userId) || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
add(userId: number, message: Message): void {
|
||||||
|
const history = conversations.get(userId) || [];
|
||||||
|
history.push(message);
|
||||||
|
// Keep only last N messages
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.splice(0, history.length - MAX_HISTORY);
|
||||||
|
}
|
||||||
|
conversations.set(userId, history);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(userId: number): void {
|
||||||
|
conversations.delete(userId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
260
secure/audit.ts
Normal file
260
secure/audit.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Moltbot Secure - Audit Logger
|
||||||
|
*
|
||||||
|
* Every interaction is logged for transparency and debugging.
|
||||||
|
* Logs are append-only JSONL format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appendFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
export type AuditEventType =
|
||||||
|
| "startup"
|
||||||
|
| "shutdown"
|
||||||
|
| "message"
|
||||||
|
| "message_blocked"
|
||||||
|
| "webhook"
|
||||||
|
| "webhook_blocked"
|
||||||
|
| "sandbox"
|
||||||
|
| "cron"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type AuditEvent = {
|
||||||
|
ts: string;
|
||||||
|
type: AuditEventType;
|
||||||
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
|
text?: string;
|
||||||
|
response?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: number;
|
||||||
|
command?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
jobId?: string;
|
||||||
|
jobName?: string;
|
||||||
|
error?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogger = {
|
||||||
|
log: (event: Omit<AuditEvent, "ts">) => void;
|
||||||
|
startup: () => void;
|
||||||
|
shutdown: () => void;
|
||||||
|
message: (params: {
|
||||||
|
userId: number;
|
||||||
|
username?: string;
|
||||||
|
text: string;
|
||||||
|
response?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}) => void;
|
||||||
|
messageBlocked: (params: {
|
||||||
|
userId: number;
|
||||||
|
username?: string;
|
||||||
|
reason: string;
|
||||||
|
}) => void;
|
||||||
|
webhook: (params: {
|
||||||
|
path: string;
|
||||||
|
status: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}) => void;
|
||||||
|
webhookBlocked: (params: {
|
||||||
|
path: string;
|
||||||
|
reason: string;
|
||||||
|
}) => void;
|
||||||
|
sandbox: (params: {
|
||||||
|
command: string;
|
||||||
|
exitCode: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}) => void;
|
||||||
|
cron: (params: {
|
||||||
|
jobId: string;
|
||||||
|
jobName: string;
|
||||||
|
status: "ok" | "error" | "skipped";
|
||||||
|
error?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}) => void;
|
||||||
|
error: (params: {
|
||||||
|
error: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive patterns from text
|
||||||
|
*/
|
||||||
|
function redact(text: string): string {
|
||||||
|
// Redact common secret patterns
|
||||||
|
return text
|
||||||
|
// API keys
|
||||||
|
.replace(/sk-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]")
|
||||||
|
.replace(/sk-ant-[a-zA-Z0-9-]{20,}/g, "[REDACTED_ANTHROPIC_KEY]")
|
||||||
|
// Tokens
|
||||||
|
.replace(/\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b/g, "[REDACTED_TG_TOKEN]")
|
||||||
|
// Bearer tokens
|
||||||
|
.replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, "Bearer [REDACTED]")
|
||||||
|
// Passwords in URLs
|
||||||
|
.replace(/:\/\/[^:]+:[^@]+@/g, "://[REDACTED]@")
|
||||||
|
// Generic secrets
|
||||||
|
.replace(/(['"]?(?:password|secret|token|key|apikey|api_key)['"]?\s*[=:]\s*)['"][^'"]+['"]/gi, "$1[REDACTED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuditLogger(opts: {
|
||||||
|
enabled: boolean;
|
||||||
|
logPath: string;
|
||||||
|
}): AuditLogger {
|
||||||
|
const { enabled, logPath } = opts;
|
||||||
|
|
||||||
|
// Ensure log directory exists
|
||||||
|
if (enabled) {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(logPath), { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Directory may already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(event: AuditEvent): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// Redact sensitive data
|
||||||
|
const redacted: AuditEvent = {
|
||||||
|
...event,
|
||||||
|
text: event.text ? redact(event.text) : undefined,
|
||||||
|
response: event.response ? redact(event.response) : undefined,
|
||||||
|
command: event.command ? redact(event.command) : undefined,
|
||||||
|
error: event.error ? redact(event.error) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const line = JSON.stringify(redacted) + "\n";
|
||||||
|
appendFileSync(logPath, line, { encoding: "utf-8" });
|
||||||
|
} catch (err) {
|
||||||
|
// Log to stderr as fallback
|
||||||
|
console.error("[audit] Failed to write audit log:", err);
|
||||||
|
console.error("[audit]", JSON.stringify(redacted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger: AuditLogger = {
|
||||||
|
log: (event) => {
|
||||||
|
write({ ...event, ts: new Date().toISOString() });
|
||||||
|
},
|
||||||
|
|
||||||
|
startup: () => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "startup",
|
||||||
|
metadata: {
|
||||||
|
nodeVersion: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
shutdown: () => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "shutdown",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
message: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "message",
|
||||||
|
userId: params.userId,
|
||||||
|
username: params.username,
|
||||||
|
text: params.text,
|
||||||
|
response: params.response,
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
messageBlocked: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "message_blocked",
|
||||||
|
userId: params.userId,
|
||||||
|
username: params.username,
|
||||||
|
error: params.reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
webhook: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "webhook",
|
||||||
|
path: params.path,
|
||||||
|
status: params.status,
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
webhookBlocked: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "webhook_blocked",
|
||||||
|
path: params.path,
|
||||||
|
error: params.reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
sandbox: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "sandbox",
|
||||||
|
command: params.command,
|
||||||
|
exitCode: params.exitCode,
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
cron: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "cron",
|
||||||
|
jobId: params.jobId,
|
||||||
|
jobName: params.jobName,
|
||||||
|
status: params.status === "ok" ? 200 : params.status === "skipped" ? 204 : 500,
|
||||||
|
error: params.error,
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (params) => {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "error",
|
||||||
|
error: params.error,
|
||||||
|
metadata: params.metadata,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console logger for development/debugging
|
||||||
|
*/
|
||||||
|
export function createConsoleAuditLogger(): AuditLogger {
|
||||||
|
const log = (event: Omit<AuditEvent, "ts">) => {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
console.log(`[audit] ${ts} ${event.type}`, JSON.stringify(event, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
log,
|
||||||
|
startup: () => log({ type: "startup" }),
|
||||||
|
shutdown: () => log({ type: "shutdown" }),
|
||||||
|
message: (p) => log({ type: "message", ...p }),
|
||||||
|
messageBlocked: (p) => log({ type: "message_blocked", userId: p.userId, username: p.username, error: p.reason }),
|
||||||
|
webhook: (p) => log({ type: "webhook", ...p }),
|
||||||
|
webhookBlocked: (p) => log({ type: "webhook_blocked", path: p.path, error: p.reason }),
|
||||||
|
sandbox: (p) => log({ type: "sandbox", ...p }),
|
||||||
|
cron: (p) => log({ type: "cron", jobId: p.jobId, jobName: p.jobName, status: p.status === "ok" ? 200 : 500, error: p.error, durationMs: p.durationMs }),
|
||||||
|
error: (p) => log({ type: "error", ...p }),
|
||||||
|
};
|
||||||
|
}
|
||||||
253
secure/config.ts
Normal file
253
secure/config.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Environment-only Configuration
|
||||||
|
*
|
||||||
|
* All configuration via environment variables.
|
||||||
|
* No config files, no filesystem secrets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SecureConfig = {
|
||||||
|
// Telegram
|
||||||
|
telegram: {
|
||||||
|
botToken: string;
|
||||||
|
allowedUsers: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI Provider
|
||||||
|
ai: {
|
||||||
|
provider: "anthropic" | "openai" | "openrouter";
|
||||||
|
apiKey: string;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
webhooks: {
|
||||||
|
enabled: boolean;
|
||||||
|
secret: string;
|
||||||
|
basePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sandbox
|
||||||
|
sandbox: {
|
||||||
|
enabled: boolean;
|
||||||
|
image: string;
|
||||||
|
network: "none" | "bridge";
|
||||||
|
memory: string;
|
||||||
|
cpus: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scheduler
|
||||||
|
scheduler: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
audit: {
|
||||||
|
enabled: boolean;
|
||||||
|
logPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server
|
||||||
|
server: {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
gatewayToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage (optional)
|
||||||
|
storage: {
|
||||||
|
postgresUrl?: string;
|
||||||
|
redisUrl?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional(name: string, defaultValue: string): string {
|
||||||
|
return process.env[name] || defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBool(name: string, defaultValue: boolean): boolean {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
return value.toLowerCase() === "true" || value === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalInt(name: string, defaultValue: number): number {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllowedUsers(value: string): number[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => parseInt(s, 10))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAiProvider(): { provider: "anthropic" | "openai" | "openrouter"; apiKey: string } {
|
||||||
|
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
const openaiKey = process.env.OPENAI_API_KEY;
|
||||||
|
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
|
||||||
|
if (anthropicKey) {
|
||||||
|
return { provider: "anthropic", apiKey: anthropicKey };
|
||||||
|
}
|
||||||
|
if (openaiKey) {
|
||||||
|
return { provider: "openai", apiKey: openaiKey };
|
||||||
|
}
|
||||||
|
if (openrouterKey) {
|
||||||
|
return { provider: "openrouter", apiKey: openrouterKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSecureToken(): string {
|
||||||
|
// Generate a secure random token if not provided
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let result = "";
|
||||||
|
const randomValues = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(randomValues);
|
||||||
|
for (const byte of randomValues) {
|
||||||
|
result += chars[byte % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSecureConfig(): SecureConfig {
|
||||||
|
// Required: Telegram
|
||||||
|
const botToken = required("TELEGRAM_BOT_TOKEN");
|
||||||
|
const allowedUsersRaw = required("ALLOWED_USERS");
|
||||||
|
const allowedUsers = parseAllowedUsers(allowedUsersRaw);
|
||||||
|
|
||||||
|
if (allowedUsers.length === 0) {
|
||||||
|
throw new Error("ALLOWED_USERS must contain at least one valid Telegram user ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required: AI Provider
|
||||||
|
const { provider, apiKey } = detectAiProvider();
|
||||||
|
|
||||||
|
// Optional: Webhooks
|
||||||
|
const webhooksEnabled = optionalBool("WEBHOOKS_ENABLED", true);
|
||||||
|
const webhookSecret = optional("WEBHOOK_SECRET", generateSecureToken());
|
||||||
|
|
||||||
|
// Optional: Sandbox (enabled by default - auto-detects Docker or Piston API fallback)
|
||||||
|
const sandboxEnabled = optionalBool("SANDBOX_ENABLED", true);
|
||||||
|
|
||||||
|
// Optional: Scheduler
|
||||||
|
const schedulerEnabled = optionalBool("SCHEDULER_ENABLED", true);
|
||||||
|
|
||||||
|
// Optional: Audit
|
||||||
|
const auditEnabled = optionalBool("AUDIT_ENABLED", true);
|
||||||
|
|
||||||
|
// Optional: Server
|
||||||
|
const port = optionalInt("PORT", 8080);
|
||||||
|
|
||||||
|
return {
|
||||||
|
telegram: {
|
||||||
|
botToken,
|
||||||
|
allowedUsers,
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
provider,
|
||||||
|
apiKey,
|
||||||
|
model: process.env.AI_MODEL,
|
||||||
|
},
|
||||||
|
webhooks: {
|
||||||
|
enabled: webhooksEnabled,
|
||||||
|
secret: webhookSecret,
|
||||||
|
basePath: optional("WEBHOOK_BASE_PATH", "/hooks"),
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
enabled: sandboxEnabled,
|
||||||
|
image: optional("SANDBOX_IMAGE", "node:22-slim"),
|
||||||
|
network: (optional("SANDBOX_NETWORK", "none") as "none" | "bridge"),
|
||||||
|
memory: optional("SANDBOX_MEMORY", "512m"),
|
||||||
|
cpus: optional("SANDBOX_CPUS", "1"),
|
||||||
|
timeoutMs: optionalInt("SANDBOX_TIMEOUT_MS", 60000),
|
||||||
|
},
|
||||||
|
scheduler: {
|
||||||
|
enabled: schedulerEnabled,
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
enabled: auditEnabled,
|
||||||
|
logPath: optional("AUDIT_LOG_PATH", "/data/audit.jsonl"),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port,
|
||||||
|
host: optional("HOST", "0.0.0.0"),
|
||||||
|
gatewayToken: optional("ASSUREBOT_GATEWAY_TOKEN", generateSecureToken()),
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
postgresUrl: process.env.DATABASE_URL || process.env.POSTGRES_URL,
|
||||||
|
redisUrl: process.env.REDIS_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate config at startup and log warnings
|
||||||
|
*/
|
||||||
|
export function validateConfig(config: SecureConfig): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Check for weak security settings
|
||||||
|
if (config.sandbox.enabled && config.sandbox.network === "bridge") {
|
||||||
|
warnings.push("SECURITY: Sandbox network is 'bridge' - containers can access network");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.telegram.allowedUsers.length > 10) {
|
||||||
|
warnings.push(`Large allowlist (${config.telegram.allowedUsers.length} users) - review if intentional`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.audit.enabled) {
|
||||||
|
warnings.push("SECURITY: Audit logging is disabled - no interaction records will be kept");
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive values for logging
|
||||||
|
*/
|
||||||
|
export function redactConfig(config: SecureConfig): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
telegram: {
|
||||||
|
botToken: config.telegram.botToken.slice(0, 8) + "...",
|
||||||
|
allowedUsers: config.telegram.allowedUsers,
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
provider: config.ai.provider,
|
||||||
|
apiKey: config.ai.apiKey.slice(0, 8) + "...",
|
||||||
|
model: config.ai.model,
|
||||||
|
},
|
||||||
|
webhooks: {
|
||||||
|
enabled: config.webhooks.enabled,
|
||||||
|
secret: "[REDACTED]",
|
||||||
|
basePath: config.webhooks.basePath,
|
||||||
|
},
|
||||||
|
sandbox: config.sandbox,
|
||||||
|
scheduler: config.scheduler,
|
||||||
|
audit: config.audit,
|
||||||
|
server: {
|
||||||
|
port: config.server.port,
|
||||||
|
host: config.server.host,
|
||||||
|
gatewayToken: "[REDACTED]",
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
postgresUrl: config.storage.postgresUrl ? "[CONFIGURED]" : undefined,
|
||||||
|
redisUrl: config.storage.redisUrl ? "[CONFIGURED]" : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
120
secure/documents.ts
Normal file
120
secure/documents.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Document Analysis
|
||||||
|
*
|
||||||
|
* Extract text from various document formats for AI analysis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DocumentResult = {
|
||||||
|
text: string;
|
||||||
|
pageCount?: number;
|
||||||
|
format: string;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_TEXT_LENGTH = 50000; // ~12k tokens
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text from a buffer based on mime type
|
||||||
|
*/
|
||||||
|
export async function extractText(
|
||||||
|
buffer: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
filename?: string
|
||||||
|
): Promise<DocumentResult> {
|
||||||
|
const ext = filename?.split(".").pop()?.toLowerCase();
|
||||||
|
|
||||||
|
// Plain text files
|
||||||
|
if (
|
||||||
|
mimeType.startsWith("text/") ||
|
||||||
|
ext === "txt" ||
|
||||||
|
ext === "md" ||
|
||||||
|
ext === "json" ||
|
||||||
|
ext === "xml" ||
|
||||||
|
ext === "csv" ||
|
||||||
|
ext === "log"
|
||||||
|
) {
|
||||||
|
return extractPlainText(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
if (mimeType === "application/pdf" || ext === "pdf") {
|
||||||
|
return extractPdf(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code files (treat as text)
|
||||||
|
const codeExtensions = [
|
||||||
|
"js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "java",
|
||||||
|
"c", "cpp", "h", "hpp", "cs", "php", "swift", "kt", "scala",
|
||||||
|
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "env",
|
||||||
|
"sql", "graphql", "html", "css", "scss", "less"
|
||||||
|
];
|
||||||
|
if (ext && codeExtensions.includes(ext)) {
|
||||||
|
return extractPlainText(buffer, ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported format
|
||||||
|
return {
|
||||||
|
text: `[Unsupported document format: ${mimeType}${ext ? ` (.${ext})` : ""}]`,
|
||||||
|
format: "unsupported",
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text
|
||||||
|
*/
|
||||||
|
function extractPlainText(buffer: Buffer, format = "text"): DocumentResult {
|
||||||
|
let text = buffer.toString("utf-8");
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
if (text.length > MAX_TEXT_LENGTH) {
|
||||||
|
text = text.slice(0, MAX_TEXT_LENGTH) + "\n\n[... truncated ...]";
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, format, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text from PDF using pdf-parse
|
||||||
|
*/
|
||||||
|
async function extractPdf(buffer: Buffer): Promise<DocumentResult> {
|
||||||
|
try {
|
||||||
|
// Dynamic import to avoid bundling issues
|
||||||
|
const pdfParse = await import("pdf-parse").then(m => m.default);
|
||||||
|
const data = await pdfParse(buffer);
|
||||||
|
|
||||||
|
let text = data.text;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
if (text.length > MAX_TEXT_LENGTH) {
|
||||||
|
text = text.slice(0, MAX_TEXT_LENGTH) + "\n\n[... truncated ...]";
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
pageCount: data.numpages,
|
||||||
|
format: "pdf",
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
text: `[Failed to parse PDF: ${msg}]`,
|
||||||
|
format: "pdf-error",
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize document metadata for logging
|
||||||
|
*/
|
||||||
|
export function summarizeDocument(result: DocumentResult): string {
|
||||||
|
const parts = [result.format.toUpperCase()];
|
||||||
|
if (result.pageCount) parts.push(`${result.pageCount} pages`);
|
||||||
|
parts.push(`${result.text.length} chars`);
|
||||||
|
if (result.truncated) parts.push("truncated");
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
223
secure/index.ts
Normal file
223
secure/index.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Entry Point
|
||||||
|
*
|
||||||
|
* Lean, secure, self-hosted AI assistant for Railway.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 npx tsx secure/index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { loadSecureConfig, validateConfig, redactConfig } from "./config.js";
|
||||||
|
import { createAuditLogger } from "./audit.js";
|
||||||
|
import { createAgent, createConversationStore } from "./agent.js";
|
||||||
|
import { createTelegramBot } from "./telegram.js";
|
||||||
|
import { createWebhookHandler } from "./webhooks.js";
|
||||||
|
import { createSandboxRunner } from "./sandbox.js";
|
||||||
|
import { createScheduler } from "./scheduler.js";
|
||||||
|
import { createStorage, type Storage } from "./storage.js";
|
||||||
|
import { createPersonality } from "./personality.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("=".repeat(50));
|
||||||
|
console.log(" ASSUREBOT");
|
||||||
|
console.log(" Lean, secure, self-hosted AI assistant");
|
||||||
|
console.log("=".repeat(50));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
console.log("[init] Loading configuration...");
|
||||||
|
const config = loadSecureConfig();
|
||||||
|
|
||||||
|
// Validate and warn
|
||||||
|
const warnings = validateConfig(config);
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.log("[init] Configuration warnings:");
|
||||||
|
for (const w of warnings) {
|
||||||
|
console.log(` - ${w}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log redacted config
|
||||||
|
console.log("[init] Configuration loaded:");
|
||||||
|
console.log(JSON.stringify(redactConfig(config), null, 2));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Create audit logger
|
||||||
|
console.log("[init] Creating audit logger...");
|
||||||
|
const audit = createAuditLogger({
|
||||||
|
enabled: config.audit.enabled,
|
||||||
|
logPath: config.audit.logPath,
|
||||||
|
});
|
||||||
|
audit.startup();
|
||||||
|
|
||||||
|
// Create storage (PostgreSQL + Redis)
|
||||||
|
console.log("[init] Creating storage layer...");
|
||||||
|
const storage = await createStorage({
|
||||||
|
postgres: config.storage.postgresUrl ? { url: config.storage.postgresUrl } : undefined,
|
||||||
|
redis: config.storage.redisUrl ? { url: config.storage.redisUrl } : undefined,
|
||||||
|
});
|
||||||
|
const storageHealthy = await storage.isHealthy();
|
||||||
|
console.log(`[init] Storage healthy: ${storageHealthy}`);
|
||||||
|
|
||||||
|
// Create AI agent
|
||||||
|
console.log(`[init] Creating AI agent (${config.ai.provider})...`);
|
||||||
|
const agent = createAgent(config, audit);
|
||||||
|
|
||||||
|
// Create conversation store
|
||||||
|
const conversations = createConversationStore();
|
||||||
|
|
||||||
|
// Create sandbox runner
|
||||||
|
console.log("[init] Creating sandbox runner...");
|
||||||
|
const sandbox = createSandboxRunner(config, audit);
|
||||||
|
const sandboxAvailable = await sandbox.isAvailable();
|
||||||
|
console.log(`[init] Sandbox available: ${sandboxAvailable}`);
|
||||||
|
|
||||||
|
// Create a placeholder bot for circular deps
|
||||||
|
// We'll create telegram, scheduler, and webhooks together
|
||||||
|
const { Bot } = await import("grammy");
|
||||||
|
const bot = new Bot(config.telegram.botToken);
|
||||||
|
|
||||||
|
// Create scheduler (needs bot for notifications, storage for persistence)
|
||||||
|
console.log("[init] Creating scheduler...");
|
||||||
|
const scheduler = createScheduler({
|
||||||
|
config,
|
||||||
|
audit,
|
||||||
|
agent,
|
||||||
|
telegramBot: bot,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create personality engine (learning + personalization)
|
||||||
|
console.log("[init] Creating personality engine...");
|
||||||
|
const personality = await createPersonality(storage);
|
||||||
|
|
||||||
|
// Create Telegram bot handler (with sandbox, scheduler, personality)
|
||||||
|
console.log("[init] Creating Telegram bot...");
|
||||||
|
const telegram = createTelegramBot({
|
||||||
|
config,
|
||||||
|
audit,
|
||||||
|
agent,
|
||||||
|
conversations,
|
||||||
|
sandbox,
|
||||||
|
scheduler,
|
||||||
|
personality,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create webhook handler
|
||||||
|
console.log("[init] Creating webhook handler...");
|
||||||
|
const webhooks = createWebhookHandler({
|
||||||
|
config,
|
||||||
|
audit,
|
||||||
|
agent,
|
||||||
|
telegramBot: telegram.bot,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
console.log("[init] Creating HTTP server...");
|
||||||
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
if (url.pathname === "/health" || url.pathname === "/healthz") {
|
||||||
|
const isStorageHealthy = await storage.isHealthy();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
telegram: "connected",
|
||||||
|
sandbox: sandboxAvailable ? "available" : "unavailable",
|
||||||
|
storage: isStorageHealthy ? "healthy" : "degraded",
|
||||||
|
postgres: config.storage.postgresUrl ? "configured" : "none",
|
||||||
|
redis: config.storage.redisUrl ? "configured" : "none",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readiness check
|
||||||
|
if (url.pathname === "/ready") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
res.end("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook handler
|
||||||
|
if (await webhooks.handleRequest(req, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 for everything else
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
res.end("Not Found");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
async function shutdown(signal: string) {
|
||||||
|
if (isShuttingDown) return;
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
|
console.log(`\n[shutdown] Received ${signal}, shutting down...`);
|
||||||
|
|
||||||
|
audit.shutdown();
|
||||||
|
|
||||||
|
try {
|
||||||
|
scheduler.stop();
|
||||||
|
await telegram.stop();
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[shutdown] Shutdown complete");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[shutdown] Error during shutdown:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
|
||||||
|
// Start everything
|
||||||
|
console.log("[start] Starting services...");
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
server.listen(config.server.port, config.server.host, () => {
|
||||||
|
console.log(`[start] HTTP server listening on ${config.server.host}:${config.server.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start scheduler (loads tasks from storage)
|
||||||
|
await scheduler.start();
|
||||||
|
|
||||||
|
// Start Telegram bot (polling mode for simplicity)
|
||||||
|
await telegram.start();
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log("=".repeat(50));
|
||||||
|
console.log(" ASSUREBOT IS RUNNING");
|
||||||
|
console.log();
|
||||||
|
console.log(` Telegram: Polling mode`);
|
||||||
|
console.log(` Webhooks: http://localhost:${config.server.port}${config.webhooks.basePath}/*`);
|
||||||
|
console.log(` Health: http://localhost:${config.server.port}/health`);
|
||||||
|
console.log(` Storage: ${config.storage.postgresUrl ? "PostgreSQL" : "memory"}${config.storage.redisUrl ? " + Redis" : ""}`);
|
||||||
|
console.log(` Allowed: ${config.telegram.allowedUsers.length} users`);
|
||||||
|
console.log();
|
||||||
|
console.log(" Press Ctrl+C to stop");
|
||||||
|
console.log("=".repeat(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
secure/package.json
Normal file
26
secure/package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "assurebot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AssureBot - Lean, secure, self-hosted AI assistant for Railway",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"cron": "^3.1.7",
|
||||||
|
"grammy": "^1.21.1",
|
||||||
|
"openai": "^4.77.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
secure/pdf-parse.d.ts
vendored
Normal file
10
secure/pdf-parse.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
declare module "pdf-parse" {
|
||||||
|
function pdfParse(dataBuffer: Buffer): Promise<{
|
||||||
|
numpages: number;
|
||||||
|
numrender: number;
|
||||||
|
info: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
export default pdfParse;
|
||||||
|
}
|
||||||
265
secure/personality.ts
Normal file
265
secure/personality.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Personality Engine
|
||||||
|
*
|
||||||
|
* Persistent, evolving AI personality that learns from conversations.
|
||||||
|
* - Stores traits and preferences in Redis (fast access)
|
||||||
|
* - Syncs to PostgreSQL (durability)
|
||||||
|
* - Learns user preferences, tone, and topics over time
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Storage, UserProfile, PersonalityTraits } from "./storage.js";
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { UserProfile, PersonalityTraits };
|
||||||
|
|
||||||
|
export type Personality = {
|
||||||
|
getSystemPrompt: (userId: number) => Promise<string>;
|
||||||
|
getUserProfile: (userId: number) => Promise<UserProfile>;
|
||||||
|
updateUserProfile: (userId: number, updates: Partial<UserProfile>) => Promise<void>;
|
||||||
|
learnFromConversation: (userId: number, userMessage: string, botResponse: string) => Promise<void>;
|
||||||
|
getTraits: () => Promise<PersonalityTraits>;
|
||||||
|
updateTraits: (updates: Partial<PersonalityTraits>) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TRAITS: PersonalityTraits = {
|
||||||
|
name: "AssureBot",
|
||||||
|
greeting: "Hey",
|
||||||
|
signOff: "",
|
||||||
|
humor: "subtle",
|
||||||
|
verbosity: "balanced",
|
||||||
|
commonPhrases: [],
|
||||||
|
avoidPhrases: [],
|
||||||
|
expertiseAreas: ["coding", "analysis", "automation"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_USER_PROFILE: Omit<UserProfile, "userId"> = {
|
||||||
|
preferredTone: "friendly",
|
||||||
|
interests: [],
|
||||||
|
recentTopics: [],
|
||||||
|
interactionCount: 0,
|
||||||
|
lastSeen: new Date(),
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createPersonality(storage: Storage): Promise<Personality> {
|
||||||
|
// Load or initialize traits from storage
|
||||||
|
let traits: PersonalityTraits = await storage.getPersonalityTraits() ?? { ...DEFAULT_TRAITS };
|
||||||
|
|
||||||
|
// Save default traits if none exist
|
||||||
|
if (!(await storage.getPersonalityTraits())) {
|
||||||
|
await storage.savePersonalityTraits(traits);
|
||||||
|
console.log("[personality] Initialized default traits");
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for hot profiles (reduces Redis calls during conversation)
|
||||||
|
const profileCache = new Map<number, UserProfile>();
|
||||||
|
|
||||||
|
async function loadUserProfile(userId: number): Promise<UserProfile> {
|
||||||
|
// Check in-memory cache first
|
||||||
|
if (profileCache.has(userId)) {
|
||||||
|
return profileCache.get(userId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try loading from storage (Redis -> PostgreSQL -> memory)
|
||||||
|
const stored = await storage.getUserProfile(userId);
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
profileCache.set(userId, stored);
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new profile for this user
|
||||||
|
const profile: UserProfile = {
|
||||||
|
userId,
|
||||||
|
...DEFAULT_USER_PROFILE,
|
||||||
|
lastSeen: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist new profile
|
||||||
|
await storage.saveUserProfile(profile);
|
||||||
|
profileCache.set(userId, profile);
|
||||||
|
console.log(`[personality] Created new profile for user ${userId}`);
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserProfile(profile: UserProfile): Promise<void> {
|
||||||
|
// Update cache
|
||||||
|
profileCache.set(profile.userId, profile);
|
||||||
|
// Persist to storage (Redis + PostgreSQL)
|
||||||
|
await storage.saveUserProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getSystemPrompt(userId: number): Promise<string> {
|
||||||
|
const profile = await loadUserProfile(userId);
|
||||||
|
|
||||||
|
let prompt = `You are ${traits.name}, a helpful AI assistant running as a Telegram bot.
|
||||||
|
|
||||||
|
## Personality
|
||||||
|
- Tone: ${profile.preferredTone}
|
||||||
|
- Verbosity: ${traits.verbosity}
|
||||||
|
- Humor: ${traits.humor === "none" ? "Stay professional" : traits.humor === "subtle" ? "Occasional light humor is fine" : "Be playful and fun"}
|
||||||
|
|
||||||
|
## Your Expertise
|
||||||
|
${traits.expertiseAreas.map(e => `- ${e}`).join("\n")}
|
||||||
|
|
||||||
|
## About This User
|
||||||
|
- Interactions: ${profile.interactionCount}
|
||||||
|
- Interests: ${profile.interests.length > 0 ? profile.interests.join(", ") : "Not yet known"}
|
||||||
|
- Recent topics: ${profile.recentTopics.length > 0 ? profile.recentTopics.slice(-3).join(", ") : "None yet"}
|
||||||
|
${profile.notes.length > 0 ? `- Notes: ${profile.notes.slice(-3).join("; ")}` : ""}
|
||||||
|
|
||||||
|
## Available Commands (you can tell users about these)
|
||||||
|
- /js <code> - Run JavaScript code
|
||||||
|
- /python <code> or /py <code> - Run Python code
|
||||||
|
- /ts <code> - Run TypeScript code
|
||||||
|
- /bash <code> or /sh <code> - Run shell commands
|
||||||
|
- /run <language> <code> - Run code in any supported language (python, javascript, typescript, bash, rust, go, c, cpp, java, ruby, php)
|
||||||
|
- /status - Check bot and sandbox status
|
||||||
|
- /clear - Clear conversation history
|
||||||
|
- /schedule "<cron>" "<name>" <prompt> - Schedule recurring AI tasks
|
||||||
|
- /tasks - List scheduled tasks
|
||||||
|
- /deltask <id> - Delete a task
|
||||||
|
|
||||||
|
When a user asks to run code, you can either:
|
||||||
|
1. Tell them to use the appropriate command (e.g., "Use /js console.log('hello')")
|
||||||
|
2. Just answer their question directly if they don't need to execute code
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
- Be helpful, accurate, and security-conscious
|
||||||
|
- Never reveal API keys, tokens, or secrets
|
||||||
|
- Adapt to the user's communication style
|
||||||
|
- Remember context from this conversation
|
||||||
|
- When users want to run code, guide them to use the right command
|
||||||
|
${traits.commonPhrases.length > 0 ? `- Phrases you like: ${traits.commonPhrases.join(", ")}` : ""}
|
||||||
|
${traits.avoidPhrases.length > 0 ? `- Avoid saying: ${traits.avoidPhrases.join(", ")}` : ""}`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserProfile(userId: number): Promise<UserProfile> {
|
||||||
|
return loadUserProfile(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUserProfile(userId: number, updates: Partial<UserProfile>): Promise<void> {
|
||||||
|
const profile = await loadUserProfile(userId);
|
||||||
|
Object.assign(profile, updates);
|
||||||
|
await saveUserProfile(profile);
|
||||||
|
},
|
||||||
|
|
||||||
|
async learnFromConversation(
|
||||||
|
userId: number,
|
||||||
|
userMessage: string,
|
||||||
|
botResponse: string
|
||||||
|
): Promise<void> {
|
||||||
|
const profile = await loadUserProfile(userId);
|
||||||
|
|
||||||
|
// Update interaction count
|
||||||
|
profile.interactionCount++;
|
||||||
|
profile.lastSeen = new Date();
|
||||||
|
|
||||||
|
// Extract topics (simple keyword extraction)
|
||||||
|
const topics = extractTopics(userMessage);
|
||||||
|
if (topics.length > 0) {
|
||||||
|
// Add to recent topics, keep last 10
|
||||||
|
profile.recentTopics = [...profile.recentTopics, ...topics].slice(-10);
|
||||||
|
|
||||||
|
// Add unique topics to interests
|
||||||
|
for (const topic of topics) {
|
||||||
|
if (!profile.interests.includes(topic)) {
|
||||||
|
profile.interests.push(topic);
|
||||||
|
// Keep interests manageable
|
||||||
|
if (profile.interests.length > 20) {
|
||||||
|
profile.interests = profile.interests.slice(-20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect user preferences from message style
|
||||||
|
if (userMessage.length < 50 && !userMessage.includes("?")) {
|
||||||
|
// User prefers concise communication
|
||||||
|
profile.preferredTone = "concise";
|
||||||
|
} else if (userMessage.includes("please") || userMessage.includes("thank")) {
|
||||||
|
profile.preferredTone = "friendly";
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUserProfile(profile);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTraits(): Promise<PersonalityTraits> {
|
||||||
|
return { ...traits };
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTraits(updates: Partial<PersonalityTraits>): Promise<void> {
|
||||||
|
traits = {
|
||||||
|
...traits,
|
||||||
|
...updates,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
version: traits.version + 1,
|
||||||
|
};
|
||||||
|
// Persist to storage
|
||||||
|
await storage.savePersonalityTraits(traits);
|
||||||
|
console.log(`[personality] Updated traits (v${traits.version})`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple topic extraction from text
|
||||||
|
*/
|
||||||
|
function extractTopics(text: string): string[] {
|
||||||
|
const topics: string[] = [];
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
|
||||||
|
// Tech topics
|
||||||
|
const techKeywords = [
|
||||||
|
"python", "javascript", "typescript", "rust", "go", "java",
|
||||||
|
"docker", "kubernetes", "aws", "api", "database", "sql",
|
||||||
|
"react", "vue", "node", "linux", "git", "ci/cd",
|
||||||
|
"machine learning", "ai", "llm", "chatgpt", "claude",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const keyword of techKeywords) {
|
||||||
|
if (lowerText.includes(keyword)) {
|
||||||
|
topics.push(keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task types
|
||||||
|
if (lowerText.includes("debug") || lowerText.includes("fix") || lowerText.includes("error")) {
|
||||||
|
topics.push("debugging");
|
||||||
|
}
|
||||||
|
if (lowerText.includes("write") || lowerText.includes("create") || lowerText.includes("build")) {
|
||||||
|
topics.push("development");
|
||||||
|
}
|
||||||
|
if (lowerText.includes("explain") || lowerText.includes("how does") || lowerText.includes("what is")) {
|
||||||
|
topics.push("learning");
|
||||||
|
}
|
||||||
|
|
||||||
|
return topics.slice(0, 3); // Max 3 topics per message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a personalized greeting
|
||||||
|
*/
|
||||||
|
export function generateGreeting(traits: PersonalityTraits, profile: UserProfile): string {
|
||||||
|
const greetings = {
|
||||||
|
casual: ["Hey!", "Hi there!", "What's up?"],
|
||||||
|
professional: ["Hello.", "Good day.", "Greetings."],
|
||||||
|
friendly: ["Hey there! 👋", "Hi! Good to see you!", "Hello friend!"],
|
||||||
|
concise: ["Hi.", "Hey.", ""],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = greetings[profile.preferredTone];
|
||||||
|
const greeting = options[Math.floor(Math.random() * options.length)];
|
||||||
|
|
||||||
|
if (profile.interactionCount > 10 && profile.name) {
|
||||||
|
return `${greeting} ${profile.name}!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return greeting;
|
||||||
|
}
|
||||||
13
secure/railway.json
Normal file
13
secure/railway.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://railway.app/railway.schema.json",
|
||||||
|
"build": {
|
||||||
|
"builder": "DOCKERFILE",
|
||||||
|
"dockerfilePath": "secure/Dockerfile"
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"healthcheckPath": "/health",
|
||||||
|
"healthcheckTimeout": 30,
|
||||||
|
"restartPolicyType": "ON_FAILURE",
|
||||||
|
"restartPolicyMaxRetries": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
482
secure/sandbox.ts
Normal file
482
secure/sandbox.ts
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Sandbox Execution
|
||||||
|
*
|
||||||
|
* Isolated code execution with multiple backends:
|
||||||
|
* 1. Docker (local) - if Docker socket available
|
||||||
|
* 2. Piston API (cloud) - free code execution API fallback
|
||||||
|
*
|
||||||
|
* Security-first: no network, read-only root, resource limits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import type { SecureConfig } from "./config.js";
|
||||||
|
import type { AuditLogger } from "./audit.js";
|
||||||
|
|
||||||
|
export type SandboxResult = {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
timedOut: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SandboxRunner = {
|
||||||
|
run: (command: string, stdin?: string) => Promise<SandboxResult>;
|
||||||
|
runCode: (language: string, code: string) => Promise<SandboxResult>;
|
||||||
|
isAvailable: () => Promise<boolean>;
|
||||||
|
backend: "docker" | "piston" | "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Piston API - free cloud-based code execution
|
||||||
|
const PISTON_API = "https://emkc.org/api/v2/piston";
|
||||||
|
|
||||||
|
// Supported languages for Piston
|
||||||
|
const PISTON_LANGUAGES: Record<string, { language: string; version: string }> = {
|
||||||
|
python: { language: "python", version: "3.10" },
|
||||||
|
python3: { language: "python", version: "3.10" },
|
||||||
|
py: { language: "python", version: "3.10" },
|
||||||
|
javascript: { language: "javascript", version: "18.15.0" },
|
||||||
|
js: { language: "javascript", version: "18.15.0" },
|
||||||
|
node: { language: "javascript", version: "18.15.0" },
|
||||||
|
typescript: { language: "typescript", version: "5.0.3" },
|
||||||
|
ts: { language: "typescript", version: "5.0.3" },
|
||||||
|
bash: { language: "bash", version: "5.2.0" },
|
||||||
|
sh: { language: "bash", version: "5.2.0" },
|
||||||
|
shell: { language: "bash", version: "5.2.0" },
|
||||||
|
rust: { language: "rust", version: "1.68.2" },
|
||||||
|
go: { language: "go", version: "1.16.2" },
|
||||||
|
c: { language: "c", version: "10.2.0" },
|
||||||
|
cpp: { language: "c++", version: "10.2.0" },
|
||||||
|
java: { language: "java", version: "15.0.2" },
|
||||||
|
ruby: { language: "ruby", version: "3.0.1" },
|
||||||
|
php: { language: "php", version: "8.2.3" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Docker is available
|
||||||
|
*/
|
||||||
|
async function checkDocker(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn("docker", ["version"], {
|
||||||
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
|
});
|
||||||
|
proc.on("error", () => resolve(false));
|
||||||
|
proc.on("close", (code) => resolve(code === 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Piston API is available
|
||||||
|
*/
|
||||||
|
async function checkPiston(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${PISTON_API}/runtimes`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute code via Piston API
|
||||||
|
*/
|
||||||
|
async function runPiston(
|
||||||
|
language: string,
|
||||||
|
code: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<SandboxResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const langConfig = PISTON_LANGUAGES[language.toLowerCase()];
|
||||||
|
if (!langConfig) {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: "",
|
||||||
|
stderr: `Unsupported language: ${language}\n\nSupported: ${Object.keys(PISTON_LANGUAGES).join(", ")}`,
|
||||||
|
timedOut: false,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${PISTON_API}/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
language: langConfig.language,
|
||||||
|
version: langConfig.version,
|
||||||
|
files: [{ content: code }],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: "",
|
||||||
|
stderr: `Piston API error: ${response.status} ${text}`,
|
||||||
|
timedOut: false,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as {
|
||||||
|
run: { stdout: string; stderr: string; code: number; signal: string | null };
|
||||||
|
compile?: { stdout: string; stderr: string; code: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for compilation errors
|
||||||
|
if (result.compile && result.compile.code !== 0) {
|
||||||
|
return {
|
||||||
|
exitCode: result.compile.code,
|
||||||
|
stdout: result.compile.stdout || "",
|
||||||
|
stderr: result.compile.stderr || "Compilation failed",
|
||||||
|
timedOut: false,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: result.run.code,
|
||||||
|
stdout: (result.run.stdout || "").slice(0, 10000),
|
||||||
|
stderr: (result.run.stderr || "").slice(0, 10000),
|
||||||
|
timedOut: result.run.signal === "SIGKILL",
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const isTimeout = err instanceof Error && err.name === "TimeoutError";
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: "",
|
||||||
|
stderr: isTimeout ? "Execution timed out" : `Error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
timedOut: isTimeout,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Docker run arguments for secure execution
|
||||||
|
*/
|
||||||
|
function buildDockerArgs(config: SecureConfig["sandbox"], command: string): string[] {
|
||||||
|
const args: string[] = [
|
||||||
|
"run",
|
||||||
|
"--rm", // Remove container after exit
|
||||||
|
"-i", // Interactive (for stdin)
|
||||||
|
|
||||||
|
// Security: No network by default
|
||||||
|
`--network=${config.network}`,
|
||||||
|
|
||||||
|
// Security: Read-only root filesystem
|
||||||
|
"--read-only",
|
||||||
|
|
||||||
|
// Security: tmpfs for writable areas
|
||||||
|
"--tmpfs=/tmp:rw,noexec,nosuid,size=64m",
|
||||||
|
"--tmpfs=/var/tmp:rw,noexec,nosuid,size=64m",
|
||||||
|
|
||||||
|
// Security: Drop all capabilities
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
|
||||||
|
// Security: No new privileges
|
||||||
|
"--security-opt=no-new-privileges",
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
`--memory=${config.memory}`,
|
||||||
|
`--cpus=${config.cpus}`,
|
||||||
|
"--pids-limit=100",
|
||||||
|
|
||||||
|
// Timeout handled externally, but set a ulimit too
|
||||||
|
"--ulimit=cpu=60:60",
|
||||||
|
|
||||||
|
// Working directory
|
||||||
|
"--workdir=/workspace",
|
||||||
|
|
||||||
|
// Image
|
||||||
|
config.image,
|
||||||
|
|
||||||
|
// Command (via shell for flexibility)
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
command,
|
||||||
|
];
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute command via Docker
|
||||||
|
*/
|
||||||
|
async function runDocker(
|
||||||
|
config: SecureConfig["sandbox"],
|
||||||
|
command: string,
|
||||||
|
stdin?: string
|
||||||
|
): Promise<SandboxResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const args = buildDockerArgs(config, command);
|
||||||
|
|
||||||
|
const proc = spawn("docker", args, {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let timedOut = false;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const finish = (exitCode: number) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
exitCode,
|
||||||
|
stdout: stdout.slice(0, 10000), // Limit output size
|
||||||
|
stderr: stderr.slice(0, 10000),
|
||||||
|
timedOut,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
}, config.timeoutMs);
|
||||||
|
|
||||||
|
proc.stdout?.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
// Prevent memory exhaustion
|
||||||
|
if (stdout.length > 100000) {
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr?.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
if (stderr.length > 100000) {
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
stderr += `\nProcess error: ${err.message}`;
|
||||||
|
finish(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
finish(code ?? 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write stdin if provided
|
||||||
|
if (stdin && proc.stdin) {
|
||||||
|
proc.stdin.write(stdin);
|
||||||
|
proc.stdin.end();
|
||||||
|
} else {
|
||||||
|
proc.stdin?.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSandboxRunner(config: SecureConfig, audit: AuditLogger): SandboxRunner {
|
||||||
|
const sandboxConfig = config.sandbox;
|
||||||
|
|
||||||
|
// Detect available backend at creation time
|
||||||
|
let detectedBackend: "docker" | "piston" | "none" = "none";
|
||||||
|
let backendChecked = false;
|
||||||
|
|
||||||
|
async function detectBackend(): Promise<"docker" | "piston" | "none"> {
|
||||||
|
if (backendChecked) return detectedBackend;
|
||||||
|
|
||||||
|
if (!sandboxConfig.enabled) {
|
||||||
|
detectedBackend = "none";
|
||||||
|
backendChecked = true;
|
||||||
|
return detectedBackend;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Docker first
|
||||||
|
if (await checkDocker()) {
|
||||||
|
detectedBackend = "docker";
|
||||||
|
console.log("[sandbox] Using Docker backend");
|
||||||
|
} else if (await checkPiston()) {
|
||||||
|
// Fall back to Piston API
|
||||||
|
detectedBackend = "piston";
|
||||||
|
console.log("[sandbox] Using Piston API backend (Docker not available)");
|
||||||
|
} else {
|
||||||
|
detectedBackend = "none";
|
||||||
|
console.log("[sandbox] No sandbox backend available");
|
||||||
|
}
|
||||||
|
|
||||||
|
backendChecked = true;
|
||||||
|
return detectedBackend;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start detection immediately
|
||||||
|
void detectBackend();
|
||||||
|
|
||||||
|
return {
|
||||||
|
get backend() {
|
||||||
|
return detectedBackend;
|
||||||
|
},
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
const backend = await detectBackend();
|
||||||
|
return backend !== "none";
|
||||||
|
},
|
||||||
|
|
||||||
|
async run(command: string, stdin?: string): Promise<SandboxResult> {
|
||||||
|
const backend = await detectBackend();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
if (backend === "none") {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "Sandbox is disabled or no backend available",
|
||||||
|
timedOut: false,
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: SandboxResult;
|
||||||
|
|
||||||
|
if (backend === "docker") {
|
||||||
|
result = await runDocker(sandboxConfig, command, stdin);
|
||||||
|
} else {
|
||||||
|
// Piston: run as bash
|
||||||
|
result = await runPiston("bash", command, sandboxConfig.timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.sandbox({
|
||||||
|
command,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async runCode(language: string, code: string): Promise<SandboxResult> {
|
||||||
|
const backend = await detectBackend();
|
||||||
|
|
||||||
|
if (backend === "none") {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "Sandbox is disabled or no backend available",
|
||||||
|
timedOut: false,
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: SandboxResult;
|
||||||
|
|
||||||
|
if (backend === "piston") {
|
||||||
|
// Use Piston directly for language support
|
||||||
|
result = await runPiston(language, code, sandboxConfig.timeoutMs);
|
||||||
|
} else {
|
||||||
|
// Docker: build command for the language
|
||||||
|
const command = buildCommand(language, code);
|
||||||
|
result = await runDocker(sandboxConfig, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.sandbox({
|
||||||
|
command: `[${language}] ${code.slice(0, 100)}...`,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse sandbox command from user message
|
||||||
|
* Returns null if message doesn't request code execution
|
||||||
|
*/
|
||||||
|
export function parseSandboxRequest(text: string): {
|
||||||
|
language: string;
|
||||||
|
code: string;
|
||||||
|
} | null {
|
||||||
|
// Match code blocks with language
|
||||||
|
const codeBlockMatch = text.match(/```(\w+)?\n([\s\S]*?)```/);
|
||||||
|
if (codeBlockMatch) {
|
||||||
|
const language = codeBlockMatch[1] || "sh";
|
||||||
|
const code = codeBlockMatch[2].trim();
|
||||||
|
return { language, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /run command
|
||||||
|
const runMatch = text.match(/^\/run\s+(.+)$/s);
|
||||||
|
if (runMatch) {
|
||||||
|
return { language: "sh", code: runMatch[1].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /python command
|
||||||
|
const pythonMatch = text.match(/^\/python\s+(.+)$/s);
|
||||||
|
if (pythonMatch) {
|
||||||
|
return { language: "python", code: pythonMatch[1].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build execution command for language (Docker only)
|
||||||
|
*/
|
||||||
|
export function buildCommand(language: string, code: string): string {
|
||||||
|
switch (language.toLowerCase()) {
|
||||||
|
case "python":
|
||||||
|
case "py":
|
||||||
|
return `python3 -c ${JSON.stringify(code)}`;
|
||||||
|
|
||||||
|
case "javascript":
|
||||||
|
case "js":
|
||||||
|
case "node":
|
||||||
|
return `node -e ${JSON.stringify(code)}`;
|
||||||
|
|
||||||
|
case "bash":
|
||||||
|
case "sh":
|
||||||
|
case "shell":
|
||||||
|
return code;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format sandbox result for display
|
||||||
|
*/
|
||||||
|
export function formatSandboxResult(result: SandboxResult): string {
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
if (result.timedOut) {
|
||||||
|
output += "**Timed out**\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.stdout) {
|
||||||
|
output += "**Output:**\n```\n" + result.stdout.trim() + "\n```\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.stderr) {
|
||||||
|
output += "**Errors:**\n```\n" + result.stderr.trim() + "\n```\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.stdout && !result.stderr) {
|
||||||
|
output += result.exitCode === 0 ? "Command completed (no output)" : "Command failed (no output)";
|
||||||
|
}
|
||||||
|
|
||||||
|
output += `\n_Exit code: ${result.exitCode}, Duration: ${result.durationMs}ms_`;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
300
secure/scheduler.ts
Normal file
300
secure/scheduler.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Moltbot Secure - Task Scheduler
|
||||||
|
*
|
||||||
|
* Simple cron-like scheduler for recurring tasks.
|
||||||
|
* Stores jobs in memory or optionally persists to file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CronJob } from "cron";
|
||||||
|
import type { SecureConfig } from "./config.js";
|
||||||
|
import type { AuditLogger } from "./audit.js";
|
||||||
|
import type { AgentCore } from "./agent.js";
|
||||||
|
import type { Bot } from "grammy";
|
||||||
|
import type { Storage } from "./storage.js";
|
||||||
|
import { sendToUser } from "./telegram.js";
|
||||||
|
|
||||||
|
export type ScheduledTask = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schedule: string; // Cron expression
|
||||||
|
prompt: string; // What to ask the AI
|
||||||
|
enabled: boolean;
|
||||||
|
lastRun?: Date;
|
||||||
|
lastStatus?: "ok" | "error";
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Scheduler = {
|
||||||
|
addTask: (task: Omit<ScheduledTask, "id">) => string;
|
||||||
|
removeTask: (id: string) => boolean;
|
||||||
|
enableTask: (id: string, enabled: boolean) => boolean;
|
||||||
|
listTasks: () => ScheduledTask[];
|
||||||
|
runTask: (id: string) => Promise<void>;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulerDeps = {
|
||||||
|
config: SecureConfig;
|
||||||
|
audit: AuditLogger;
|
||||||
|
agent: AgentCore;
|
||||||
|
telegramBot: Bot;
|
||||||
|
storage?: Storage;
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||||
|
const { config, audit, agent, telegramBot, storage } = deps;
|
||||||
|
const tasks = new Map<string, ScheduledTask>();
|
||||||
|
const cronJobs = new Map<string, CronJob<null, unknown>>();
|
||||||
|
|
||||||
|
async function executeTask(task: ScheduledTask): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run the AI with the task prompt
|
||||||
|
const response = await agent.chat([
|
||||||
|
{ role: "user", content: task.prompt },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Notify users
|
||||||
|
const message = `**Scheduled Task: ${task.name}**\n\n${response.text}`;
|
||||||
|
for (const userId of config.telegram.allowedUsers) {
|
||||||
|
await sendToUser(telegramBot, userId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.lastRun = new Date();
|
||||||
|
task.lastStatus = "ok";
|
||||||
|
task.lastError = undefined;
|
||||||
|
|
||||||
|
// Save updated task status
|
||||||
|
if (storage) {
|
||||||
|
void storage.saveTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.cron({
|
||||||
|
jobId: task.id,
|
||||||
|
jobName: task.name,
|
||||||
|
status: "ok",
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
task.lastRun = new Date();
|
||||||
|
task.lastStatus = "error";
|
||||||
|
task.lastError = errorMsg;
|
||||||
|
|
||||||
|
// Save updated task status
|
||||||
|
if (storage) {
|
||||||
|
void storage.saveTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.cron({
|
||||||
|
jobId: task.id,
|
||||||
|
jobName: task.name,
|
||||||
|
status: "error",
|
||||||
|
error: errorMsg,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify about error
|
||||||
|
const message = `**Scheduled Task Failed: ${task.name}**\n\nError: ${errorMsg}`;
|
||||||
|
for (const userId of config.telegram.allowedUsers) {
|
||||||
|
await sendToUser(telegramBot, userId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTask(task: ScheduledTask): void {
|
||||||
|
// Remove existing job if any
|
||||||
|
const existing = cronJobs.get(task.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.stop();
|
||||||
|
cronJobs.delete(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.enabled || !config.scheduler.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = new CronJob(
|
||||||
|
task.schedule,
|
||||||
|
() => {
|
||||||
|
void executeTask(task);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
true, // Start immediately
|
||||||
|
undefined, // Default timezone
|
||||||
|
undefined,
|
||||||
|
false // Don't run on init
|
||||||
|
);
|
||||||
|
cronJobs.set(task.id, job);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[scheduler] Failed to schedule task ${task.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addTask(taskInput: Omit<ScheduledTask, "id">): string {
|
||||||
|
const id = generateId();
|
||||||
|
const task: ScheduledTask = { ...taskInput, id };
|
||||||
|
tasks.set(id, task);
|
||||||
|
scheduleTask(task);
|
||||||
|
// Persist to storage
|
||||||
|
if (storage) {
|
||||||
|
void storage.saveTask(task);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTask(id: string): boolean {
|
||||||
|
const task = tasks.get(id);
|
||||||
|
if (!task) return false;
|
||||||
|
|
||||||
|
const job = cronJobs.get(id);
|
||||||
|
if (job) {
|
||||||
|
job.stop();
|
||||||
|
cronJobs.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.delete(id);
|
||||||
|
// Remove from storage
|
||||||
|
if (storage) {
|
||||||
|
void storage.deleteTask(id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
enableTask(id: string, enabled: boolean): boolean {
|
||||||
|
const task = tasks.get(id);
|
||||||
|
if (!task) return false;
|
||||||
|
|
||||||
|
task.enabled = enabled;
|
||||||
|
scheduleTask(task);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
listTasks(): ScheduledTask[] {
|
||||||
|
return Array.from(tasks.values());
|
||||||
|
},
|
||||||
|
|
||||||
|
async runTask(id: string): Promise<void> {
|
||||||
|
const task = tasks.get(id);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task not found: ${id}`);
|
||||||
|
}
|
||||||
|
await executeTask(task);
|
||||||
|
},
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (!config.scheduler.enabled) {
|
||||||
|
console.log("[scheduler] Scheduler is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[scheduler] Starting scheduler...");
|
||||||
|
|
||||||
|
// Load tasks from storage
|
||||||
|
if (storage) {
|
||||||
|
const storedTasks = await storage.getAllTasks();
|
||||||
|
for (const task of storedTasks) {
|
||||||
|
tasks.set(task.id, task);
|
||||||
|
}
|
||||||
|
console.log(`[scheduler] Loaded ${storedTasks.length} tasks from storage`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of tasks.values()) {
|
||||||
|
scheduleTask(task);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
console.log("[scheduler] Stopping scheduler...");
|
||||||
|
for (const job of cronJobs.values()) {
|
||||||
|
job.stop();
|
||||||
|
}
|
||||||
|
cronJobs.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse schedule from human-readable format
|
||||||
|
*/
|
||||||
|
export function parseSchedule(input: string): string | null {
|
||||||
|
const lower = input.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Common patterns
|
||||||
|
const patterns: Record<string, string> = {
|
||||||
|
"every minute": "* * * * *",
|
||||||
|
"every 5 minutes": "*/5 * * * *",
|
||||||
|
"every 15 minutes": "*/15 * * * *",
|
||||||
|
"every 30 minutes": "*/30 * * * *",
|
||||||
|
"every hour": "0 * * * *",
|
||||||
|
hourly: "0 * * * *",
|
||||||
|
"every day": "0 9 * * *",
|
||||||
|
daily: "0 9 * * *",
|
||||||
|
"every morning": "0 9 * * *",
|
||||||
|
"every evening": "0 18 * * *",
|
||||||
|
"every week": "0 9 * * 1",
|
||||||
|
weekly: "0 9 * * 1",
|
||||||
|
"every monday": "0 9 * * 1",
|
||||||
|
"every tuesday": "0 9 * * 2",
|
||||||
|
"every wednesday": "0 9 * * 3",
|
||||||
|
"every thursday": "0 9 * * 4",
|
||||||
|
"every friday": "0 9 * * 5",
|
||||||
|
"every saturday": "0 9 * * 6",
|
||||||
|
"every sunday": "0 9 * * 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (patterns[lower]) {
|
||||||
|
return patterns[lower];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's already a valid cron expression (5 or 6 fields)
|
||||||
|
const parts = input.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 5 && parts.length <= 6) {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format next run time
|
||||||
|
*/
|
||||||
|
export function formatNextRun(cronExpression: string): string {
|
||||||
|
try {
|
||||||
|
const job = new CronJob(cronExpression, () => {});
|
||||||
|
const nextDate = job.nextDate();
|
||||||
|
return nextDate.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return "Invalid schedule";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in task templates
|
||||||
|
*/
|
||||||
|
export const taskTemplates = {
|
||||||
|
morningBriefing: {
|
||||||
|
name: "Morning Briefing",
|
||||||
|
schedule: "0 9 * * *", // 9 AM daily
|
||||||
|
prompt: "Give me a brief morning update. Include: current date, a motivational quote, and remind me to check my priorities for the day.",
|
||||||
|
},
|
||||||
|
weeklyReview: {
|
||||||
|
name: "Weekly Review",
|
||||||
|
schedule: "0 17 * * 5", // 5 PM on Fridays
|
||||||
|
prompt: "It's Friday. Help me reflect on the week. What should I consider for my weekly review?",
|
||||||
|
},
|
||||||
|
healthReminder: {
|
||||||
|
name: "Health Reminder",
|
||||||
|
schedule: "0 */2 * * *", // Every 2 hours
|
||||||
|
prompt: "Give me a brief health reminder (stretch, drink water, take a break). Keep it under 2 sentences.",
|
||||||
|
},
|
||||||
|
};
|
||||||
584
secure/storage.ts
Normal file
584
secure/storage.ts
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Storage Layer
|
||||||
|
*
|
||||||
|
* PostgreSQL for persistent data (tasks, profiles, traits)
|
||||||
|
* Redis for caching and sessions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ScheduledTask } from "./scheduler.js";
|
||||||
|
|
||||||
|
export type StorageConfig = {
|
||||||
|
postgres?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
redis?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Storage = {
|
||||||
|
// Tasks
|
||||||
|
saveTask: (task: ScheduledTask) => Promise<void>;
|
||||||
|
getTask: (id: string) => Promise<ScheduledTask | null>;
|
||||||
|
getAllTasks: () => Promise<ScheduledTask[]>;
|
||||||
|
deleteTask: (id: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
// Conversations (Redis cache)
|
||||||
|
getConversation: (userId: number) => Promise<ConversationMessage[]>;
|
||||||
|
saveConversation: (userId: number, messages: ConversationMessage[]) => Promise<void>;
|
||||||
|
clearConversation: (userId: number) => Promise<void>;
|
||||||
|
|
||||||
|
// Personality (Redis + PostgreSQL)
|
||||||
|
getUserProfile: (userId: number) => Promise<UserProfile | null>;
|
||||||
|
saveUserProfile: (profile: UserProfile) => Promise<void>;
|
||||||
|
getPersonalityTraits: () => Promise<PersonalityTraits | null>;
|
||||||
|
savePersonalityTraits: (traits: PersonalityTraits) => Promise<void>;
|
||||||
|
|
||||||
|
// Health
|
||||||
|
isHealthy: () => Promise<boolean>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationMessage = {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
timestamp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
userId: number;
|
||||||
|
name?: string;
|
||||||
|
timezone?: string;
|
||||||
|
preferredTone: "casual" | "professional" | "friendly" | "concise";
|
||||||
|
interests: string[];
|
||||||
|
recentTopics: string[];
|
||||||
|
interactionCount: number;
|
||||||
|
lastSeen: Date;
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersonalityTraits = {
|
||||||
|
name: string;
|
||||||
|
greeting: string;
|
||||||
|
signOff: string;
|
||||||
|
humor: "none" | "subtle" | "playful";
|
||||||
|
verbosity: "concise" | "balanced" | "detailed";
|
||||||
|
commonPhrases: string[];
|
||||||
|
avoidPhrases: string[];
|
||||||
|
expertiseAreas: string[];
|
||||||
|
lastUpdated: Date;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory storage (fallback when no DB configured)
|
||||||
|
*/
|
||||||
|
function createMemoryStorage(): Storage {
|
||||||
|
const tasks = new Map<string, ScheduledTask>();
|
||||||
|
const conversations = new Map<number, ConversationMessage[]>();
|
||||||
|
const userProfiles = new Map<number, UserProfile>();
|
||||||
|
let personalityTraits: PersonalityTraits | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async saveTask(task) {
|
||||||
|
tasks.set(task.id, task);
|
||||||
|
},
|
||||||
|
async getTask(id) {
|
||||||
|
return tasks.get(id) || null;
|
||||||
|
},
|
||||||
|
async getAllTasks() {
|
||||||
|
return Array.from(tasks.values());
|
||||||
|
},
|
||||||
|
async deleteTask(id) {
|
||||||
|
return tasks.delete(id);
|
||||||
|
},
|
||||||
|
async getConversation(userId) {
|
||||||
|
return conversations.get(userId) || [];
|
||||||
|
},
|
||||||
|
async saveConversation(userId, messages) {
|
||||||
|
conversations.set(userId, messages);
|
||||||
|
},
|
||||||
|
async clearConversation(userId) {
|
||||||
|
conversations.delete(userId);
|
||||||
|
},
|
||||||
|
async getUserProfile(userId) {
|
||||||
|
return userProfiles.get(userId) || null;
|
||||||
|
},
|
||||||
|
async saveUserProfile(profile) {
|
||||||
|
userProfiles.set(profile.userId, profile);
|
||||||
|
},
|
||||||
|
async getPersonalityTraits() {
|
||||||
|
return personalityTraits;
|
||||||
|
},
|
||||||
|
async savePersonalityTraits(traits) {
|
||||||
|
personalityTraits = traits;
|
||||||
|
},
|
||||||
|
async isHealthy() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
// Nothing to close
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL storage for tasks and personality
|
||||||
|
*/
|
||||||
|
async function createPostgresStorage(url: string): Promise<{
|
||||||
|
saveTask: Storage["saveTask"];
|
||||||
|
getTask: Storage["getTask"];
|
||||||
|
getAllTasks: Storage["getAllTasks"];
|
||||||
|
deleteTask: Storage["deleteTask"];
|
||||||
|
getUserProfile: Storage["getUserProfile"];
|
||||||
|
saveUserProfile: Storage["saveUserProfile"];
|
||||||
|
getPersonalityTraits: Storage["getPersonalityTraits"];
|
||||||
|
savePersonalityTraits: Storage["savePersonalityTraits"];
|
||||||
|
isHealthy: () => Promise<boolean>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const { default: pg } = await import("pg");
|
||||||
|
const pool = new pg.Pool({ connectionString: url });
|
||||||
|
|
||||||
|
// Create tables if not exist
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
schedule TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN DEFAULT true,
|
||||||
|
last_run TIMESTAMPTZ,
|
||||||
|
last_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User profiles table
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
timezone TEXT,
|
||||||
|
preferred_tone TEXT DEFAULT 'friendly',
|
||||||
|
interests JSONB DEFAULT '[]',
|
||||||
|
recent_topics JSONB DEFAULT '[]',
|
||||||
|
interaction_count INTEGER DEFAULT 0,
|
||||||
|
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
notes JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Personality traits table (singleton)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS personality_traits (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
name TEXT DEFAULT 'AssureBot',
|
||||||
|
greeting TEXT DEFAULT 'Hey',
|
||||||
|
sign_off TEXT DEFAULT '',
|
||||||
|
humor TEXT DEFAULT 'subtle',
|
||||||
|
verbosity TEXT DEFAULT 'balanced',
|
||||||
|
common_phrases JSONB DEFAULT '[]',
|
||||||
|
avoid_phrases JSONB DEFAULT '[]',
|
||||||
|
expertise_areas JSONB DEFAULT '["coding", "analysis", "automation"]',
|
||||||
|
last_updated TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
version INTEGER DEFAULT 1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("[storage] PostgreSQL connected, tables ready");
|
||||||
|
|
||||||
|
return {
|
||||||
|
async saveTask(task) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO scheduled_tasks (id, name, schedule, prompt, enabled, last_run, last_status, last_error, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = $2, schedule = $3, prompt = $4, enabled = $5,
|
||||||
|
last_run = $6, last_status = $7, last_error = $8, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
task.id,
|
||||||
|
task.name,
|
||||||
|
task.schedule,
|
||||||
|
task.prompt,
|
||||||
|
task.enabled,
|
||||||
|
task.lastRun || null,
|
||||||
|
task.lastStatus || null,
|
||||||
|
task.lastError || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTask(id) {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM scheduled_tasks WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return rowToTask(result.rows[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllTasks() {
|
||||||
|
const result = await pool.query("SELECT * FROM scheduled_tasks ORDER BY created_at");
|
||||||
|
return result.rows.map(rowToTask);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTask(id) {
|
||||||
|
const result = await pool.query(
|
||||||
|
"DELETE FROM scheduled_tasks WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserProfile(userId) {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM user_profiles WHERE user_id = $1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return rowToUserProfile(result.rows[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveUserProfile(profile) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO user_profiles (user_id, name, timezone, preferred_tone, interests, recent_topics, interaction_count, last_seen, notes, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
name = $2, timezone = $3, preferred_tone = $4, interests = $5,
|
||||||
|
recent_topics = $6, interaction_count = $7, last_seen = $8, notes = $9, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
profile.userId,
|
||||||
|
profile.name || null,
|
||||||
|
profile.timezone || null,
|
||||||
|
profile.preferredTone,
|
||||||
|
JSON.stringify(profile.interests),
|
||||||
|
JSON.stringify(profile.recentTopics),
|
||||||
|
profile.interactionCount,
|
||||||
|
profile.lastSeen,
|
||||||
|
JSON.stringify(profile.notes),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPersonalityTraits() {
|
||||||
|
const result = await pool.query("SELECT * FROM personality_traits WHERE id = 1");
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return rowToTraits(result.rows[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePersonalityTraits(traits) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO personality_traits (id, name, greeting, sign_off, humor, verbosity, common_phrases, avoid_phrases, expertise_areas, last_updated, version)
|
||||||
|
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = $1, greeting = $2, sign_off = $3, humor = $4, verbosity = $5,
|
||||||
|
common_phrases = $6, avoid_phrases = $7, expertise_areas = $8, last_updated = $9, version = $10`,
|
||||||
|
[
|
||||||
|
traits.name,
|
||||||
|
traits.greeting,
|
||||||
|
traits.signOff,
|
||||||
|
traits.humor,
|
||||||
|
traits.verbosity,
|
||||||
|
JSON.stringify(traits.commonPhrases),
|
||||||
|
JSON.stringify(traits.avoidPhrases),
|
||||||
|
JSON.stringify(traits.expertiseAreas),
|
||||||
|
traits.lastUpdated,
|
||||||
|
traits.version,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async isHealthy() {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await pool.end();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToTask(row: Record<string, unknown>): ScheduledTask {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
name: row.name as string,
|
||||||
|
schedule: row.schedule as string,
|
||||||
|
prompt: row.prompt as string,
|
||||||
|
enabled: row.enabled as boolean,
|
||||||
|
lastRun: row.last_run ? new Date(row.last_run as string) : undefined,
|
||||||
|
lastStatus: row.last_status as "ok" | "error" | undefined,
|
||||||
|
lastError: row.last_error as string | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToUserProfile(row: Record<string, unknown>): UserProfile {
|
||||||
|
return {
|
||||||
|
userId: Number(row.user_id),
|
||||||
|
name: row.name as string | undefined,
|
||||||
|
timezone: row.timezone as string | undefined,
|
||||||
|
preferredTone: row.preferred_tone as UserProfile["preferredTone"],
|
||||||
|
interests: (row.interests as string[]) || [],
|
||||||
|
recentTopics: (row.recent_topics as string[]) || [],
|
||||||
|
interactionCount: row.interaction_count as number,
|
||||||
|
lastSeen: new Date(row.last_seen as string),
|
||||||
|
notes: (row.notes as string[]) || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToTraits(row: Record<string, unknown>): PersonalityTraits {
|
||||||
|
return {
|
||||||
|
name: row.name as string,
|
||||||
|
greeting: row.greeting as string,
|
||||||
|
signOff: row.sign_off as string,
|
||||||
|
humor: row.humor as PersonalityTraits["humor"],
|
||||||
|
verbosity: row.verbosity as PersonalityTraits["verbosity"],
|
||||||
|
commonPhrases: (row.common_phrases as string[]) || [],
|
||||||
|
avoidPhrases: (row.avoid_phrases as string[]) || [],
|
||||||
|
expertiseAreas: (row.expertise_areas as string[]) || [],
|
||||||
|
lastUpdated: new Date(row.last_updated as string),
|
||||||
|
version: row.version as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis storage for conversations/cache and personality caching
|
||||||
|
*/
|
||||||
|
async function createRedisStorage(url: string): Promise<{
|
||||||
|
getConversation: Storage["getConversation"];
|
||||||
|
saveConversation: Storage["saveConversation"];
|
||||||
|
clearConversation: Storage["clearConversation"];
|
||||||
|
getUserProfile: Storage["getUserProfile"];
|
||||||
|
saveUserProfile: Storage["saveUserProfile"];
|
||||||
|
getPersonalityTraits: Storage["getPersonalityTraits"];
|
||||||
|
savePersonalityTraits: Storage["savePersonalityTraits"];
|
||||||
|
isHealthy: () => Promise<boolean>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const { createClient } = await import("redis");
|
||||||
|
const client = createClient({ url });
|
||||||
|
|
||||||
|
client.on("error", (err) => console.error("[redis] Error:", err));
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
console.log("[storage] Redis connected");
|
||||||
|
|
||||||
|
const CONVERSATION_TTL = 60 * 60 * 24; // 24 hours
|
||||||
|
const PROFILE_TTL = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
const TRAITS_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
const MAX_MESSAGES = 50;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getConversation(userId) {
|
||||||
|
const key = `conv:${userId}`;
|
||||||
|
const data = await client.get(key);
|
||||||
|
if (!data) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as ConversationMessage[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveConversation(userId, messages) {
|
||||||
|
const key = `conv:${userId}`;
|
||||||
|
// Keep only last N messages
|
||||||
|
const trimmed = messages.slice(-MAX_MESSAGES);
|
||||||
|
await client.setEx(key, CONVERSATION_TTL, JSON.stringify(trimmed));
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearConversation(userId) {
|
||||||
|
const key = `conv:${userId}`;
|
||||||
|
await client.del(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserProfile(userId) {
|
||||||
|
const key = `profile:${userId}`;
|
||||||
|
const data = await client.get(key);
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
lastSeen: new Date(parsed.lastSeen),
|
||||||
|
} as UserProfile;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveUserProfile(profile) {
|
||||||
|
const key = `profile:${profile.userId}`;
|
||||||
|
await client.setEx(key, PROFILE_TTL, JSON.stringify(profile));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPersonalityTraits() {
|
||||||
|
const key = "personality:traits";
|
||||||
|
const data = await client.get(key);
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
lastUpdated: new Date(parsed.lastUpdated),
|
||||||
|
} as PersonalityTraits;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePersonalityTraits(traits) {
|
||||||
|
const key = "personality:traits";
|
||||||
|
await client.setEx(key, TRAITS_TTL, JSON.stringify(traits));
|
||||||
|
},
|
||||||
|
|
||||||
|
async isHealthy() {
|
||||||
|
try {
|
||||||
|
await client.ping();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await client.quit();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create storage based on config
|
||||||
|
* Strategy:
|
||||||
|
* - Redis: fast cache for conversations, profiles, traits
|
||||||
|
* - PostgreSQL: durable backing store for profiles, traits, tasks
|
||||||
|
* - Memory: fallback when neither is available
|
||||||
|
*/
|
||||||
|
export async function createStorage(config: StorageConfig): Promise<Storage> {
|
||||||
|
const memory = createMemoryStorage();
|
||||||
|
|
||||||
|
let pgStorage: Awaited<ReturnType<typeof createPostgresStorage>> | null = null;
|
||||||
|
let redisStorage: Awaited<ReturnType<typeof createRedisStorage>> | null = null;
|
||||||
|
|
||||||
|
// Try PostgreSQL
|
||||||
|
if (config.postgres?.url) {
|
||||||
|
try {
|
||||||
|
pgStorage = await createPostgresStorage(config.postgres.url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[storage] PostgreSQL connection failed, using memory:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Redis
|
||||||
|
if (config.redis?.url) {
|
||||||
|
try {
|
||||||
|
redisStorage = await createRedisStorage(config.redis.url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[storage] Redis connection failed, using memory:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create layered personality storage (Redis cache -> PostgreSQL backing -> memory fallback)
|
||||||
|
async function getUserProfile(userId: number): Promise<UserProfile | null> {
|
||||||
|
// Try Redis cache first
|
||||||
|
if (redisStorage) {
|
||||||
|
const cached = await redisStorage.getUserProfile(userId);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
// Try PostgreSQL
|
||||||
|
if (pgStorage) {
|
||||||
|
const profile = await pgStorage.getUserProfile(userId);
|
||||||
|
// Cache in Redis if found
|
||||||
|
if (profile && redisStorage) {
|
||||||
|
await redisStorage.saveUserProfile(profile);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
// Fallback to memory
|
||||||
|
return memory.getUserProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserProfile(profile: UserProfile): Promise<void> {
|
||||||
|
// Save to PostgreSQL (durable)
|
||||||
|
if (pgStorage) {
|
||||||
|
await pgStorage.saveUserProfile(profile);
|
||||||
|
}
|
||||||
|
// Cache in Redis
|
||||||
|
if (redisStorage) {
|
||||||
|
await redisStorage.saveUserProfile(profile);
|
||||||
|
}
|
||||||
|
// Also update memory for consistency
|
||||||
|
await memory.saveUserProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPersonalityTraits(): Promise<PersonalityTraits | null> {
|
||||||
|
// Try Redis cache first
|
||||||
|
if (redisStorage) {
|
||||||
|
const cached = await redisStorage.getPersonalityTraits();
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
// Try PostgreSQL
|
||||||
|
if (pgStorage) {
|
||||||
|
const traits = await pgStorage.getPersonalityTraits();
|
||||||
|
// Cache in Redis if found
|
||||||
|
if (traits && redisStorage) {
|
||||||
|
await redisStorage.savePersonalityTraits(traits);
|
||||||
|
}
|
||||||
|
return traits;
|
||||||
|
}
|
||||||
|
// Fallback to memory
|
||||||
|
return memory.getPersonalityTraits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePersonalityTraits(traits: PersonalityTraits): Promise<void> {
|
||||||
|
// Save to PostgreSQL (durable)
|
||||||
|
if (pgStorage) {
|
||||||
|
await pgStorage.savePersonalityTraits(traits);
|
||||||
|
}
|
||||||
|
// Cache in Redis
|
||||||
|
if (redisStorage) {
|
||||||
|
await redisStorage.savePersonalityTraits(traits);
|
||||||
|
}
|
||||||
|
// Also update memory for consistency
|
||||||
|
await memory.savePersonalityTraits(traits);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Tasks: prefer PostgreSQL, fallback to memory
|
||||||
|
saveTask: pgStorage?.saveTask ?? memory.saveTask,
|
||||||
|
getTask: pgStorage?.getTask ?? memory.getTask,
|
||||||
|
getAllTasks: pgStorage?.getAllTasks ?? memory.getAllTasks,
|
||||||
|
deleteTask: pgStorage?.deleteTask ?? memory.deleteTask,
|
||||||
|
|
||||||
|
// Conversations: prefer Redis, fallback to memory
|
||||||
|
getConversation: redisStorage?.getConversation ?? memory.getConversation,
|
||||||
|
saveConversation: redisStorage?.saveConversation ?? memory.saveConversation,
|
||||||
|
clearConversation: redisStorage?.clearConversation ?? memory.clearConversation,
|
||||||
|
|
||||||
|
// Personality: layered (Redis cache -> PostgreSQL -> memory)
|
||||||
|
getUserProfile,
|
||||||
|
saveUserProfile,
|
||||||
|
getPersonalityTraits,
|
||||||
|
savePersonalityTraits,
|
||||||
|
|
||||||
|
async isHealthy() {
|
||||||
|
const pgOk = pgStorage ? await pgStorage.isHealthy() : true;
|
||||||
|
const redisOk = redisStorage ? await redisStorage.isHealthy() : true;
|
||||||
|
return pgOk && redisOk;
|
||||||
|
},
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await pgStorage?.close();
|
||||||
|
await redisStorage?.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
795
secure/telegram.ts
Normal file
795
secure/telegram.ts
Normal file
@ -0,0 +1,795 @@
|
|||||||
|
/**
|
||||||
|
* AssureBot - Telegram Channel
|
||||||
|
*
|
||||||
|
* Minimal, secure Telegram bot handler with image analysis.
|
||||||
|
* Allowlist-only: only approved users can interact.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Bot, Context } from "grammy";
|
||||||
|
import type { SecureConfig } from "./config.js";
|
||||||
|
import type { AuditLogger } from "./audit.js";
|
||||||
|
import type { AgentCore, ConversationStore, ImageContent } from "./agent.js";
|
||||||
|
import type { SandboxRunner } from "./sandbox.js";
|
||||||
|
import type { Scheduler } from "./scheduler.js";
|
||||||
|
import type { Personality } from "./personality.js";
|
||||||
|
import { extractText, summarizeDocument } from "./documents.js";
|
||||||
|
|
||||||
|
export type TelegramBot = {
|
||||||
|
bot: Bot;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramDeps = {
|
||||||
|
config: SecureConfig;
|
||||||
|
audit: AuditLogger;
|
||||||
|
agent: AgentCore;
|
||||||
|
conversations: ConversationStore;
|
||||||
|
sandbox?: SandboxRunner;
|
||||||
|
scheduler?: Scheduler;
|
||||||
|
personality?: Personality;
|
||||||
|
onWebhookMessage?: (userId: number, text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUserAllowed(userId: number, allowedUsers: number[]): boolean {
|
||||||
|
return allowedUsers.includes(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsername(ctx: Context): string {
|
||||||
|
const user = ctx.from;
|
||||||
|
if (!user) return "unknown";
|
||||||
|
if (user.username) return `@${user.username}`;
|
||||||
|
const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
|
||||||
|
return name || `id:${user.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTelegramBot(deps: TelegramDeps): TelegramBot {
|
||||||
|
const { config, audit, agent, conversations, sandbox, scheduler, personality } = deps;
|
||||||
|
const bot = new Bot(config.telegram.botToken);
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
bot.catch((err) => {
|
||||||
|
audit.error({
|
||||||
|
error: `Telegram bot error: ${err.message}`,
|
||||||
|
metadata: { stack: err.stack },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /start
|
||||||
|
bot.command("start", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
audit.messageBlocked({
|
||||||
|
userId: userId || 0,
|
||||||
|
username: formatUsername(ctx),
|
||||||
|
reason: "User not in allowlist",
|
||||||
|
});
|
||||||
|
await ctx.reply("Access denied. You are not authorized to use this bot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`Welcome to AssureBot.
|
||||||
|
|
||||||
|
You are authorized to use this bot.
|
||||||
|
|
||||||
|
Code Execution:
|
||||||
|
/js <code> - Run JavaScript
|
||||||
|
/python <code> - Run Python
|
||||||
|
/ts <code> - Run TypeScript
|
||||||
|
/bash <code> - Run shell commands
|
||||||
|
/run <lang> <code> - Run any language
|
||||||
|
|
||||||
|
Other Commands:
|
||||||
|
/status - Check bot status
|
||||||
|
/clear - Clear conversation history
|
||||||
|
/schedule - Schedule AI tasks
|
||||||
|
/tasks - List scheduled tasks
|
||||||
|
/help - Show full help
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Chat with AI
|
||||||
|
- Image analysis (send photos)
|
||||||
|
- Document analysis (send PDFs)
|
||||||
|
- Code execution (15+ languages)`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /clear
|
||||||
|
bot.command("clear", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations.clear(userId);
|
||||||
|
await ctx.reply("Conversation history cleared.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /status
|
||||||
|
bot.command("status", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = conversations.get(userId);
|
||||||
|
const sandboxStatus = sandbox
|
||||||
|
? `${sandbox.backend} (${await sandbox.isAvailable() ? "ready" : "unavailable"})`
|
||||||
|
: "not configured";
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`Status:
|
||||||
|
- AI Provider: ${agent.provider}
|
||||||
|
- Conversation: ${history.length} messages
|
||||||
|
- Sandbox: ${sandboxStatus}
|
||||||
|
- Webhooks: ${config.webhooks.enabled ? "enabled" : "disabled"}
|
||||||
|
- Scheduler: ${config.scheduler.enabled ? "enabled" : "disabled"}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /help
|
||||||
|
bot.command("help", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`AssureBot Help
|
||||||
|
|
||||||
|
A secure, self-hosted AI assistant.
|
||||||
|
|
||||||
|
CODE EXECUTION:
|
||||||
|
/js <code> - Run JavaScript
|
||||||
|
/python <code> or /py <code> - Run Python
|
||||||
|
/ts <code> - Run TypeScript
|
||||||
|
/bash <code> or /sh <code> - Run shell
|
||||||
|
/run <lang> <code> - Run any language
|
||||||
|
|
||||||
|
Supported: python, javascript, typescript, bash, rust, go, c, cpp, java, ruby, php
|
||||||
|
|
||||||
|
SCHEDULING:
|
||||||
|
/schedule "<cron>" "<name>" <prompt>
|
||||||
|
/tasks - List tasks
|
||||||
|
/deltask <id> - Delete task
|
||||||
|
|
||||||
|
Example: /schedule "0 9 * * *" "Morning" Good morning!
|
||||||
|
|
||||||
|
OTHER:
|
||||||
|
/status - Bot & sandbox status
|
||||||
|
/clear - Clear conversation
|
||||||
|
/help - This message
|
||||||
|
|
||||||
|
FEATURES:
|
||||||
|
- Chat naturally with AI
|
||||||
|
- Send images for analysis
|
||||||
|
- Send PDFs/docs for analysis
|
||||||
|
- Code runs in isolated sandbox`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /sandbox <code>
|
||||||
|
bot.command("sandbox", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
const username = formatUsername(ctx);
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sandbox) {
|
||||||
|
await ctx.reply("Sandbox is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.sandbox.enabled) {
|
||||||
|
await ctx.reply("Sandbox is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = ctx.message?.text?.replace(/^\/sandbox\s*/, "").trim() ?? "";
|
||||||
|
if (!code) {
|
||||||
|
await ctx.reply("Usage: /sandbox <code>\n\nExample: /sandbox echo Hello World");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sandbox.run(code);
|
||||||
|
const output = result.stdout || result.stderr || "(no output)";
|
||||||
|
const status = result.exitCode === 0 ? "✓" : `✗ (exit ${result.exitCode})`;
|
||||||
|
const timeout = result.timedOut ? " [TIMED OUT]" : "";
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`**Sandbox Result** ${status}${timeout}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\`\nDuration: ${result.durationMs}ms`,
|
||||||
|
{ parse_mode: "Markdown" }
|
||||||
|
).catch(async () => {
|
||||||
|
await ctx.reply(`Sandbox Result ${status}${timeout}\n\n${output.slice(0, 3500)}\n\nDuration: ${result.durationMs}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
audit.sandbox({
|
||||||
|
command: code,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
audit.error({ error: `Sandbox error: ${errorMsg}`, metadata: { userId, code } });
|
||||||
|
await ctx.reply(`Sandbox error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper for language-specific code execution
|
||||||
|
async function runCodeCommand(
|
||||||
|
ctx: Context,
|
||||||
|
language: string,
|
||||||
|
commandName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sandbox) {
|
||||||
|
await ctx.reply("Sandbox is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAvailable = await sandbox.isAvailable();
|
||||||
|
if (!isAvailable) {
|
||||||
|
await ctx.reply(`Sandbox unavailable. Backend: ${sandbox.backend}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = ctx.message?.text?.replace(new RegExp(`^/${commandName}\\s*`), "").trim() ?? "";
|
||||||
|
if (!code) {
|
||||||
|
await ctx.reply(`Usage: /${commandName} <code>\n\nExample: /${commandName} console.log("Hello!")`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sandbox.runCode(language, code);
|
||||||
|
const output = result.stdout || result.stderr || "(no output)";
|
||||||
|
const status = result.exitCode === 0 ? "✓" : `✗ (exit ${result.exitCode})`;
|
||||||
|
const timeout = result.timedOut ? " [TIMED OUT]" : "";
|
||||||
|
const backend = sandbox.backend === "piston" ? " [Piston]" : "";
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`**${language}** ${status}${timeout}${backend}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\`\nDuration: ${result.durationMs}ms`,
|
||||||
|
{ parse_mode: "Markdown" }
|
||||||
|
).catch(async () => {
|
||||||
|
await ctx.reply(`${language} ${status}${timeout}${backend}\n\n${output.slice(0, 3500)}\n\nDuration: ${result.durationMs}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
audit.sandbox({
|
||||||
|
command: `[${language}] ${code.slice(0, 100)}`,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
audit.error({ error: `Code execution error: ${errorMsg}`, metadata: { userId, language, code } });
|
||||||
|
await ctx.reply(`Error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command: /js <code> - Run JavaScript
|
||||||
|
bot.command("js", (ctx) => runCodeCommand(ctx, "javascript", "js"));
|
||||||
|
|
||||||
|
// Command: /python <code> - Run Python
|
||||||
|
bot.command("python", (ctx) => runCodeCommand(ctx, "python", "python"));
|
||||||
|
bot.command("py", (ctx) => runCodeCommand(ctx, "python", "py"));
|
||||||
|
|
||||||
|
// Command: /ts <code> - Run TypeScript
|
||||||
|
bot.command("ts", (ctx) => runCodeCommand(ctx, "typescript", "ts"));
|
||||||
|
|
||||||
|
// Command: /bash <code> - Run Bash
|
||||||
|
bot.command("bash", (ctx) => runCodeCommand(ctx, "bash", "bash"));
|
||||||
|
bot.command("sh", (ctx) => runCodeCommand(ctx, "bash", "sh"));
|
||||||
|
|
||||||
|
// Command: /run <language> <code> - Run code in any supported language
|
||||||
|
bot.command("run", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sandbox) {
|
||||||
|
await ctx.reply("Sandbox is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAvailable = await sandbox.isAvailable();
|
||||||
|
if (!isAvailable) {
|
||||||
|
await ctx.reply(`Sandbox unavailable. Backend: ${sandbox.backend}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = ctx.message?.text?.replace(/^\/run\s*/, "").trim() ?? "";
|
||||||
|
const match = text.match(/^(\w+)\s+([\s\S]+)$/);
|
||||||
|
if (!match) {
|
||||||
|
await ctx.reply(
|
||||||
|
`Usage: /run <language> <code>
|
||||||
|
|
||||||
|
Supported languages:
|
||||||
|
- javascript, js
|
||||||
|
- typescript, ts
|
||||||
|
- python, py
|
||||||
|
- bash, sh
|
||||||
|
- rust, go, c, cpp, java, ruby, php
|
||||||
|
|
||||||
|
Example: /run python print("Hello!")`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, language, code] = match;
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sandbox.runCode(language, code);
|
||||||
|
const output = result.stdout || result.stderr || "(no output)";
|
||||||
|
const status = result.exitCode === 0 ? "✓" : `✗ (exit ${result.exitCode})`;
|
||||||
|
const timeout = result.timedOut ? " [TIMED OUT]" : "";
|
||||||
|
const backend = sandbox.backend === "piston" ? " [Piston]" : "";
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`**${language}** ${status}${timeout}${backend}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\`\nDuration: ${result.durationMs}ms`,
|
||||||
|
{ parse_mode: "Markdown" }
|
||||||
|
).catch(async () => {
|
||||||
|
await ctx.reply(`${language} ${status}${timeout}${backend}\n\n${output.slice(0, 3500)}\n\nDuration: ${result.durationMs}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
audit.sandbox({
|
||||||
|
command: `[${language}] ${code.slice(0, 100)}`,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
audit.error({ error: `Code execution error: ${errorMsg}`, metadata: { userId, language, code } });
|
||||||
|
await ctx.reply(`Error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /schedule <cron> <name> <prompt>
|
||||||
|
bot.command("schedule", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduler) {
|
||||||
|
await ctx.reply("Scheduler is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.scheduler.enabled) {
|
||||||
|
await ctx.reply("Scheduler is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: /schedule "*/5 * * * *" "Task Name" What to do
|
||||||
|
const text = ctx.message?.text?.replace(/^\/schedule\s*/, "").trim() ?? "";
|
||||||
|
const match = text.match(/^"([^"]+)"\s+"([^"]+)"\s+(.+)$/s);
|
||||||
|
if (!match) {
|
||||||
|
await ctx.reply(
|
||||||
|
`Usage: /schedule "<cron>" "<name>" <prompt>
|
||||||
|
|
||||||
|
Example:
|
||||||
|
/schedule "0 9 * * *" "Morning Brief" Give me a summary of what I should focus on today
|
||||||
|
|
||||||
|
Cron format: minute hour day month weekday
|
||||||
|
- "0 9 * * *" = 9:00 AM daily
|
||||||
|
- "*/30 * * * *" = Every 30 minutes
|
||||||
|
- "0 0 * * 1" = Midnight on Mondays`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, cronExpr, name, prompt] = match;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskId = scheduler.addTask({
|
||||||
|
name,
|
||||||
|
schedule: cronExpr,
|
||||||
|
prompt,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
await ctx.reply(`Task scheduled!\n\nID: ${taskId}\nName: ${name}\nSchedule: ${cronExpr}`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
await ctx.reply(`Failed to schedule task: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /tasks
|
||||||
|
bot.command("tasks", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduler) {
|
||||||
|
await ctx.reply("Scheduler is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = scheduler.listTasks();
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
await ctx.reply("No scheduled tasks.\n\nUse /schedule to create one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = tasks.map((t) => {
|
||||||
|
const status = t.enabled ? "✓" : "✗";
|
||||||
|
const lastRun = t.lastRun ? t.lastRun.toISOString().slice(0, 16) : "never";
|
||||||
|
return `${status} **${t.name}** (${t.id})\n ${t.schedule}\n Last: ${lastRun}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.reply(`**Scheduled Tasks**\n\n${lines.join("\n\n")}`, { parse_mode: "Markdown" }).catch(async () => {
|
||||||
|
await ctx.reply(`Scheduled Tasks\n\n${lines.join("\n\n").replace(/\*\*/g, "")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: /deltask <id>
|
||||||
|
bot.command("deltask", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduler) {
|
||||||
|
await ctx.reply("Scheduler is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = ctx.message?.text?.replace(/^\/deltask\s*/, "").trim() ?? "";
|
||||||
|
if (!taskId) {
|
||||||
|
await ctx.reply("Usage: /deltask <task_id>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduler.removeTask(taskId)) {
|
||||||
|
await ctx.reply(`Task ${taskId} deleted.`);
|
||||||
|
} else {
|
||||||
|
await ctx.reply(`Task ${taskId} not found.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle all text messages
|
||||||
|
bot.on("message:text", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
const username = formatUsername(ctx);
|
||||||
|
const text = ctx.message.text;
|
||||||
|
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Check allowlist
|
||||||
|
if (!isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
audit.messageBlocked({
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
reason: "User not in allowlist",
|
||||||
|
});
|
||||||
|
await ctx.reply("Access denied. You are not authorized to use this bot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip commands (handled above)
|
||||||
|
if (text.startsWith("/")) return;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show typing indicator
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
// Add user message to history
|
||||||
|
conversations.add(userId, { role: "user", content: text });
|
||||||
|
|
||||||
|
// Get conversation history
|
||||||
|
const history = conversations.get(userId);
|
||||||
|
|
||||||
|
// Get personalized system prompt if personality is configured
|
||||||
|
const systemPrompt = personality
|
||||||
|
? await personality.getSystemPrompt(userId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Call AI with optional personalized system prompt
|
||||||
|
const response = await agent.chat(history, systemPrompt);
|
||||||
|
|
||||||
|
// Add assistant response to history
|
||||||
|
conversations.add(userId, { role: "assistant", content: response.text });
|
||||||
|
|
||||||
|
// Learn from this conversation
|
||||||
|
if (personality) {
|
||||||
|
await personality.learnFromConversation(userId, text, response.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
await ctx.reply(response.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||||
|
// Fallback without markdown if it fails
|
||||||
|
await ctx.reply(response.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
audit.message({
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
text,
|
||||||
|
response: response.text,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
audit.error({
|
||||||
|
error: `Failed to process message: ${errorMsg}`,
|
||||||
|
metadata: { userId, username },
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.reply("Sorry, I encountered an error processing your message. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle forwarded messages
|
||||||
|
bot.on("message:forward_origin", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
const username = formatUsername(ctx);
|
||||||
|
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
audit.messageBlocked({
|
||||||
|
userId: userId || 0,
|
||||||
|
username,
|
||||||
|
reason: "User not in allowlist",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = ctx.message.text || ctx.message.caption || "";
|
||||||
|
if (!text) {
|
||||||
|
await ctx.reply("I received your forwarded message but couldn't extract any text.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
// Process as a standalone analysis (don't add to conversation history)
|
||||||
|
const response = await agent.chat([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Please analyze this forwarded message:\n\n${text}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await ctx.reply(response.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||||
|
await ctx.reply(response.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
audit.message({
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
text: `[FORWARDED] ${text}`,
|
||||||
|
response: response.text,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
audit.error({
|
||||||
|
error: `Failed to process forwarded message: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
await ctx.reply("Sorry, I couldn't analyze that forwarded message.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle photos
|
||||||
|
bot.on("message:photo", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
const username = formatUsername(ctx);
|
||||||
|
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
audit.messageBlocked({
|
||||||
|
userId: userId || 0,
|
||||||
|
username,
|
||||||
|
reason: "User not in allowlist",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const caption = ctx.message.caption || "What's in this image? Describe it in detail.";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
// Get the largest photo (last in array)
|
||||||
|
const photos = ctx.message.photo;
|
||||||
|
const photo = photos[photos.length - 1];
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
const file = await ctx.api.getFile(photo.file_id);
|
||||||
|
if (!file.file_path) {
|
||||||
|
await ctx.reply("Sorry, I couldn't download the image.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${file.file_path}`;
|
||||||
|
const response = await fetch(fileUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
await ctx.reply("Sorry, I couldn't download the image.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const base64 = Buffer.from(buffer).toString("base64");
|
||||||
|
|
||||||
|
// Determine media type from file path
|
||||||
|
const ext = file.file_path.split(".").pop()?.toLowerCase();
|
||||||
|
let mediaType: ImageContent["mediaType"] = "image/jpeg";
|
||||||
|
if (ext === "png") mediaType = "image/png";
|
||||||
|
else if (ext === "gif") mediaType = "image/gif";
|
||||||
|
else if (ext === "webp") mediaType = "image/webp";
|
||||||
|
|
||||||
|
// Analyze with AI
|
||||||
|
const result = await agent.analyzeImage(base64, mediaType, caption);
|
||||||
|
|
||||||
|
await ctx.reply(result.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||||
|
await ctx.reply(result.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
audit.message({
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
text: `[IMAGE] ${caption}`,
|
||||||
|
response: result.text,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
audit.error({
|
||||||
|
error: `Failed to analyze image: ${errorMsg}`,
|
||||||
|
metadata: { userId, username },
|
||||||
|
});
|
||||||
|
await ctx.reply("Sorry, I couldn't analyze that image. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle documents
|
||||||
|
bot.on("message:document", async (ctx) => {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
const username = formatUsername(ctx);
|
||||||
|
|
||||||
|
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||||
|
audit.messageBlocked({
|
||||||
|
userId: userId || 0,
|
||||||
|
username,
|
||||||
|
reason: "User not in allowlist",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = ctx.message?.document;
|
||||||
|
if (!doc) {
|
||||||
|
await ctx.reply("Could not process document.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const caption = ctx.message?.caption || "Please analyze this document and summarize the key points.";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.replyWithChatAction("typing");
|
||||||
|
|
||||||
|
// Check file size (max 20MB)
|
||||||
|
if (doc.file_size && doc.file_size > 20 * 1024 * 1024) {
|
||||||
|
await ctx.reply("Document too large (max 20MB).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
const file = await ctx.api.getFile(doc.file_id);
|
||||||
|
if (!file.file_path) {
|
||||||
|
await ctx.reply("Could not download document.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${file.file_path}`;
|
||||||
|
const response = await fetch(fileUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
await ctx.reply("Failed to download document.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const mimeType = doc.mime_type || "application/octet-stream";
|
||||||
|
|
||||||
|
// Extract text
|
||||||
|
const extracted = await extractText(buffer, mimeType, doc.file_name);
|
||||||
|
|
||||||
|
if (extracted.format === "unsupported") {
|
||||||
|
await ctx.reply(
|
||||||
|
`Unsupported document format: ${mimeType}\n\nSupported: PDF, TXT, MD, JSON, CSV, code files`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted.format === "pdf-error") {
|
||||||
|
await ctx.reply(`Could not parse PDF: ${extracted.text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze with AI
|
||||||
|
const result = await agent.chat([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `${caption}\n\n--- Document Content (${summarizeDocument(extracted)}) ---\n\n${extracted.text}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await ctx.reply(result.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||||
|
await ctx.reply(result.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
audit.message({
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
text: `[DOCUMENT: ${doc.file_name || "unnamed"}] ${caption}`,
|
||||||
|
response: result.text,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
audit.error({
|
||||||
|
error: `Failed to analyze document: ${errorMsg}`,
|
||||||
|
metadata: { userId, username, filename: doc.file_name },
|
||||||
|
});
|
||||||
|
await ctx.reply("Sorry, I couldn't analyze that document. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bot,
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
console.log("[telegram] Starting bot in polling mode...");
|
||||||
|
await bot.start({
|
||||||
|
onStart: (botInfo) => {
|
||||||
|
console.log(`[telegram] Bot started: @${botInfo.username}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
console.log("[telegram] Stopping bot...");
|
||||||
|
await bot.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a user (for webhook notifications, cron results, etc.)
|
||||||
|
*/
|
||||||
|
export async function sendToUser(
|
||||||
|
bot: Bot,
|
||||||
|
userId: number,
|
||||||
|
message: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await bot.api.sendMessage(userId, message, { parse_mode: "Markdown" }).catch(async () => {
|
||||||
|
// Fallback without markdown
|
||||||
|
await bot.api.sendMessage(userId, message);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[telegram] Failed to send message to ${userId}:`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
secure/tsconfig.json
Normal file
21
secure/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
287
secure/webhooks.ts
Normal file
287
secure/webhooks.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* Moltbot Secure - Webhook Receiver
|
||||||
|
*
|
||||||
|
* Authenticated webhook endpoint for external integrations.
|
||||||
|
* Receives events from GitHub, Stripe, uptime monitors, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import type { SecureConfig } from "./config.js";
|
||||||
|
import type { AuditLogger } from "./audit.js";
|
||||||
|
import type { AgentCore } from "./agent.js";
|
||||||
|
import type { Bot } from "grammy";
|
||||||
|
import { sendToUser } from "./telegram.js";
|
||||||
|
|
||||||
|
export type WebhookHandler = {
|
||||||
|
handleRequest: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookDeps = {
|
||||||
|
config: SecureConfig;
|
||||||
|
audit: AuditLogger;
|
||||||
|
agent: AgentCore;
|
||||||
|
telegramBot: Bot;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timing-safe token comparison
|
||||||
|
*/
|
||||||
|
function verifyToken(provided: string, expected: string): boolean {
|
||||||
|
if (!provided || !expected) return false;
|
||||||
|
if (provided.length !== expected.length) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract token from request
|
||||||
|
*/
|
||||||
|
function extractToken(req: IncomingMessage, url: URL): { token: string; fromQuery: boolean } {
|
||||||
|
// Check Authorization header (preferred)
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
return { token: authHeader.slice(7), fromQuery: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check X-Moltbot-Token header
|
||||||
|
const tokenHeader = req.headers["x-moltbot-token"];
|
||||||
|
if (typeof tokenHeader === "string") {
|
||||||
|
return { token: tokenHeader, fromQuery: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check query parameter (deprecated, less secure)
|
||||||
|
const queryToken = url.searchParams.get("token");
|
||||||
|
if (queryToken) {
|
||||||
|
return { token: queryToken, fromQuery: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token: "", fromQuery: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read JSON body from request
|
||||||
|
*/
|
||||||
|
async function readJsonBody(
|
||||||
|
req: IncomingMessage,
|
||||||
|
maxBytes = 1024 * 1024 // 1MB default
|
||||||
|
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > maxBytes) {
|
||||||
|
req.destroy();
|
||||||
|
resolve({ ok: false, error: "payload too large" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const body = Buffer.concat(chunks).toString("utf-8");
|
||||||
|
if (!body.trim()) {
|
||||||
|
resolve({ ok: true, value: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
resolve({ ok: true, value: parsed });
|
||||||
|
} catch {
|
||||||
|
resolve({ ok: false, error: "invalid JSON" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", () => {
|
||||||
|
resolve({ ok: false, error: "read error" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send JSON response
|
||||||
|
*/
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize webhook payload using AI
|
||||||
|
*/
|
||||||
|
async function summarizeWebhook(
|
||||||
|
agent: AgentCore,
|
||||||
|
source: string,
|
||||||
|
payload: unknown
|
||||||
|
): Promise<string> {
|
||||||
|
const payloadStr = JSON.stringify(payload, null, 2).slice(0, 4000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await agent.chat([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Summarize this webhook notification from "${source}" in 2-3 concise sentences. Focus on what happened and any action needed:\n\n${payloadStr}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return response.text;
|
||||||
|
} catch {
|
||||||
|
return `Received webhook from ${source}. (Unable to summarize)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebhookHandler(deps: WebhookDeps): WebhookHandler {
|
||||||
|
const { config, audit, agent, telegramBot } = deps;
|
||||||
|
const { basePath, secret, enabled } = config.webhooks;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||||
|
if (!enabled) return false;
|
||||||
|
|
||||||
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||||
|
|
||||||
|
// Check if this is a webhook path
|
||||||
|
if (!url.pathname.startsWith(basePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const subPath = url.pathname.slice(basePath.length).replace(/^\//, "") || "default";
|
||||||
|
|
||||||
|
// Verify authentication
|
||||||
|
const { token, fromQuery } = extractToken(req, url);
|
||||||
|
|
||||||
|
if (!verifyToken(token, secret)) {
|
||||||
|
audit.webhookBlocked({
|
||||||
|
path: url.pathname,
|
||||||
|
reason: "Invalid or missing token",
|
||||||
|
});
|
||||||
|
sendJson(res, 401, { ok: false, error: "Unauthorized" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromQuery) {
|
||||||
|
console.warn(
|
||||||
|
"[webhooks] Token provided via query parameter is insecure. Use Authorization header instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept POST
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader("Allow", "POST");
|
||||||
|
res.end("Method Not Allowed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (!body.ok) {
|
||||||
|
sendJson(res, body.error === "payload too large" ? 413 : 400, {
|
||||||
|
ok: false,
|
||||||
|
error: body.error,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process webhook
|
||||||
|
try {
|
||||||
|
// Summarize with AI
|
||||||
|
const summary = await summarizeWebhook(agent, subPath, body.value);
|
||||||
|
|
||||||
|
// Notify all allowed users
|
||||||
|
const notificationText = `**Webhook: ${subPath}**\n\n${summary}`;
|
||||||
|
|
||||||
|
for (const userId of config.telegram.allowedUsers) {
|
||||||
|
await sendToUser(telegramBot, userId, notificationText);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.webhook({
|
||||||
|
path: url.pathname,
|
||||||
|
status: 200,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
sendJson(res, 200, { ok: true, processed: true });
|
||||||
|
} catch (err) {
|
||||||
|
audit.error({
|
||||||
|
error: `Webhook processing failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
metadata: { path: url.pathname },
|
||||||
|
});
|
||||||
|
|
||||||
|
sendJson(res, 500, { ok: false, error: "Processing failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in webhook handlers for common services
|
||||||
|
*/
|
||||||
|
export const webhookParsers = {
|
||||||
|
/**
|
||||||
|
* Parse GitHub webhook
|
||||||
|
*/
|
||||||
|
github(payload: unknown): string {
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const action = p.action as string | undefined;
|
||||||
|
const repo = (p.repository as Record<string, unknown>)?.full_name as string | undefined;
|
||||||
|
|
||||||
|
if (p.pull_request) {
|
||||||
|
const pr = p.pull_request as Record<string, unknown>;
|
||||||
|
return `GitHub PR ${action}: ${pr.title} in ${repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.issue) {
|
||||||
|
const issue = p.issue as Record<string, unknown>;
|
||||||
|
return `GitHub Issue ${action}: ${issue.title} in ${repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.pusher) {
|
||||||
|
const commits = p.commits as unknown[] | undefined;
|
||||||
|
return `GitHub Push: ${commits?.length || 0} commits to ${repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `GitHub event in ${repo || "unknown"}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Stripe webhook
|
||||||
|
*/
|
||||||
|
stripe(payload: unknown): string {
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const type = p.type as string | undefined;
|
||||||
|
const data = p.data as Record<string, unknown> | undefined;
|
||||||
|
const object = data?.object as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (type?.startsWith("payment_intent.")) {
|
||||||
|
const amount = object?.amount as number | undefined;
|
||||||
|
const currency = object?.currency as string | undefined;
|
||||||
|
return `Stripe ${type}: ${amount ? (amount / 100).toFixed(2) : "?"} ${currency?.toUpperCase() || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type?.startsWith("customer.")) {
|
||||||
|
return `Stripe ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Stripe event: ${type || "unknown"}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse generic uptime monitor webhook
|
||||||
|
*/
|
||||||
|
uptime(payload: unknown): string {
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const status = p.status || p.state || p.alert_type;
|
||||||
|
const url = p.url || p.monitor_url || p.target;
|
||||||
|
return `Uptime alert: ${status} for ${url || "unknown"}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user