feat: add persistent personality, Piston API sandbox, and storage layer
- Add personality engine with learning from conversations - Tracks user preferences, interests, and communication style - Persists to Redis (cache) + PostgreSQL (durable) - Generates personalized system prompts per user - Add Piston API fallback for sandbox execution - Auto-detects backend: Docker → Piston API → none - Supports 15+ languages via free cloud execution - Works on Railway and other managed platforms - Add storage layer with layered persistence - PostgreSQL for tasks, user profiles, personality traits - Redis for conversation cache and fast profile access - Graceful fallback to in-memory when not configured - Update scheduler with task persistence - Loads tasks from PostgreSQL on startup - Saves task status after execution - Add document analysis (PDF, text files) - Add railway-template.json for one-click deployment - Enable sandbox by default (Piston fallback) - Add OpenRouter support (100+ models) https://claude.ai/code/session_015VqJ7gN4vaxtYfYc92UjLs
This commit is contained in:
parent
c33905a2bc
commit
095d476acc
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,43 +1,42 @@
|
||||
# AssureBot - Minimal Docker Image
|
||||
# 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
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy workspace config and package files
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY secure/package.json ./secure/
|
||||
# Copy package files (handles both root and secure/ as context)
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json* ./
|
||||
COPY *.ts ./
|
||||
COPY *.d.ts ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Copy source
|
||||
COPY secure/ ./secure/
|
||||
RUN npm install --omit=dev=false
|
||||
|
||||
# Build TypeScript
|
||||
RUN cd secure && pnpm exec tsc
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
# Security: Run as non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash assurebot
|
||||
USER assurebot
|
||||
# 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/secure/node_modules ./secure/node_modules
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/secure/dist ./dist
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/secure/package.json ./
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/dist ./dist
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/package.json ./
|
||||
|
||||
# Create data directory for audit logs
|
||||
RUN mkdir -p /app/data
|
||||
# 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
|
||||
@ -45,7 +44,7 @@ ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
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"]
|
||||
|
||||
105
secure/agent.ts
105
secure/agent.ts
@ -39,11 +39,12 @@ export type AgentResponse = {
|
||||
export type AgentCore = {
|
||||
chat: (messages: Message[], systemPrompt?: string) => Promise<AgentResponse>;
|
||||
analyzeImage: (imageData: string, mediaType: ImageContent["mediaType"], prompt?: string) => Promise<AgentResponse>;
|
||||
provider: "anthropic" | "openai";
|
||||
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 a helpful AI assistant running as a secure, self-hosted bot.
|
||||
|
||||
@ -234,10 +235,112 @@ function createOpenAIAgent(config: SecureConfig, audit: AuditLogger): AgentCore
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Moltbot Secure - Environment-only Configuration
|
||||
* AssureBot - Environment-only Configuration
|
||||
*
|
||||
* All configuration via environment variables.
|
||||
* No config files, no filesystem secrets.
|
||||
@ -14,7 +14,7 @@ export type SecureConfig = {
|
||||
|
||||
// AI Provider
|
||||
ai: {
|
||||
provider: "anthropic" | "openai";
|
||||
provider: "anthropic" | "openai" | "openrouter";
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
};
|
||||
@ -53,6 +53,12 @@ export type SecureConfig = {
|
||||
host: string;
|
||||
gatewayToken: string;
|
||||
};
|
||||
|
||||
// Storage (optional)
|
||||
storage: {
|
||||
postgresUrl?: string;
|
||||
redisUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function required(name: string): string {
|
||||
@ -89,9 +95,10 @@ function parseAllowedUsers(value: string): number[] {
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
}
|
||||
|
||||
function detectAiProvider(): { provider: "anthropic" | "openai"; apiKey: string } {
|
||||
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 };
|
||||
@ -99,8 +106,11 @@ function detectAiProvider(): { provider: "anthropic" | "openai"; apiKey: string
|
||||
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 or OPENAI_API_KEY");
|
||||
throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY");
|
||||
}
|
||||
|
||||
function generateSecureToken(): string {
|
||||
@ -132,7 +142,7 @@ export function loadSecureConfig(): SecureConfig {
|
||||
const webhooksEnabled = optionalBool("WEBHOOKS_ENABLED", true);
|
||||
const webhookSecret = optional("WEBHOOK_SECRET", generateSecureToken());
|
||||
|
||||
// Optional: Sandbox
|
||||
// Optional: Sandbox (enabled by default - auto-detects Docker or Piston API fallback)
|
||||
const sandboxEnabled = optionalBool("SANDBOX_ENABLED", true);
|
||||
|
||||
// Optional: Scheduler
|
||||
@ -177,7 +187,11 @@ export function loadSecureConfig(): SecureConfig {
|
||||
server: {
|
||||
port,
|
||||
host: optional("HOST", "0.0.0.0"),
|
||||
gatewayToken: optional("MOLTBOT_GATEWAY_TOKEN", generateSecureToken()),
|
||||
gatewayToken: optional("ASSUREBOT_GATEWAY_TOKEN", generateSecureToken()),
|
||||
},
|
||||
storage: {
|
||||
postgresUrl: process.env.DATABASE_URL || process.env.POSTGRES_URL,
|
||||
redisUrl: process.env.REDIS_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -231,5 +245,9 @@ export function redactConfig(config: SecureConfig): Record<string, unknown> {
|
||||
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(", ");
|
||||
}
|
||||
@ -15,6 +15,8 @@ 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));
|
||||
@ -49,6 +51,15 @@ async function main() {
|
||||
});
|
||||
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);
|
||||
@ -56,33 +67,46 @@ async function main() {
|
||||
// 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
|
||||
// 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,
|
||||
@ -96,6 +120,7 @@ async function main() {
|
||||
|
||||
// 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({
|
||||
@ -104,6 +129,9 @@ async function main() {
|
||||
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;
|
||||
}
|
||||
@ -141,6 +169,7 @@ async function main() {
|
||||
try {
|
||||
scheduler.stop();
|
||||
await telegram.stop();
|
||||
await storage.close();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
@ -168,8 +197,8 @@ async function main() {
|
||||
console.log(`[start] HTTP server listening on ${config.server.host}:${config.server.port}`);
|
||||
});
|
||||
|
||||
// Start scheduler
|
||||
scheduler.start();
|
||||
// Start scheduler (loads tasks from storage)
|
||||
await scheduler.start();
|
||||
|
||||
// Start Telegram bot (polling mode for simplicity)
|
||||
await telegram.start();
|
||||
@ -181,6 +210,7 @@ async function main() {
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
248
secure/personality.ts
Normal file
248
secure/personality.ts
Normal file
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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.
|
||||
|
||||
## 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("; ")}` : ""}
|
||||
|
||||
## 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
|
||||
${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;
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Moltbot Secure - Sandbox Execution
|
||||
* 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
|
||||
*
|
||||
* Isolated Docker container for code/script execution.
|
||||
* Security-first: no network, read-only root, resource limits.
|
||||
*/
|
||||
|
||||
@ -19,7 +22,34 @@ export type SandboxResult = {
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
/**
|
||||
@ -35,6 +65,102 @@ async function checkDocker(): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@ -83,101 +209,192 @@ function buildDockerArgs(config: SecureConfig["sandbox"], command: string): stri
|
||||
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> {
|
||||
if (!sandboxConfig.enabled) return false;
|
||||
return checkDocker();
|
||||
const backend = await detectBackend();
|
||||
return backend !== "none";
|
||||
},
|
||||
|
||||
async run(command: string, stdin?: string): Promise<SandboxResult> {
|
||||
const backend = await detectBackend();
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!sandboxConfig.enabled) {
|
||||
if (backend === "none") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "Sandbox is disabled",
|
||||
stderr: "Sandbox is disabled or no backend available",
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = buildDockerArgs(sandboxConfig, command);
|
||||
let result: SandboxResult;
|
||||
|
||||
const proc = spawn("docker", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
if (backend === "docker") {
|
||||
result = await runDocker(sandboxConfig, command, stdin);
|
||||
} else {
|
||||
// Piston: run as bash
|
||||
result = await runPiston("bash", command, sandboxConfig.timeoutMs);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -214,13 +431,12 @@ export function parseSandboxRequest(text: string): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build execution command for language
|
||||
* Build execution command for language (Docker only)
|
||||
*/
|
||||
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":
|
||||
@ -234,7 +450,6 @@ export function buildCommand(language: string, code: string): string {
|
||||
return code;
|
||||
|
||||
default:
|
||||
// Default to shell
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ 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 = {
|
||||
@ -29,7 +30,7 @@ export type Scheduler = {
|
||||
enableTask: (id: string, enabled: boolean) => boolean;
|
||||
listTasks: () => ScheduledTask[];
|
||||
runTask: (id: string) => Promise<void>;
|
||||
start: () => void;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
@ -38,6 +39,7 @@ export type SchedulerDeps = {
|
||||
audit: AuditLogger;
|
||||
agent: AgentCore;
|
||||
telegramBot: Bot;
|
||||
storage?: Storage;
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
@ -45,7 +47,7 @@ function generateId(): string {
|
||||
}
|
||||
|
||||
export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
const { config, audit, agent, telegramBot } = deps;
|
||||
const { config, audit, agent, telegramBot, storage } = deps;
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
const cronJobs = new Map<string, CronJob<null, unknown>>();
|
||||
|
||||
@ -68,6 +70,11 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
task.lastStatus = "ok";
|
||||
task.lastError = undefined;
|
||||
|
||||
// Save updated task status
|
||||
if (storage) {
|
||||
void storage.saveTask(task);
|
||||
}
|
||||
|
||||
audit.cron({
|
||||
jobId: task.id,
|
||||
jobName: task.name,
|
||||
@ -81,6 +88,11 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
task.lastStatus = "error";
|
||||
task.lastError = errorMsg;
|
||||
|
||||
// Save updated task status
|
||||
if (storage) {
|
||||
void storage.saveTask(task);
|
||||
}
|
||||
|
||||
audit.cron({
|
||||
jobId: task.id,
|
||||
jobName: task.name,
|
||||
@ -133,6 +145,10 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
const task: ScheduledTask = { ...taskInput, id };
|
||||
tasks.set(id, task);
|
||||
scheduleTask(task);
|
||||
// Persist to storage
|
||||
if (storage) {
|
||||
void storage.saveTask(task);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
@ -147,6 +163,10 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
}
|
||||
|
||||
tasks.delete(id);
|
||||
// Remove from storage
|
||||
if (storage) {
|
||||
void storage.deleteTask(id);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@ -171,13 +191,23 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
await executeTask(task);
|
||||
},
|
||||
|
||||
start(): void {
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -9,6 +9,10 @@ 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;
|
||||
@ -21,6 +25,9 @@ export type TelegramDeps = {
|
||||
audit: AuditLogger;
|
||||
agent: AgentCore;
|
||||
conversations: ConversationStore;
|
||||
sandbox?: SandboxRunner;
|
||||
scheduler?: Scheduler;
|
||||
personality?: Personality;
|
||||
onWebhookMessage?: (userId: number, text: string) => void;
|
||||
};
|
||||
|
||||
@ -37,7 +44,7 @@ function formatUsername(ctx: Context): string {
|
||||
}
|
||||
|
||||
export function createTelegramBot(deps: TelegramDeps): TelegramBot {
|
||||
const { config, audit, agent, conversations } = deps;
|
||||
const { config, audit, agent, conversations, sandbox, scheduler, personality } = deps;
|
||||
const bot = new Bot(config.telegram.botToken);
|
||||
|
||||
// Error handler
|
||||
@ -70,6 +77,9 @@ Commands:
|
||||
/start - Show this message
|
||||
/clear - Clear conversation history
|
||||
/status - Check bot status
|
||||
/sandbox <code> - Run code in sandbox
|
||||
/schedule <cron> <task> - Schedule a task
|
||||
/tasks - List scheduled tasks
|
||||
/help - Show help
|
||||
|
||||
Features:
|
||||
@ -124,21 +134,183 @@ Features:
|
||||
- Chat with AI (text messages)
|
||||
- Image analysis (send photos)
|
||||
- Forward content for analysis
|
||||
- Receive webhook notifications
|
||||
- Run code in isolated sandbox
|
||||
- Schedule recurring AI tasks
|
||||
|
||||
Commands:
|
||||
/start - Welcome message
|
||||
/clear - Clear conversation history
|
||||
/status - Bot status
|
||||
/sandbox <code> - Run code in sandbox
|
||||
/schedule "<cron>" "<name>" <prompt> - Schedule task
|
||||
/tasks - List scheduled tasks
|
||||
/deltask <id> - Delete a task
|
||||
/help - This message
|
||||
|
||||
Security:
|
||||
- Only authorized users can interact
|
||||
- All interactions are logged
|
||||
- No data is sent to third parties (except AI provider)`
|
||||
- Sandbox runs in isolated Docker (no network)`
|
||||
);
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@ -173,12 +345,22 @@ Security:
|
||||
// Get conversation history
|
||||
const history = conversations.get(userId);
|
||||
|
||||
// Call AI
|
||||
const response = await agent.chat(history);
|
||||
// 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
|
||||
@ -332,13 +514,95 @@ Security:
|
||||
// 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;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"I received your document. Document analysis coming soon - for now, please copy/paste the text content."
|
||||
);
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user