feat: add Moltbot Secure edition for Railway deployment
A lean, secure, self-hosted AI assistant designed for Railway: - Telegram-only channel (allowlist-based access control) - Authenticated webhook receiver for external integrations - Docker sandbox for isolated code execution - Cron scheduler for recurring tasks - Env-only configuration (no config files) - Full audit logging Core files: - secure/config.ts - Environment-only configuration - secure/audit.ts - Audit logging system - secure/agent.ts - AI agent core (Anthropic/OpenAI) - secure/telegram.ts - Telegram bot handler - secure/webhooks.ts - Webhook receiver - secure/sandbox.ts - Docker sandbox execution - secure/scheduler.ts - Cron task scheduler - secure/index.ts - Main entry point - secure/Dockerfile - Minimal container image - secure/railway.json - Railway deployment config https://claude.ai/code/session_015VqJ7gN4vaxtYfYc92UjLs
This commit is contained in:
parent
109ac1c549
commit
c7306b6721
225
SECURE-BOT.md
Normal file
225
SECURE-BOT.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Moltbot Secure 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
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ MOLTBOT SECURE │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 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
|
||||
```
|
||||
51
secure/Dockerfile
Normal file
51
secure/Dockerfile
Normal file
@ -0,0 +1,51 @@
|
||||
# Moltbot Secure - Minimal Docker Image
|
||||
# Lean, secure, self-hosted AI assistant for Railway
|
||||
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY secure/package.json ./secure/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Copy source
|
||||
COPY secure/ ./secure/
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm exec tsc --project secure/tsconfig.json
|
||||
|
||||
# Production image
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
# Security: Run as non-root user
|
||||
RUN useradd -m -u 1000 moltbot
|
||||
USER moltbot
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built files and production deps
|
||||
COPY --from=builder --chown=moltbot:moltbot /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=moltbot:moltbot /app/secure/dist ./dist
|
||||
COPY --from=builder --chown=moltbot:moltbot /app/package.json ./
|
||||
|
||||
# Create data directory for audit logs
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --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"]
|
||||
177
secure/agent.ts
Normal file
177
secure/agent.ts
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Moltbot Secure - Agent Core
|
||||
*
|
||||
* Minimal AI agent that handles conversations.
|
||||
* 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 Message = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AgentResponse = {
|
||||
text: string;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCore = {
|
||||
chat: (messages: Message[], systemPrompt?: string) => Promise<AgentResponse>;
|
||||
provider: "anthropic" | "openai";
|
||||
};
|
||||
|
||||
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-4o";
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant running as a secure, self-hosted 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
|
||||
|
||||
When you receive webhook notifications, summarize them helpfully for the user.
|
||||
|
||||
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;
|
||||
|
||||
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: 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenAIAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||
const client = new OpenAI({
|
||||
apiKey: config.ai.apiKey,
|
||||
});
|
||||
|
||||
const model = config.ai.model || DEFAULT_OPENAI_MODEL;
|
||||
|
||||
return {
|
||||
provider: "openai",
|
||||
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT },
|
||||
...messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||
if (config.ai.provider === "anthropic") {
|
||||
return createAnthropicAgent(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 }),
|
||||
};
|
||||
}
|
||||
235
secure/config.ts
Normal file
235
secure/config.ts
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Moltbot Secure - 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";
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
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"; apiKey: string } {
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const openaiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (anthropicKey) {
|
||||
return { provider: "anthropic", apiKey: anthropicKey };
|
||||
}
|
||||
if (openaiKey) {
|
||||
return { provider: "openai", apiKey: openaiKey };
|
||||
}
|
||||
|
||||
throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY or OPENAI_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
|
||||
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", "moltbot/sandbox:latest"),
|
||||
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("MOLTBOT_GATEWAY_TOKEN", generateSecureToken()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]",
|
||||
},
|
||||
};
|
||||
}
|
||||
193
secure/index.ts
Normal file
193
secure/index.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Moltbot Secure - Entry Point
|
||||
*
|
||||
* Lean, secure, self-hosted AI assistant for Railway.
|
||||
*
|
||||
* Usage:
|
||||
* TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 npx ts-node 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";
|
||||
|
||||
async function main() {
|
||||
console.log("=".repeat(50));
|
||||
console.log(" MOLTBOT SECURE");
|
||||
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 AI agent
|
||||
console.log(`[init] Creating AI agent (${config.ai.provider})...`);
|
||||
const agent = createAgent(config, audit);
|
||||
|
||||
// Create conversation store
|
||||
const conversations = createConversationStore();
|
||||
|
||||
// Create Telegram bot
|
||||
console.log("[init] Creating Telegram bot...");
|
||||
const telegram = createTelegramBot({
|
||||
config,
|
||||
audit,
|
||||
agent,
|
||||
conversations,
|
||||
});
|
||||
|
||||
// Create webhook handler
|
||||
console.log("[init] Creating webhook handler...");
|
||||
const webhooks = createWebhookHandler({
|
||||
config,
|
||||
audit,
|
||||
agent,
|
||||
telegramBot: telegram.bot,
|
||||
});
|
||||
|
||||
// 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 scheduler
|
||||
console.log("[init] Creating scheduler...");
|
||||
const scheduler = createScheduler({
|
||||
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") {
|
||||
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",
|
||||
}));
|
||||
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 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
|
||||
scheduler.start();
|
||||
|
||||
// Start Telegram bot (polling mode for simplicity)
|
||||
await telegram.start();
|
||||
|
||||
console.log();
|
||||
console.log("=".repeat(50));
|
||||
console.log(" MOLTBOT SECURE 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(` 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": "moltbot-secure",
|
||||
"version": "1.0.0",
|
||||
"description": "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"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
267
secure/sandbox.ts
Normal file
267
secure/sandbox.ts
Normal file
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Moltbot Secure - Sandbox Execution
|
||||
*
|
||||
* Isolated Docker container for code/script execution.
|
||||
* 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>;
|
||||
isAvailable: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function createSandboxRunner(config: SecureConfig, audit: AuditLogger): SandboxRunner {
|
||||
const sandboxConfig = config.sandbox;
|
||||
|
||||
return {
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (!sandboxConfig.enabled) return false;
|
||||
return checkDocker();
|
||||
},
|
||||
|
||||
async run(command: string, stdin?: string): Promise<SandboxResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!sandboxConfig.enabled) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "Sandbox is disabled",
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = buildDockerArgs(sandboxConfig, 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;
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
audit.sandbox({
|
||||
command,
|
||||
exitCode,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: stdout.slice(0, 10000), // Limit output size
|
||||
stderr: stderr.slice(0, 10000),
|
||||
timedOut,
|
||||
durationMs,
|
||||
});
|
||||
};
|
||||
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill("SIGKILL");
|
||||
}, sandboxConfig.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();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function buildCommand(language: string, code: string): string {
|
||||
switch (language.toLowerCase()) {
|
||||
case "python":
|
||||
case "py":
|
||||
// Write code to temp file and execute
|
||||
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:
|
||||
// Default to shell
|
||||
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;
|
||||
}
|
||||
270
secure/scheduler.ts
Normal file
270
secure/scheduler.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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 { 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: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export type SchedulerDeps = {
|
||||
config: SecureConfig;
|
||||
audit: AuditLogger;
|
||||
agent: AgentCore;
|
||||
telegramBot: Bot;
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
const { config, audit, agent, telegramBot } = deps;
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
const cronJobs = new Map<string, CronJob>();
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
},
|
||||
|
||||
start(): void {
|
||||
if (!config.scheduler.enabled) {
|
||||
console.log("[scheduler] Scheduler is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[scheduler] Starting scheduler...");
|
||||
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.",
|
||||
},
|
||||
};
|
||||
321
secure/telegram.ts
Normal file
321
secure/telegram.ts
Normal file
@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Moltbot Secure - Telegram Channel
|
||||
*
|
||||
* Minimal, secure Telegram bot handler.
|
||||
* Allowlist-only: only approved users can interact.
|
||||
*/
|
||||
|
||||
import { Bot, Context, webhookCallback } from "grammy";
|
||||
import type { SecureConfig } from "./config.js";
|
||||
import type { AuditLogger } from "./audit.js";
|
||||
import type { AgentCore, ConversationStore, Message } from "./agent.js";
|
||||
|
||||
export type TelegramBot = {
|
||||
bot: Bot;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
webhookHandler: (path?: string) => ReturnType<typeof webhookCallback>;
|
||||
};
|
||||
|
||||
export type TelegramDeps = {
|
||||
config: SecureConfig;
|
||||
audit: AuditLogger;
|
||||
agent: AgentCore;
|
||||
conversations: ConversationStore;
|
||||
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 } = 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 Moltbot Secure.
|
||||
|
||||
You are authorized to use this bot.
|
||||
|
||||
Commands:
|
||||
/start - Show this message
|
||||
/clear - Clear conversation history
|
||||
/status - Check bot status
|
||||
/help - Show help
|
||||
|
||||
Just send me a message to chat!`
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
await ctx.reply(
|
||||
`Status:
|
||||
- AI Provider: ${agent.provider}
|
||||
- Conversation: ${history.length} messages
|
||||
- Sandbox: ${config.sandbox.enabled ? "enabled" : "disabled"}
|
||||
- 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(
|
||||
`Moltbot Secure Help
|
||||
|
||||
This is a secure, self-hosted AI assistant.
|
||||
|
||||
Features:
|
||||
- Chat with AI (text messages)
|
||||
- Forward content for analysis
|
||||
- Receive webhook notifications
|
||||
|
||||
Commands:
|
||||
/start - Welcome message
|
||||
/clear - Clear conversation history
|
||||
/status - Bot status
|
||||
/help - This message
|
||||
|
||||
Security:
|
||||
- Only authorized users can interact
|
||||
- All interactions are logged
|
||||
- No data is sent to third parties (except AI provider)`
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Call AI
|
||||
const response = await agent.chat(history);
|
||||
|
||||
// Add assistant response to history
|
||||
conversations.add(userId, { role: "assistant", content: 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;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"I received your image. Image analysis is available with Claude - please describe what you'd like me to analyze."
|
||||
);
|
||||
});
|
||||
|
||||
// Handle documents
|
||||
bot.on("message:document", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"I received your document. Document analysis coming soon - for now, please copy/paste the text content."
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
webhookHandler(path = "/telegram"): ReturnType<typeof webhookCallback> {
|
||||
return webhookCallback(bot, "http", { path });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
20
secure/tsconfig.json
Normal file
20
secure/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"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