Merge ff198e89b9 into 6af205a13a
This commit is contained in:
commit
e4813afece
562
README.md
562
README.md
@ -1,518 +1,114 @@
|
||||
# 🦞 OpenClaw — Personal AI Assistant
|
||||
# AssureBot
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
||||
</picture>
|
||||
</p>
|
||||
**Lean, secure, self-hosted AI assistant for Railway.**
|
||||
|
||||
<p align="center">
|
||||
<strong>EXFOLIATE! EXFOLIATE!</strong>
|
||||
</p>
|
||||
Your AI agent that runs on your infrastructure, answers only to you, and you can actually audit.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/openclaw/openclaw/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/openclaw/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/openclaw/openclaw/releases"><img src="https://img.shields.io/github/v/release/openclaw/openclaw?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
[](https://railway.app/template/assurebot)
|
||||
|
||||
**OpenClaw** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
## Why AssureBot?
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
| Full OpenClaw | AssureBot |
|
||||
|---------------|-----------|
|
||||
| 12+ channels | Telegram only |
|
||||
| File-based config | Env vars only |
|
||||
| Plugins/extensions | None (locked down) |
|
||||
| Desktop/mobile apps | Headless server |
|
||||
| Complex setup | One-click deploy |
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-clawdbot) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
**Trade-off**: Less features, more trust.
|
||||
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
## Features
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
- **Telegram Bot** — Allowlist-only access, no public commands
|
||||
- **Image Analysis** — Send photos for AI analysis (Claude Vision / GPT-4V)
|
||||
- **Webhook Receiver** — Authenticated HTTP endpoint for integrations
|
||||
- **Docker Sandbox** — Isolated code execution (no network, dropped caps)
|
||||
- **Cron Scheduler** — Time-based recurring tasks
|
||||
- **Full Audit Log** — JSONL logs of every interaction
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
## Quick Start
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
# or: pnpm add -g openclaw@latest
|
||||
# Required
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
ALLOWED_USERS=123456789,987654321 # Telegram user IDs
|
||||
|
||||
openclaw onboard --install-daemon
|
||||
# AI Provider (one required)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# or
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Optional
|
||||
WEBHOOK_SECRET=auto-generated-if-empty
|
||||
AUDIT_LOG_PATH=/data/audit.jsonl
|
||||
SANDBOX_ENABLED=true
|
||||
```
|
||||
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
### Deploy to Railway
|
||||
|
||||
## Quick start (TL;DR)
|
||||
1. Click the deploy button above
|
||||
2. Set environment variables
|
||||
3. Your bot is live
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
### Run Locally
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
|
||||
openclaw gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
|
||||
cd secure
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
### Docker
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups).
|
||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
||||
|
||||
### Channels
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [iMessage](https://docs.openclaw.ai/channels/imessage) (imsg), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
|
||||
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
|
||||
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (openclaw …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
```bash
|
||||
docker build -t assurebot -f secure/Dockerfile .
|
||||
docker run -d \
|
||||
-e TELEGRAM_BOT_TOKEN=... \
|
||||
-e ALLOWED_USERS=... \
|
||||
-e ANTHROPIC_API_KEY=... \
|
||||
assurebot
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
## Security Model
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
- **No config files** — All secrets via environment variables
|
||||
- **Allowlist only** — Only specified Telegram user IDs can interact
|
||||
- **Timing-safe auth** — Webhook tokens compared safely
|
||||
- **Sandbox isolation** — Code runs in Docker with no network, read-only root, dropped capabilities
|
||||
- **Audit everything** — Every message, command, and action logged to JSONL
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
## Architecture
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawdHub](https://ClawdHub.com)
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## Apps (optional)
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
|
||||
### macOS (OpenClaw.app) (optional)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `openclaw nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5"
|
||||
}
|
||||
}
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Telegram │────▶│ AssureBot │────▶│ AI Agent │
|
||||
│ (User) │◀────│ (Core) │◀────│ (Claude/ │
|
||||
└─────────────┘ └─────────────┘ │ OpenAI) │
|
||||
│ └─────────────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Webhooks │ │ Sandbox │ │ Scheduler│
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
## Commands
|
||||
|
||||
## Security model (important)
|
||||
In Telegram, send:
|
||||
- Any text message → AI responds
|
||||
- Photo with caption → Image analysis
|
||||
- `/sandbox <code>` → Run code in isolated container
|
||||
- `/schedule <cron> <task>` → Create scheduled task
|
||||
- `/tasks` → List scheduled tasks
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
## Based On
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
AssureBot is a hardened fork of [OpenClaw](https://github.com/openclaw/openclaw), stripped down for security-first self-hosting.
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
## License
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [iMessage](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
|
||||
## Molty
|
||||
|
||||
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [openclaw.ai](https://openclaw.ai)
|
||||
- [soul.md](https://soul.md)
|
||||
- [steipete.me](https://steipete.me)
|
||||
- [@openclaw](https://x.com/openclaw)
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a>
|
||||
<a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
|
||||
<a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a>
|
||||
<a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a>
|
||||
<a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||
<a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a>
|
||||
<a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||
<a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
|
||||
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a>
|
||||
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a>
|
||||
<a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a>
|
||||
<a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a>
|
||||
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
|
||||
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||
<a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/search?q=OpenClaw%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="OpenClaw Maintainers" title="OpenClaw Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
|
||||
<a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a>
|
||||
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a>
|
||||
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a>
|
||||
<a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
MIT
|
||||
|
||||
225
SECURE-BOT.md
Normal file
225
SECURE-BOT.md
Normal file
@ -0,0 +1,225 @@
|
||||
# AssureBot Edition
|
||||
|
||||
A lean, secure, self-hosted AI assistant for Railway deployment.
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Your AI agent that runs on your infrastructure, answers only to you, and you can actually audit.**
|
||||
|
||||
- No SaaS middleman
|
||||
- No data harvesting
|
||||
- Your keys, your server, your rules
|
||||
|
||||
## Core Principles
|
||||
|
||||
| Principle | Implementation |
|
||||
|-----------|----------------|
|
||||
| **Allowlist-only** | Nobody talks to it unless explicitly approved |
|
||||
| **Env-var config** | No config files to leak, no filesystem secrets |
|
||||
| **Audit log** | Every interaction logged, inspectable |
|
||||
| **No phone-home** | Zero telemetry, no central service |
|
||||
| **Minimal surface** | Small codebase, few deps, easy to read |
|
||||
| **Your keys** | Direct to Anthropic/OpenAI, no proxy |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ ASSUREBOT │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Telegram │ │ Webhooks │ │ Scheduler │ │
|
||||
│ │ Channel │ │ Receiver │ │ (Cron) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────┐ │
|
||||
│ │ Agent │ │
|
||||
│ │ Core │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────┼─────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
|
||||
│ │ AI Model │ │ Sandbox │ │ Audit │ │
|
||||
│ │ (Direct) │ │ (Docker) │ │ Logger │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Telegram (Primary UI)
|
||||
- Chat with AI (text, voice transcription, images)
|
||||
- Forward anything for analysis
|
||||
- Upload docs for Q&A
|
||||
- `/commands` for quick actions
|
||||
- **Allowlist-only**: Must be in `ALLOWED_USERS`
|
||||
|
||||
### Webhooks (Inbound)
|
||||
- Authenticated endpoint at `/hooks/*`
|
||||
- Receive from GitHub, Stripe, uptime monitors, etc.
|
||||
- AI summarizes and forwards to Telegram
|
||||
- Bearer token or `X-Moltbot-Token` header auth
|
||||
|
||||
### Scheduler (Cron)
|
||||
- Built-in cron expressions
|
||||
- Morning briefings, monitors, recurring tasks
|
||||
- `at:` one-shot scheduling
|
||||
- `every:` interval scheduling
|
||||
|
||||
### Sandbox (Isolated Execution)
|
||||
- Docker container for code/script execution
|
||||
- Network isolated by default
|
||||
- Resource limits (CPU, memory, time)
|
||||
- Read-only root filesystem
|
||||
- Ephemeral - destroyed after use
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration via environment variables. No config files.
|
||||
|
||||
### Required
|
||||
|
||||
```bash
|
||||
# Bot Identity
|
||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||
|
||||
# AI Provider (pick one)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# or
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Access Control
|
||||
ALLOWED_USERS=123456789,987654321 # Telegram user IDs
|
||||
```
|
||||
|
||||
### Optional
|
||||
|
||||
```bash
|
||||
# Webhook Authentication
|
||||
WEBHOOK_SECRET=your-random-32-char-secret
|
||||
|
||||
# Gateway Auth (for internal API)
|
||||
MOLTBOT_GATEWAY_TOKEN=another-random-secret
|
||||
|
||||
# Sandbox Settings
|
||||
SANDBOX_ENABLED=true
|
||||
SANDBOX_NETWORK=none # none | bridge
|
||||
SANDBOX_MEMORY=512m
|
||||
SANDBOX_CPUS=1
|
||||
|
||||
# Audit Logging
|
||||
AUDIT_LOG_PATH=/data/audit.jsonl
|
||||
```
|
||||
|
||||
## Railway Deployment
|
||||
|
||||
### One-Click Deploy
|
||||
|
||||
[](https://railway.app/template/moltbot-secure)
|
||||
|
||||
### Manual Setup
|
||||
|
||||
1. Create new Railway project
|
||||
2. Add from GitHub repo
|
||||
3. Set environment variables:
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
|
||||
- `ALLOWED_USERS`
|
||||
- `WEBHOOK_SECRET` (recommended)
|
||||
4. Add volume at `/data` for persistence
|
||||
5. Deploy
|
||||
|
||||
### railway.json
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile.secure"
|
||||
},
|
||||
"deploy": {
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 30,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
### What We Block
|
||||
|
||||
- **Unauthorized users**: Only `ALLOWED_USERS` can interact
|
||||
- **Unauthenticated webhooks**: Require valid token
|
||||
- **Network in sandbox**: Disabled by default
|
||||
- **Filesystem access**: Read-only root, tmpfs only
|
||||
- **Privilege escalation**: All caps dropped
|
||||
- **Secret leakage**: Automatic redaction in logs
|
||||
|
||||
### What We Log
|
||||
|
||||
Every interaction is logged to `AUDIT_LOG_PATH`:
|
||||
|
||||
```jsonl
|
||||
{"ts":"2024-01-15T10:30:00Z","type":"message","user":123456789,"text":"...","response":"..."}
|
||||
{"ts":"2024-01-15T10:30:05Z","type":"webhook","path":"/hooks/github","status":200}
|
||||
{"ts":"2024-01-15T10:30:10Z","type":"sandbox","command":"python script.py","exit":0}
|
||||
```
|
||||
|
||||
### Threat Model
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|------------|
|
||||
| Unauthorized access | Telegram user ID allowlist |
|
||||
| Webhook abuse | Bearer token auth, rate limits |
|
||||
| Code execution escape | Docker isolation, no network, caps dropped |
|
||||
| Secret exposure | Env-only config, log redaction |
|
||||
| Model prompt injection | Sandboxed tool execution |
|
||||
|
||||
## What's NOT Included
|
||||
|
||||
Intentionally removed for security/simplicity:
|
||||
|
||||
- Web UI / Setup wizard
|
||||
- WebSocket device pairing
|
||||
- Plugin/extension system
|
||||
- WhatsApp/Signal/iMessage/Discord
|
||||
- Multi-account support
|
||||
- Browser automation sandbox
|
||||
- File-based configuration
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run in dev mode
|
||||
TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 pnpm dev:secure
|
||||
|
||||
# Build
|
||||
pnpm build:secure
|
||||
|
||||
# Test
|
||||
pnpm test:secure
|
||||
```
|
||||
|
||||
## Directory Structure (Secure Edition)
|
||||
|
||||
```
|
||||
secure/
|
||||
├── index.ts # Entry point
|
||||
├── config.ts # Env-only config loader
|
||||
├── telegram.ts # Telegram bot (grammy)
|
||||
├── webhooks.ts # Webhook receiver
|
||||
├── scheduler.ts # Cron service
|
||||
├── sandbox.ts # Docker sandbox
|
||||
├── audit.ts # Audit logger
|
||||
├── agent.ts # AI agent core
|
||||
└── Dockerfile # Minimal container
|
||||
```
|
||||
174
pnpm-lock.yaml
generated
174
pnpm-lock.yaml
generated
@ -314,7 +314,7 @@ importers:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0
|
||||
devDependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@ -322,7 +322,7 @@ importers:
|
||||
|
||||
extensions/line:
|
||||
devDependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@ -348,7 +348,7 @@ importers:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@ -356,7 +356,7 @@ importers:
|
||||
|
||||
extensions/memory-core:
|
||||
devDependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@ -386,7 +386,7 @@ importers:
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
proper-lockfile:
|
||||
@ -397,12 +397,12 @@ importers:
|
||||
|
||||
extensions/nostr:
|
||||
dependencies:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
nostr-tools:
|
||||
specifier: ^2.20.0
|
||||
version: 2.20.0(typescript@5.9.3)
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@ -439,7 +439,7 @@ importers:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@ -459,7 +459,7 @@ importers:
|
||||
|
||||
extensions/zalo:
|
||||
dependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
undici:
|
||||
@ -471,21 +471,40 @@ importers:
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
packages/clawdbot:
|
||||
dependencies:
|
||||
openclaw:
|
||||
moltbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
packages/moltbot:
|
||||
secure:
|
||||
dependencies:
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
'@anthropic-ai/sdk':
|
||||
specifier: ^0.39.0
|
||||
version: 0.39.0
|
||||
cron:
|
||||
specifier: ^3.1.7
|
||||
version: 3.5.0
|
||||
grammy:
|
||||
specifier: ^1.21.1
|
||||
version: 1.39.3
|
||||
openai:
|
||||
specifier: ^4.77.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.19.7
|
||||
tsx:
|
||||
specifier: ^4.7.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
@ -525,6 +544,9 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@anthropic-ai/sdk@0.39.0':
|
||||
resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==}
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2':
|
||||
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
||||
hasBin: true
|
||||
@ -2725,6 +2747,9 @@ packages:
|
||||
'@types/long@4.0.2':
|
||||
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
|
||||
|
||||
'@types/luxon@3.4.2':
|
||||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
@ -2740,12 +2765,21 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@10.17.60':
|
||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/node@24.10.9':
|
||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||
|
||||
@ -2958,6 +2992,10 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
@ -3325,6 +3363,9 @@ packages:
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cron@3.5.0:
|
||||
resolution: {integrity: sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==}
|
||||
|
||||
croner@9.1.0:
|
||||
resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==}
|
||||
engines: {node: '>=18.0'}
|
||||
@ -3633,6 +3674,9 @@ packages:
|
||||
forever-agent@0.6.1:
|
||||
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
|
||||
|
||||
form-data-encoder@1.7.2:
|
||||
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||
|
||||
form-data@2.3.3:
|
||||
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
||||
engines: {node: '>= 0.12'}
|
||||
@ -3645,6 +3689,10 @@ packages:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formdata-node@4.4.1:
|
||||
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||
engines: {node: '>= 12.20'}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@ -3839,6 +3887,9 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -4232,6 +4283,10 @@ packages:
|
||||
lucide@0.563.0:
|
||||
resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
|
||||
|
||||
luxon@3.5.0:
|
||||
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@ -4520,6 +4575,18 @@ packages:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openai@4.104.0:
|
||||
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openai@6.10.0:
|
||||
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
|
||||
hasBin: true
|
||||
@ -5313,6 +5380,9 @@ packages:
|
||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@ -5462,6 +5532,10 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3:
|
||||
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@ -5583,6 +5657,18 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@anthropic-ai/sdk@0.39.0':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
'@types/node-fetch': 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
formdata-node: 4.4.1
|
||||
node-fetch: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
@ -8502,6 +8588,8 @@ snapshots:
|
||||
|
||||
'@types/long@4.0.2': {}
|
||||
|
||||
'@types/luxon@3.4.2': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
@ -8515,12 +8603,25 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.19.30':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.19.7':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.9':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@ -8808,6 +8909,10 @@ snapshots:
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
@ -9210,6 +9315,11 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cron@3.5.0:
|
||||
dependencies:
|
||||
'@types/luxon': 3.4.2
|
||||
luxon: 3.5.0
|
||||
|
||||
croner@9.1.0: {}
|
||||
|
||||
cross-fetch@4.1.0:
|
||||
@ -9573,6 +9683,8 @@ snapshots:
|
||||
|
||||
forever-agent@0.6.1: {}
|
||||
|
||||
form-data-encoder@1.7.2: {}
|
||||
|
||||
form-data@2.3.3:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@ -9596,6 +9708,11 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
formdata-node@4.4.1:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 4.0.0-beta.3
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
@ -9836,6 +9953,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@ -10236,6 +10357,8 @@ snapshots:
|
||||
|
||||
lucide@0.563.0: {}
|
||||
|
||||
luxon@3.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@ -10553,6 +10676,21 @@ snapshots:
|
||||
mimic-function: 5.0.1
|
||||
optional: true
|
||||
|
||||
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
'@types/node-fetch': 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
formdata-node: 4.4.1
|
||||
node-fetch: 2.7.0
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
@ -11500,6 +11638,8 @@ snapshots:
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
@ -11614,6 +11754,8 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-fetch@3.6.20: {}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- .
|
||||
- ui
|
||||
- secure
|
||||
- packages/*
|
||||
- extensions/*
|
||||
|
||||
|
||||
58
railway-template.json
Normal file
58
railway-template.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"name": "AssureBot",
|
||||
"description": "Lean, secure, self-hosted AI assistant with Telegram, document analysis, and scheduled tasks",
|
||||
"services": [
|
||||
{
|
||||
"name": "assurebot",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "secure/Dockerfile",
|
||||
"watchPatterns": ["secure/**"]
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "node dist/index.js",
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 60,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 3
|
||||
},
|
||||
"variables": {
|
||||
"DATABASE_URL": "${{Postgres.DATABASE_URL}}",
|
||||
"REDIS_URL": "${{Redis.REDIS_URL}}",
|
||||
"TELEGRAM_BOT_TOKEN": {
|
||||
"description": "Telegram bot token from @BotFather",
|
||||
"required": true
|
||||
},
|
||||
"ALLOWED_USERS": {
|
||||
"description": "Comma-separated Telegram user IDs",
|
||||
"required": true
|
||||
},
|
||||
"ANTHROPIC_API_KEY": {
|
||||
"description": "Anthropic API key (or use OPENAI_API_KEY or OPENROUTER_API_KEY)",
|
||||
"required": false
|
||||
},
|
||||
"OPENAI_API_KEY": {
|
||||
"description": "OpenAI API key",
|
||||
"required": false
|
||||
},
|
||||
"OPENROUTER_API_KEY": {
|
||||
"description": "OpenRouter API key (100+ models)",
|
||||
"required": false
|
||||
},
|
||||
"AI_MODEL": {
|
||||
"description": "Model override (e.g., claude-3-5-sonnet-20241022)",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Postgres",
|
||||
"plugin": "postgresql"
|
||||
},
|
||||
{
|
||||
"name": "Redis",
|
||||
"plugin": "redis"
|
||||
}
|
||||
]
|
||||
}
|
||||
50
secure/Dockerfile
Normal file
50
secure/Dockerfile
Normal file
@ -0,0 +1,50 @@
|
||||
# AssureBot - Standalone Docker Image
|
||||
# Lean, secure, self-hosted AI assistant for Railway
|
||||
#
|
||||
# Build from repo root: docker build -f secure/Dockerfile .
|
||||
# Or set Railway root directory to: secure/
|
||||
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files (handles both root and secure/ as context)
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json* ./
|
||||
COPY *.ts ./
|
||||
COPY *.d.ts ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev=false
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
# Security: Run as non-root user (use different UID since 1000 exists)
|
||||
RUN useradd -m -u 1001 -s /bin/bash assurebot
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built files and production deps
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/dist ./dist
|
||||
COPY --from=builder --chown=assurebot:assurebot /app/package.json ./
|
||||
|
||||
# Create data directory for audit logs (before switching user)
|
||||
RUN mkdir -p /app/data && chown assurebot:assurebot /app/data
|
||||
|
||||
USER assurebot
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1))" || exit 1
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
241
secure/README.md
Normal file
241
secure/README.md
Normal file
@ -0,0 +1,241 @@
|
||||
# AssureBot
|
||||
|
||||
**Lean, secure, self-hosted AI assistant for Railway.**
|
||||
|
||||
Your AI agent that runs on your infrastructure, answers only to you, and you can actually audit.
|
||||
|
||||
## Why AssureBot?
|
||||
|
||||
| Full Moltbot | AssureBot |
|
||||
|--------------|----------------|
|
||||
| 12+ channels | Telegram only |
|
||||
| File-based config | Env vars only |
|
||||
| Plugins/extensions | None (locked down) |
|
||||
| Desktop/mobile apps | Headless server |
|
||||
| Complex setup | One-click deploy |
|
||||
|
||||
**Trade-off**: Less features, more trust.
|
||||
|
||||
## Features
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ TELEGRAM (your secure UI) │
|
||||
│ ├── Chat with AI (text, images, documents) │
|
||||
│ ├── Code execution (15+ languages) │
|
||||
│ ├── Forward anything → get analysis │
|
||||
│ └── /commands for actions │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ CODE EXECUTION │
|
||||
│ ├── /js, /python, /ts, /bash - Quick execute │
|
||||
│ ├── /run <lang> <code> - Any language │
|
||||
│ ├── Docker (local) or Piston API (cloud) │
|
||||
│ └── Isolated, no network, resource limits │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ WEBHOOKS IN (authenticated) │
|
||||
│ ├── GitHub → "PR merged, here's the summary" │
|
||||
│ ├── Uptime → "Site down, checking why..." │
|
||||
│ └── Anything → AI-summarized to Telegram │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ SCHEDULED TASKS (cron) │
|
||||
│ ├── Morning briefing │
|
||||
│ ├── Monitor RSS/sites │
|
||||
│ └── Recurring research │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ PERSISTENCE (optional) │
|
||||
│ ├── PostgreSQL - Tasks, user profiles │
|
||||
│ ├── Redis - Conversations, cache │
|
||||
│ └── Personality learning per user │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/js <code>` | Run JavaScript |
|
||||
| `/python <code>` | Run Python |
|
||||
| `/ts <code>` | Run TypeScript |
|
||||
| `/bash <code>` | Run shell commands |
|
||||
| `/run <lang> <code>` | Run any language |
|
||||
| `/status` | Bot & sandbox status |
|
||||
| `/clear` | Clear conversation |
|
||||
| `/schedule` | Schedule AI tasks |
|
||||
| `/tasks` | List scheduled tasks |
|
||||
| `/help` | Full command list |
|
||||
|
||||
**Supported Languages**: python, javascript, typescript, bash, rust, go, c, cpp, java, ruby, php
|
||||
|
||||
## Deploy to Railway
|
||||
|
||||
### One-Click (Recommended)
|
||||
|
||||
[](https://railway.app/new/template?template=https://github.com/TNovs1/moltbot/tree/main&envs=TELEGRAM_BOT_TOKEN,ALLOWED_USERS,ANTHROPIC_API_KEY)
|
||||
|
||||
This auto-provisions PostgreSQL and Redis for persistence.
|
||||
|
||||
### Manual
|
||||
|
||||
1. Fork this repo
|
||||
2. Create Railway project from GitHub
|
||||
3. **Set Root Directory to `secure`**
|
||||
4. Set environment variables (see below)
|
||||
5. Optionally add PostgreSQL and Redis services
|
||||
6. Deploy
|
||||
|
||||
## Configuration
|
||||
|
||||
**All config via environment variables. No files.**
|
||||
|
||||
### Required
|
||||
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF... # From @BotFather
|
||||
ALLOWED_USERS=123456789,987654321 # Telegram user IDs
|
||||
|
||||
# Pick ONE AI provider:
|
||||
ANTHROPIC_API_KEY=sk-ant-... # Claude
|
||||
OPENAI_API_KEY=sk-... # GPT-4
|
||||
OPENROUTER_API_KEY=sk-or-... # 100+ models
|
||||
```
|
||||
|
||||
### Optional
|
||||
|
||||
```bash
|
||||
# AI Model (optional - uses sensible defaults)
|
||||
AI_MODEL=claude-sonnet-4-20250514 # or gpt-4o, etc.
|
||||
|
||||
# Storage (auto-wired on Railway template)
|
||||
DATABASE_URL=postgres://... # PostgreSQL
|
||||
REDIS_URL=redis://... # Redis
|
||||
|
||||
# Sandbox (enabled by default)
|
||||
SANDBOX_ENABLED=true # Auto-detects Docker or Piston API
|
||||
SANDBOX_NETWORK=none # none | bridge
|
||||
SANDBOX_MEMORY=512m
|
||||
SANDBOX_CPUS=1
|
||||
SANDBOX_TIMEOUT_MS=60000
|
||||
|
||||
# Webhooks
|
||||
WEBHOOK_SECRET=random-32-chars # Auto-generated if missing
|
||||
WEBHOOK_BASE_PATH=/hooks # Default: /hooks
|
||||
|
||||
# Scheduler
|
||||
SCHEDULER_ENABLED=true # Default: true
|
||||
|
||||
# Audit
|
||||
AUDIT_ENABLED=true # Default: true
|
||||
AUDIT_LOG_PATH=/data/audit.jsonl
|
||||
|
||||
# Server
|
||||
PORT=8080 # Railway sets this
|
||||
HOST=0.0.0.0
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
### What's Enforced
|
||||
|
||||
| Control | Implementation |
|
||||
|---------|----------------|
|
||||
| **Access** | Telegram user ID allowlist |
|
||||
| **Auth** | Timing-safe token comparison |
|
||||
| **Sandbox** | Docker (local) or Piston API (cloud), isolated |
|
||||
| **Secrets** | Env-only, auto-redacted in logs |
|
||||
| **Audit** | Every interaction logged |
|
||||
|
||||
### Sandbox Backends
|
||||
|
||||
AssureBot auto-detects the best available backend:
|
||||
|
||||
1. **Docker** - Full isolation, no network, caps dropped (requires Docker socket)
|
||||
2. **Piston API** - Free cloud execution, 15+ languages (works on Railway/Render/Fly)
|
||||
3. **None** - Sandbox disabled if neither available
|
||||
|
||||
### What's NOT Included
|
||||
|
||||
Intentionally removed:
|
||||
|
||||
- Web UI / setup wizard
|
||||
- Plugin system
|
||||
- WhatsApp/Signal/Discord/Slack
|
||||
- File-based configuration
|
||||
- Multi-account support
|
||||
- Desktop/mobile apps
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
cd secure
|
||||
npm install
|
||||
|
||||
# Dev mode
|
||||
TELEGRAM_BOT_TOKEN=xxx \
|
||||
ANTHROPIC_API_KEY=xxx \
|
||||
ALLOWED_USERS=123456789 \
|
||||
npm run dev
|
||||
|
||||
# Production
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/health` | Health check (JSON) |
|
||||
| `/ready` | Readiness probe |
|
||||
| `/hooks/*` | Webhook receiver (POST, auth required) |
|
||||
|
||||
## Webhook Usage
|
||||
|
||||
```bash
|
||||
# Send a webhook
|
||||
curl -X POST https://your-app.up.railway.app/hooks/github \
|
||||
-H "Authorization: Bearer YOUR_WEBHOOK_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action": "opened", "pull_request": {"title": "Fix bug"}}'
|
||||
```
|
||||
|
||||
All webhooks are:
|
||||
1. Authenticated (token required)
|
||||
2. Summarized by AI
|
||||
3. Forwarded to all allowed Telegram users
|
||||
|
||||
## Audit Log Format
|
||||
|
||||
```jsonl
|
||||
{"ts":"2024-01-15T10:30:00Z","type":"message","userId":123,"text":"Hello","response":"Hi!"}
|
||||
{"ts":"2024-01-15T10:30:05Z","type":"webhook","path":"/hooks/github","status":200}
|
||||
{"ts":"2024-01-15T10:30:10Z","type":"sandbox","command":"[python] print(1)","exitCode":0}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ AssureBot │────▶│ Sandbox │
|
||||
│ (main container) │ │ (Docker/Piston) │
|
||||
│ │ │ │
|
||||
│ • Telegram bot │ │ • Code execution │
|
||||
│ • Webhook recv │ │ • 15+ languages │
|
||||
│ • Scheduler │ │ • Isolated │
|
||||
│ • Personality │ │ • No network │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
│
|
||||
├────▶ [PostgreSQL] - Tasks, profiles
|
||||
├────▶ [Redis] - Conversations, cache
|
||||
│
|
||||
▼
|
||||
[Anthropic/OpenAI/OpenRouter]
|
||||
(Direct API calls)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - Same as Moltbot.
|
||||
|
||||
---
|
||||
|
||||
**Full Moltbot**: [github.com/moltbot/moltbot](https://github.com/moltbot/moltbot)
|
||||
391
secure/agent.ts
Normal file
391
secure/agent.ts
Normal file
@ -0,0 +1,391 @@
|
||||
/**
|
||||
* AssureBot - Agent Core
|
||||
*
|
||||
* Minimal AI agent that handles conversations with image support.
|
||||
* Direct API calls to Anthropic or OpenAI - no intermediaries.
|
||||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import type { SecureConfig } from "./config.js";
|
||||
import type { AuditLogger } from "./audit.js";
|
||||
|
||||
export type ImageContent = {
|
||||
type: "image";
|
||||
data: string; // base64
|
||||
mediaType: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
|
||||
};
|
||||
|
||||
export type TextContent = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type MessageContent = string | (TextContent | ImageContent)[];
|
||||
|
||||
export type Message = {
|
||||
role: "user" | "assistant";
|
||||
content: MessageContent;
|
||||
};
|
||||
|
||||
export type AgentResponse = {
|
||||
text: string;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCore = {
|
||||
chat: (messages: Message[], systemPrompt?: string) => Promise<AgentResponse>;
|
||||
analyzeImage: (imageData: string, mediaType: ImageContent["mediaType"], prompt?: string) => Promise<AgentResponse>;
|
||||
provider: "anthropic" | "openai" | "openrouter";
|
||||
};
|
||||
|
||||
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-4o";
|
||||
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-3.5-sonnet";
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are AssureBot, a helpful AI assistant running as a secure Telegram bot.
|
||||
|
||||
You are direct, concise, and helpful. You can:
|
||||
- Answer questions and have conversations
|
||||
- Analyze images and documents shared with you
|
||||
- Help with coding and technical tasks
|
||||
- Summarize content and extract information
|
||||
|
||||
## Available Commands (tell users about these when relevant)
|
||||
- /js <code> - Run JavaScript
|
||||
- /python <code> - Run Python
|
||||
- /ts <code> - Run TypeScript
|
||||
- /bash <code> - Run shell commands
|
||||
- /run <lang> <code> - Run code in any language (python, js, ts, bash, rust, go, c, cpp, java, ruby, php)
|
||||
- /status - Check bot status
|
||||
- /clear - Clear conversation history
|
||||
|
||||
When users ask to run or test code, guide them to use the appropriate command.
|
||||
Example: "Use /js console.log('hello')" or "Try /python print('hello')"
|
||||
|
||||
Be security-conscious:
|
||||
- Never reveal API keys, tokens, or secrets
|
||||
- Don't execute commands that could harm the system
|
||||
- Warn users about potentially dangerous operations`;
|
||||
|
||||
function createAnthropicAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||
const client = new Anthropic({
|
||||
apiKey: config.ai.apiKey,
|
||||
});
|
||||
|
||||
const model = config.ai.model || DEFAULT_ANTHROPIC_MODEL;
|
||||
|
||||
function convertContent(content: MessageContent): Anthropic.MessageParam["content"] {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return { type: "text" as const, text: part.text };
|
||||
}
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: part.mediaType,
|
||||
data: part.data,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "anthropic",
|
||||
|
||||
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt || DEFAULT_SYSTEM_PROMPT,
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: convertContent(m.content),
|
||||
})),
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
.filter((block): block is Anthropic.TextBlock => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: response.usage.input_tokens,
|
||||
outputTokens: response.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
audit.error({
|
||||
error: `Anthropic API error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeImage(
|
||||
imageData: string,
|
||||
mediaType: ImageContent["mediaType"],
|
||||
prompt = "What's in this image? Describe it in detail."
|
||||
): Promise<AgentResponse> {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image", data: imageData, mediaType },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
];
|
||||
return this.chat(messages);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenAIAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||
const client = new OpenAI({
|
||||
apiKey: config.ai.apiKey,
|
||||
});
|
||||
|
||||
const model = config.ai.model || DEFAULT_OPENAI_MODEL;
|
||||
|
||||
type OpenAIContent = OpenAI.ChatCompletionContentPart[];
|
||||
|
||||
function convertContent(content: MessageContent): string | OpenAIContent {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return { type: "text" as const, text: part.text };
|
||||
}
|
||||
return {
|
||||
type: "image_url" as const,
|
||||
image_url: {
|
||||
url: `data:${part.mediaType};base64,${part.data}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "openai",
|
||||
|
||||
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||
try {
|
||||
const openaiMessages: OpenAI.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT },
|
||||
];
|
||||
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
openaiMessages.push({
|
||||
role: "user",
|
||||
content: convertContent(m.content),
|
||||
});
|
||||
} else {
|
||||
// Assistant messages are always text
|
||||
openaiMessages.push({
|
||||
role: "assistant",
|
||||
content: typeof m.content === "string" ? m.content : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
messages: openaiMessages,
|
||||
});
|
||||
|
||||
const text = response.choices[0]?.message?.content || "";
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: response.usage
|
||||
? {
|
||||
inputTokens: response.usage.prompt_tokens,
|
||||
outputTokens: response.usage.completion_tokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
audit.error({
|
||||
error: `OpenAI API error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeImage(
|
||||
imageData: string,
|
||||
mediaType: ImageContent["mediaType"],
|
||||
prompt = "What's in this image? Describe it in detail."
|
||||
): Promise<AgentResponse> {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image", data: imageData, mediaType },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
];
|
||||
return this.chat(messages);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenRouterAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||
// OpenRouter uses OpenAI-compatible API
|
||||
const client = new OpenAI({
|
||||
apiKey: config.ai.apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://github.com/TNovs1/moltbot",
|
||||
"X-Title": "AssureBot",
|
||||
},
|
||||
});
|
||||
|
||||
const model = config.ai.model || DEFAULT_OPENROUTER_MODEL;
|
||||
|
||||
type OpenAIContent = OpenAI.ChatCompletionContentPart[];
|
||||
|
||||
function convertContent(content: MessageContent): string | OpenAIContent {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return { type: "text" as const, text: part.text };
|
||||
}
|
||||
return {
|
||||
type: "image_url" as const,
|
||||
image_url: {
|
||||
url: `data:${part.mediaType};base64,${part.data}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "openrouter",
|
||||
|
||||
async chat(messages: Message[], systemPrompt?: string): Promise<AgentResponse> {
|
||||
try {
|
||||
const openaiMessages: OpenAI.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT },
|
||||
];
|
||||
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
openaiMessages.push({
|
||||
role: "user",
|
||||
content: convertContent(m.content),
|
||||
});
|
||||
} else {
|
||||
openaiMessages.push({
|
||||
role: "assistant",
|
||||
content: typeof m.content === "string" ? m.content : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
messages: openaiMessages,
|
||||
});
|
||||
|
||||
const text = response.choices[0]?.message?.content || "";
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: response.usage
|
||||
? {
|
||||
inputTokens: response.usage.prompt_tokens,
|
||||
outputTokens: response.usage.completion_tokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
audit.error({
|
||||
error: `OpenRouter API error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeImage(
|
||||
imageData: string,
|
||||
mediaType: ImageContent["mediaType"],
|
||||
prompt = "What's in this image? Describe it in detail."
|
||||
): Promise<AgentResponse> {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image", data: imageData, mediaType },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
];
|
||||
return this.chat(messages);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgent(config: SecureConfig, audit: AuditLogger): AgentCore {
|
||||
if (config.ai.provider === "anthropic") {
|
||||
return createAnthropicAgent(config, audit);
|
||||
}
|
||||
if (config.ai.provider === "openrouter") {
|
||||
return createOpenRouterAgent(config, audit);
|
||||
}
|
||||
return createOpenAIAgent(config, audit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory conversation store
|
||||
* For Railway, consider using Redis or persistent storage
|
||||
*/
|
||||
export type ConversationStore = {
|
||||
get: (userId: number) => Message[];
|
||||
add: (userId: number, message: Message) => void;
|
||||
clear: (userId: number) => void;
|
||||
};
|
||||
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
export function createConversationStore(): ConversationStore {
|
||||
const conversations = new Map<number, Message[]>();
|
||||
|
||||
return {
|
||||
get(userId: number): Message[] {
|
||||
return conversations.get(userId) || [];
|
||||
},
|
||||
|
||||
add(userId: number, message: Message): void {
|
||||
const history = conversations.get(userId) || [];
|
||||
history.push(message);
|
||||
// Keep only last N messages
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.splice(0, history.length - MAX_HISTORY);
|
||||
}
|
||||
conversations.set(userId, history);
|
||||
},
|
||||
|
||||
clear(userId: number): void {
|
||||
conversations.delete(userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
260
secure/audit.ts
Normal file
260
secure/audit.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* AssureBot - Audit Logger
|
||||
*
|
||||
* Every interaction is logged for transparency and debugging.
|
||||
* Logs are append-only JSONL format.
|
||||
*/
|
||||
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export type AuditEventType =
|
||||
| "startup"
|
||||
| "shutdown"
|
||||
| "message"
|
||||
| "message_blocked"
|
||||
| "webhook"
|
||||
| "webhook_blocked"
|
||||
| "sandbox"
|
||||
| "cron"
|
||||
| "error";
|
||||
|
||||
export type AuditEvent = {
|
||||
ts: string;
|
||||
type: AuditEventType;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
text?: string;
|
||||
response?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
command?: string;
|
||||
exitCode?: number;
|
||||
jobId?: string;
|
||||
jobName?: string;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuditLogger = {
|
||||
log: (event: Omit<AuditEvent, "ts">) => void;
|
||||
startup: () => void;
|
||||
shutdown: () => void;
|
||||
message: (params: {
|
||||
userId: number;
|
||||
username?: string;
|
||||
text: string;
|
||||
response?: string;
|
||||
durationMs?: number;
|
||||
}) => void;
|
||||
messageBlocked: (params: {
|
||||
userId: number;
|
||||
username?: string;
|
||||
reason: string;
|
||||
}) => void;
|
||||
webhook: (params: {
|
||||
path: string;
|
||||
status: number;
|
||||
durationMs?: number;
|
||||
}) => void;
|
||||
webhookBlocked: (params: {
|
||||
path: string;
|
||||
reason: string;
|
||||
}) => void;
|
||||
sandbox: (params: {
|
||||
command: string;
|
||||
exitCode: number;
|
||||
durationMs?: number;
|
||||
}) => void;
|
||||
cron: (params: {
|
||||
jobId: string;
|
||||
jobName: string;
|
||||
status: "ok" | "error" | "skipped";
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
}) => void;
|
||||
error: (params: {
|
||||
error: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Redact sensitive patterns from text
|
||||
*/
|
||||
function redact(text: string): string {
|
||||
// Redact common secret patterns
|
||||
return text
|
||||
// API keys
|
||||
.replace(/sk-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]")
|
||||
.replace(/sk-ant-[a-zA-Z0-9-]{20,}/g, "[REDACTED_ANTHROPIC_KEY]")
|
||||
// Tokens
|
||||
.replace(/\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b/g, "[REDACTED_TG_TOKEN]")
|
||||
// Bearer tokens
|
||||
.replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, "Bearer [REDACTED]")
|
||||
// Passwords in URLs
|
||||
.replace(/:\/\/[^:]+:[^@]+@/g, "://[REDACTED]@")
|
||||
// Generic secrets
|
||||
.replace(/(['"]?(?:password|secret|token|key|apikey|api_key)['"]?\s*[=:]\s*)['"][^'"]+['"]/gi, "$1[REDACTED]");
|
||||
}
|
||||
|
||||
export function createAuditLogger(opts: {
|
||||
enabled: boolean;
|
||||
logPath: string;
|
||||
}): AuditLogger {
|
||||
const { enabled, logPath } = opts;
|
||||
|
||||
// Ensure log directory exists
|
||||
if (enabled) {
|
||||
try {
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
}
|
||||
}
|
||||
|
||||
function write(event: AuditEvent): void {
|
||||
if (!enabled) return;
|
||||
|
||||
// Redact sensitive data
|
||||
const redacted: AuditEvent = {
|
||||
...event,
|
||||
text: event.text ? redact(event.text) : undefined,
|
||||
response: event.response ? redact(event.response) : undefined,
|
||||
command: event.command ? redact(event.command) : undefined,
|
||||
error: event.error ? redact(event.error) : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const line = JSON.stringify(redacted) + "\n";
|
||||
appendFileSync(logPath, line, { encoding: "utf-8" });
|
||||
} catch (err) {
|
||||
// Log to stderr as fallback
|
||||
console.error("[audit] Failed to write audit log:", err);
|
||||
console.error("[audit]", JSON.stringify(redacted));
|
||||
}
|
||||
}
|
||||
|
||||
const logger: AuditLogger = {
|
||||
log: (event) => {
|
||||
write({ ...event, ts: new Date().toISOString() });
|
||||
},
|
||||
|
||||
startup: () => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "startup",
|
||||
metadata: {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
shutdown: () => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "shutdown",
|
||||
});
|
||||
},
|
||||
|
||||
message: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "message",
|
||||
userId: params.userId,
|
||||
username: params.username,
|
||||
text: params.text,
|
||||
response: params.response,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
},
|
||||
|
||||
messageBlocked: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "message_blocked",
|
||||
userId: params.userId,
|
||||
username: params.username,
|
||||
error: params.reason,
|
||||
});
|
||||
},
|
||||
|
||||
webhook: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "webhook",
|
||||
path: params.path,
|
||||
status: params.status,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
},
|
||||
|
||||
webhookBlocked: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "webhook_blocked",
|
||||
path: params.path,
|
||||
error: params.reason,
|
||||
});
|
||||
},
|
||||
|
||||
sandbox: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "sandbox",
|
||||
command: params.command,
|
||||
exitCode: params.exitCode,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
},
|
||||
|
||||
cron: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "cron",
|
||||
jobId: params.jobId,
|
||||
jobName: params.jobName,
|
||||
status: params.status === "ok" ? 200 : params.status === "skipped" ? 204 : 500,
|
||||
error: params.error,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
},
|
||||
|
||||
error: (params) => {
|
||||
write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "error",
|
||||
error: params.error,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console logger for development/debugging
|
||||
*/
|
||||
export function createConsoleAuditLogger(): AuditLogger {
|
||||
const log = (event: Omit<AuditEvent, "ts">) => {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[audit] ${ts} ${event.type}`, JSON.stringify(event, null, 2));
|
||||
};
|
||||
|
||||
return {
|
||||
log,
|
||||
startup: () => log({ type: "startup" }),
|
||||
shutdown: () => log({ type: "shutdown" }),
|
||||
message: (p) => log({ type: "message", ...p }),
|
||||
messageBlocked: (p) => log({ type: "message_blocked", userId: p.userId, username: p.username, error: p.reason }),
|
||||
webhook: (p) => log({ type: "webhook", ...p }),
|
||||
webhookBlocked: (p) => log({ type: "webhook_blocked", path: p.path, error: p.reason }),
|
||||
sandbox: (p) => log({ type: "sandbox", ...p }),
|
||||
cron: (p) => log({ type: "cron", jobId: p.jobId, jobName: p.jobName, status: p.status === "ok" ? 200 : 500, error: p.error, durationMs: p.durationMs }),
|
||||
error: (p) => log({ type: "error", ...p }),
|
||||
};
|
||||
}
|
||||
253
secure/config.ts
Normal file
253
secure/config.ts
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* AssureBot - Environment-only Configuration
|
||||
*
|
||||
* All configuration via environment variables.
|
||||
* No config files, no filesystem secrets.
|
||||
*/
|
||||
|
||||
export type SecureConfig = {
|
||||
// Telegram
|
||||
telegram: {
|
||||
botToken: string;
|
||||
allowedUsers: number[];
|
||||
};
|
||||
|
||||
// AI Provider
|
||||
ai: {
|
||||
provider: "anthropic" | "openai" | "openrouter";
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
// Webhooks
|
||||
webhooks: {
|
||||
enabled: boolean;
|
||||
secret: string;
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
// Sandbox
|
||||
sandbox: {
|
||||
enabled: boolean;
|
||||
image: string;
|
||||
network: "none" | "bridge";
|
||||
memory: string;
|
||||
cpus: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
// Scheduler
|
||||
scheduler: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
// Audit
|
||||
audit: {
|
||||
enabled: boolean;
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
// Server
|
||||
server: {
|
||||
port: number;
|
||||
host: string;
|
||||
gatewayToken: string;
|
||||
};
|
||||
|
||||
// Storage (optional)
|
||||
storage: {
|
||||
postgresUrl?: string;
|
||||
redisUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(name: string, defaultValue: string): string {
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
function optionalBool(name: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[name];
|
||||
if (!value) return defaultValue;
|
||||
return value.toLowerCase() === "true" || value === "1";
|
||||
}
|
||||
|
||||
function optionalInt(name: string, defaultValue: number): number {
|
||||
const value = process.env[name];
|
||||
if (!value) return defaultValue;
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
function parseAllowedUsers(value: string): number[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
}
|
||||
|
||||
function detectAiProvider(): { provider: "anthropic" | "openai" | "openrouter"; apiKey: string } {
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const openaiKey = process.env.OPENAI_API_KEY;
|
||||
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (anthropicKey) {
|
||||
return { provider: "anthropic", apiKey: anthropicKey };
|
||||
}
|
||||
if (openaiKey) {
|
||||
return { provider: "openai", apiKey: openaiKey };
|
||||
}
|
||||
if (openrouterKey) {
|
||||
return { provider: "openrouter", apiKey: openrouterKey };
|
||||
}
|
||||
|
||||
throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY");
|
||||
}
|
||||
|
||||
function generateSecureToken(): string {
|
||||
// Generate a secure random token if not provided
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
const randomValues = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomValues);
|
||||
for (const byte of randomValues) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function loadSecureConfig(): SecureConfig {
|
||||
// Required: Telegram
|
||||
const botToken = required("TELEGRAM_BOT_TOKEN");
|
||||
const allowedUsersRaw = required("ALLOWED_USERS");
|
||||
const allowedUsers = parseAllowedUsers(allowedUsersRaw);
|
||||
|
||||
if (allowedUsers.length === 0) {
|
||||
throw new Error("ALLOWED_USERS must contain at least one valid Telegram user ID");
|
||||
}
|
||||
|
||||
// Required: AI Provider
|
||||
const { provider, apiKey } = detectAiProvider();
|
||||
|
||||
// Optional: Webhooks
|
||||
const webhooksEnabled = optionalBool("WEBHOOKS_ENABLED", true);
|
||||
const webhookSecret = optional("WEBHOOK_SECRET", generateSecureToken());
|
||||
|
||||
// Optional: Sandbox (enabled by default - auto-detects Docker or Piston API fallback)
|
||||
const sandboxEnabled = optionalBool("SANDBOX_ENABLED", true);
|
||||
|
||||
// Optional: Scheduler
|
||||
const schedulerEnabled = optionalBool("SCHEDULER_ENABLED", true);
|
||||
|
||||
// Optional: Audit
|
||||
const auditEnabled = optionalBool("AUDIT_ENABLED", true);
|
||||
|
||||
// Optional: Server
|
||||
const port = optionalInt("PORT", 8080);
|
||||
|
||||
return {
|
||||
telegram: {
|
||||
botToken,
|
||||
allowedUsers,
|
||||
},
|
||||
ai: {
|
||||
provider,
|
||||
apiKey,
|
||||
model: process.env.AI_MODEL,
|
||||
},
|
||||
webhooks: {
|
||||
enabled: webhooksEnabled,
|
||||
secret: webhookSecret,
|
||||
basePath: optional("WEBHOOK_BASE_PATH", "/hooks"),
|
||||
},
|
||||
sandbox: {
|
||||
enabled: sandboxEnabled,
|
||||
image: optional("SANDBOX_IMAGE", "node:22-slim"),
|
||||
network: (optional("SANDBOX_NETWORK", "none") as "none" | "bridge"),
|
||||
memory: optional("SANDBOX_MEMORY", "512m"),
|
||||
cpus: optional("SANDBOX_CPUS", "1"),
|
||||
timeoutMs: optionalInt("SANDBOX_TIMEOUT_MS", 60000),
|
||||
},
|
||||
scheduler: {
|
||||
enabled: schedulerEnabled,
|
||||
},
|
||||
audit: {
|
||||
enabled: auditEnabled,
|
||||
logPath: optional("AUDIT_LOG_PATH", "/data/audit.jsonl"),
|
||||
},
|
||||
server: {
|
||||
port,
|
||||
host: optional("HOST", "0.0.0.0"),
|
||||
gatewayToken: optional("ASSUREBOT_GATEWAY_TOKEN", generateSecureToken()),
|
||||
},
|
||||
storage: {
|
||||
postgresUrl: process.env.DATABASE_URL || process.env.POSTGRES_URL,
|
||||
redisUrl: process.env.REDIS_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config at startup and log warnings
|
||||
*/
|
||||
export function validateConfig(config: SecureConfig): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for weak security settings
|
||||
if (config.sandbox.enabled && config.sandbox.network === "bridge") {
|
||||
warnings.push("SECURITY: Sandbox network is 'bridge' - containers can access network");
|
||||
}
|
||||
|
||||
if (config.telegram.allowedUsers.length > 10) {
|
||||
warnings.push(`Large allowlist (${config.telegram.allowedUsers.length} users) - review if intentional`);
|
||||
}
|
||||
|
||||
if (!config.audit.enabled) {
|
||||
warnings.push("SECURITY: Audit logging is disabled - no interaction records will be kept");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive values for logging
|
||||
*/
|
||||
export function redactConfig(config: SecureConfig): Record<string, unknown> {
|
||||
return {
|
||||
telegram: {
|
||||
botToken: config.telegram.botToken.slice(0, 8) + "...",
|
||||
allowedUsers: config.telegram.allowedUsers,
|
||||
},
|
||||
ai: {
|
||||
provider: config.ai.provider,
|
||||
apiKey: config.ai.apiKey.slice(0, 8) + "...",
|
||||
model: config.ai.model,
|
||||
},
|
||||
webhooks: {
|
||||
enabled: config.webhooks.enabled,
|
||||
secret: "[REDACTED]",
|
||||
basePath: config.webhooks.basePath,
|
||||
},
|
||||
sandbox: config.sandbox,
|
||||
scheduler: config.scheduler,
|
||||
audit: config.audit,
|
||||
server: {
|
||||
port: config.server.port,
|
||||
host: config.server.host,
|
||||
gatewayToken: "[REDACTED]",
|
||||
},
|
||||
storage: {
|
||||
postgresUrl: config.storage.postgresUrl ? "[CONFIGURED]" : undefined,
|
||||
redisUrl: config.storage.redisUrl ? "[CONFIGURED]" : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
120
secure/documents.ts
Normal file
120
secure/documents.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* AssureBot - Document Analysis
|
||||
*
|
||||
* Extract text from various document formats for AI analysis.
|
||||
*/
|
||||
|
||||
export type DocumentResult = {
|
||||
text: string;
|
||||
pageCount?: number;
|
||||
format: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
const MAX_TEXT_LENGTH = 50000; // ~12k tokens
|
||||
|
||||
/**
|
||||
* Extract text from a buffer based on mime type
|
||||
*/
|
||||
export async function extractText(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
filename?: string
|
||||
): Promise<DocumentResult> {
|
||||
const ext = filename?.split(".").pop()?.toLowerCase();
|
||||
|
||||
// Plain text files
|
||||
if (
|
||||
mimeType.startsWith("text/") ||
|
||||
ext === "txt" ||
|
||||
ext === "md" ||
|
||||
ext === "json" ||
|
||||
ext === "xml" ||
|
||||
ext === "csv" ||
|
||||
ext === "log"
|
||||
) {
|
||||
return extractPlainText(buffer);
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (mimeType === "application/pdf" || ext === "pdf") {
|
||||
return extractPdf(buffer);
|
||||
}
|
||||
|
||||
// Code files (treat as text)
|
||||
const codeExtensions = [
|
||||
"js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "java",
|
||||
"c", "cpp", "h", "hpp", "cs", "php", "swift", "kt", "scala",
|
||||
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "env",
|
||||
"sql", "graphql", "html", "css", "scss", "less"
|
||||
];
|
||||
if (ext && codeExtensions.includes(ext)) {
|
||||
return extractPlainText(buffer, ext);
|
||||
}
|
||||
|
||||
// Unsupported format
|
||||
return {
|
||||
text: `[Unsupported document format: ${mimeType}${ext ? ` (.${ext})` : ""}]`,
|
||||
format: "unsupported",
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text
|
||||
*/
|
||||
function extractPlainText(buffer: Buffer, format = "text"): DocumentResult {
|
||||
let text = buffer.toString("utf-8");
|
||||
let truncated = false;
|
||||
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
text = text.slice(0, MAX_TEXT_LENGTH) + "\n\n[... truncated ...]";
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return { text, format, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from PDF using pdf-parse
|
||||
*/
|
||||
async function extractPdf(buffer: Buffer): Promise<DocumentResult> {
|
||||
try {
|
||||
// Dynamic import to avoid bundling issues
|
||||
const pdfParse = await import("pdf-parse").then(m => m.default);
|
||||
const data = await pdfParse(buffer);
|
||||
|
||||
let text = data.text;
|
||||
let truncated = false;
|
||||
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
text = text.slice(0, MAX_TEXT_LENGTH) + "\n\n[... truncated ...]";
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
pageCount: data.numpages,
|
||||
format: "pdf",
|
||||
truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
text: `[Failed to parse PDF: ${msg}]`,
|
||||
format: "pdf-error",
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize document metadata for logging
|
||||
*/
|
||||
export function summarizeDocument(result: DocumentResult): string {
|
||||
const parts = [result.format.toUpperCase()];
|
||||
if (result.pageCount) parts.push(`${result.pageCount} pages`);
|
||||
parts.push(`${result.text.length} chars`);
|
||||
if (result.truncated) parts.push("truncated");
|
||||
return parts.join(", ");
|
||||
}
|
||||
223
secure/index.ts
Normal file
223
secure/index.ts
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* AssureBot - Entry Point
|
||||
*
|
||||
* Lean, secure, self-hosted AI assistant for Railway.
|
||||
*
|
||||
* Usage:
|
||||
* TELEGRAM_BOT_TOKEN=xxx ANTHROPIC_API_KEY=xxx ALLOWED_USERS=123 npx tsx secure/index.ts
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { loadSecureConfig, validateConfig, redactConfig } from "./config.js";
|
||||
import { createAuditLogger } from "./audit.js";
|
||||
import { createAgent, createConversationStore } from "./agent.js";
|
||||
import { createTelegramBot } from "./telegram.js";
|
||||
import { createWebhookHandler } from "./webhooks.js";
|
||||
import { createSandboxRunner } from "./sandbox.js";
|
||||
import { createScheduler } from "./scheduler.js";
|
||||
import { createStorage, type Storage } from "./storage.js";
|
||||
import { createPersonality } from "./personality.js";
|
||||
|
||||
async function main() {
|
||||
console.log("=".repeat(50));
|
||||
console.log(" ASSUREBOT");
|
||||
console.log(" Lean, secure, self-hosted AI assistant");
|
||||
console.log("=".repeat(50));
|
||||
console.log();
|
||||
|
||||
// Load configuration
|
||||
console.log("[init] Loading configuration...");
|
||||
const config = loadSecureConfig();
|
||||
|
||||
// Validate and warn
|
||||
const warnings = validateConfig(config);
|
||||
if (warnings.length > 0) {
|
||||
console.log("[init] Configuration warnings:");
|
||||
for (const w of warnings) {
|
||||
console.log(` - ${w}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log redacted config
|
||||
console.log("[init] Configuration loaded:");
|
||||
console.log(JSON.stringify(redactConfig(config), null, 2));
|
||||
console.log();
|
||||
|
||||
// Create audit logger
|
||||
console.log("[init] Creating audit logger...");
|
||||
const audit = createAuditLogger({
|
||||
enabled: config.audit.enabled,
|
||||
logPath: config.audit.logPath,
|
||||
});
|
||||
audit.startup();
|
||||
|
||||
// Create storage (PostgreSQL + Redis)
|
||||
console.log("[init] Creating storage layer...");
|
||||
const storage = await createStorage({
|
||||
postgres: config.storage.postgresUrl ? { url: config.storage.postgresUrl } : undefined,
|
||||
redis: config.storage.redisUrl ? { url: config.storage.redisUrl } : undefined,
|
||||
});
|
||||
const storageHealthy = await storage.isHealthy();
|
||||
console.log(`[init] Storage healthy: ${storageHealthy}`);
|
||||
|
||||
// Create AI agent
|
||||
console.log(`[init] Creating AI agent (${config.ai.provider})...`);
|
||||
const agent = createAgent(config, audit);
|
||||
|
||||
// Create conversation store
|
||||
const conversations = createConversationStore();
|
||||
|
||||
// Create sandbox runner
|
||||
console.log("[init] Creating sandbox runner...");
|
||||
const sandbox = createSandboxRunner(config, audit);
|
||||
const sandboxAvailable = await sandbox.isAvailable();
|
||||
console.log(`[init] Sandbox available: ${sandboxAvailable}`);
|
||||
|
||||
// Create a placeholder bot for circular deps
|
||||
// We'll create telegram, scheduler, and webhooks together
|
||||
const { Bot } = await import("grammy");
|
||||
const bot = new Bot(config.telegram.botToken);
|
||||
|
||||
// Create scheduler (needs bot for notifications, storage for persistence)
|
||||
console.log("[init] Creating scheduler...");
|
||||
const scheduler = createScheduler({
|
||||
config,
|
||||
audit,
|
||||
agent,
|
||||
telegramBot: bot,
|
||||
storage,
|
||||
});
|
||||
|
||||
// Create personality engine (learning + personalization)
|
||||
console.log("[init] Creating personality engine...");
|
||||
const personality = await createPersonality(storage);
|
||||
|
||||
// Create Telegram bot handler (with sandbox, scheduler, personality)
|
||||
console.log("[init] Creating Telegram bot...");
|
||||
const telegram = createTelegramBot({
|
||||
config,
|
||||
audit,
|
||||
agent,
|
||||
conversations,
|
||||
sandbox,
|
||||
scheduler,
|
||||
personality,
|
||||
});
|
||||
|
||||
// Create webhook handler
|
||||
console.log("[init] Creating webhook handler...");
|
||||
const webhooks = createWebhookHandler({
|
||||
config,
|
||||
audit,
|
||||
agent,
|
||||
telegramBot: telegram.bot,
|
||||
});
|
||||
|
||||
// Create HTTP server
|
||||
console.log("[init] Creating HTTP server...");
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||
|
||||
// Health check
|
||||
if (url.pathname === "/health" || url.pathname === "/healthz") {
|
||||
const isStorageHealthy = await storage.isHealthy();
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
telegram: "connected",
|
||||
sandbox: sandboxAvailable ? "available" : "unavailable",
|
||||
storage: isStorageHealthy ? "healthy" : "degraded",
|
||||
postgres: config.storage.postgresUrl ? "configured" : "none",
|
||||
redis: config.storage.redisUrl ? "configured" : "none",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Readiness check
|
||||
if (url.pathname === "/ready") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
// Webhook handler
|
||||
if (await webhooks.handleRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 for everything else
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not Found");
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
let isShuttingDown = false;
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log(`\n[shutdown] Received ${signal}, shutting down...`);
|
||||
|
||||
audit.shutdown();
|
||||
|
||||
try {
|
||||
scheduler.stop();
|
||||
await telegram.stop();
|
||||
await storage.close();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.log("[shutdown] Shutdown complete");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("[shutdown] Error during shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
|
||||
// Start everything
|
||||
console.log("[start] Starting services...");
|
||||
|
||||
// Start HTTP server
|
||||
server.listen(config.server.port, config.server.host, () => {
|
||||
console.log(`[start] HTTP server listening on ${config.server.host}:${config.server.port}`);
|
||||
});
|
||||
|
||||
// Start scheduler (loads tasks from storage)
|
||||
await scheduler.start();
|
||||
|
||||
// Start Telegram bot (polling mode for simplicity)
|
||||
await telegram.start();
|
||||
|
||||
console.log();
|
||||
console.log("=".repeat(50));
|
||||
console.log(" ASSUREBOT IS RUNNING");
|
||||
console.log();
|
||||
console.log(` Telegram: Polling mode`);
|
||||
console.log(` Webhooks: http://localhost:${config.server.port}${config.webhooks.basePath}/*`);
|
||||
console.log(` Health: http://localhost:${config.server.port}/health`);
|
||||
console.log(` Storage: ${config.storage.postgresUrl ? "PostgreSQL" : "memory"}${config.storage.redisUrl ? " + Redis" : ""}`);
|
||||
console.log(` Allowed: ${config.telegram.allowedUsers.length} users`);
|
||||
console.log();
|
||||
console.log(" Press Ctrl+C to stop");
|
||||
console.log("=".repeat(50));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
30
secure/package.json
Normal file
30
secure/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "assurebot",
|
||||
"version": "1.0.0",
|
||||
"description": "AssureBot - Lean, secure, self-hosted AI assistant for Railway",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"cron": "^3.1.7",
|
||||
"grammy": "^1.21.1",
|
||||
"openai": "^4.77.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.10.9",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
10
secure/pdf-parse.d.ts
vendored
Normal file
10
secure/pdf-parse.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
declare module "pdf-parse" {
|
||||
function pdfParse(dataBuffer: Buffer): Promise<{
|
||||
numpages: number;
|
||||
numrender: number;
|
||||
info: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
text: string;
|
||||
}>;
|
||||
export default pdfParse;
|
||||
}
|
||||
265
secure/personality.ts
Normal file
265
secure/personality.ts
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* AssureBot - Personality Engine
|
||||
*
|
||||
* Persistent, evolving AI personality that learns from conversations.
|
||||
* - Stores traits and preferences in Redis (fast access)
|
||||
* - Syncs to PostgreSQL (durability)
|
||||
* - Learns user preferences, tone, and topics over time
|
||||
*/
|
||||
|
||||
import type { Storage, UserProfile, PersonalityTraits } from "./storage.js";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { UserProfile, PersonalityTraits };
|
||||
|
||||
export type Personality = {
|
||||
getSystemPrompt: (userId: number) => Promise<string>;
|
||||
getUserProfile: (userId: number) => Promise<UserProfile>;
|
||||
updateUserProfile: (userId: number, updates: Partial<UserProfile>) => Promise<void>;
|
||||
learnFromConversation: (userId: number, userMessage: string, botResponse: string) => Promise<void>;
|
||||
getTraits: () => Promise<PersonalityTraits>;
|
||||
updateTraits: (updates: Partial<PersonalityTraits>) => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_TRAITS: PersonalityTraits = {
|
||||
name: "AssureBot",
|
||||
greeting: "Hey",
|
||||
signOff: "",
|
||||
humor: "subtle",
|
||||
verbosity: "balanced",
|
||||
commonPhrases: [],
|
||||
avoidPhrases: [],
|
||||
expertiseAreas: ["coding", "analysis", "automation"],
|
||||
lastUpdated: new Date(),
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const DEFAULT_USER_PROFILE: Omit<UserProfile, "userId"> = {
|
||||
preferredTone: "friendly",
|
||||
interests: [],
|
||||
recentTopics: [],
|
||||
interactionCount: 0,
|
||||
lastSeen: new Date(),
|
||||
notes: [],
|
||||
};
|
||||
|
||||
export async function createPersonality(storage: Storage): Promise<Personality> {
|
||||
// Load or initialize traits from storage
|
||||
let traits: PersonalityTraits = await storage.getPersonalityTraits() ?? { ...DEFAULT_TRAITS };
|
||||
|
||||
// Save default traits if none exist
|
||||
if (!(await storage.getPersonalityTraits())) {
|
||||
await storage.savePersonalityTraits(traits);
|
||||
console.log("[personality] Initialized default traits");
|
||||
}
|
||||
|
||||
// In-memory cache for hot profiles (reduces Redis calls during conversation)
|
||||
const profileCache = new Map<number, UserProfile>();
|
||||
|
||||
async function loadUserProfile(userId: number): Promise<UserProfile> {
|
||||
// Check in-memory cache first
|
||||
if (profileCache.has(userId)) {
|
||||
return profileCache.get(userId)!;
|
||||
}
|
||||
|
||||
// Try loading from storage (Redis -> PostgreSQL -> memory)
|
||||
const stored = await storage.getUserProfile(userId);
|
||||
|
||||
if (stored) {
|
||||
profileCache.set(userId, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Create new profile for this user
|
||||
const profile: UserProfile = {
|
||||
userId,
|
||||
...DEFAULT_USER_PROFILE,
|
||||
lastSeen: new Date(),
|
||||
};
|
||||
|
||||
// Persist new profile
|
||||
await storage.saveUserProfile(profile);
|
||||
profileCache.set(userId, profile);
|
||||
console.log(`[personality] Created new profile for user ${userId}`);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function saveUserProfile(profile: UserProfile): Promise<void> {
|
||||
// Update cache
|
||||
profileCache.set(profile.userId, profile);
|
||||
// Persist to storage (Redis + PostgreSQL)
|
||||
await storage.saveUserProfile(profile);
|
||||
}
|
||||
|
||||
return {
|
||||
async getSystemPrompt(userId: number): Promise<string> {
|
||||
const profile = await loadUserProfile(userId);
|
||||
|
||||
let prompt = `You are ${traits.name}, a helpful AI assistant running as a Telegram bot.
|
||||
|
||||
## Personality
|
||||
- Tone: ${profile.preferredTone}
|
||||
- Verbosity: ${traits.verbosity}
|
||||
- Humor: ${traits.humor === "none" ? "Stay professional" : traits.humor === "subtle" ? "Occasional light humor is fine" : "Be playful and fun"}
|
||||
|
||||
## Your Expertise
|
||||
${traits.expertiseAreas.map(e => `- ${e}`).join("\n")}
|
||||
|
||||
## About This User
|
||||
- Interactions: ${profile.interactionCount}
|
||||
- Interests: ${profile.interests.length > 0 ? profile.interests.join(", ") : "Not yet known"}
|
||||
- Recent topics: ${profile.recentTopics.length > 0 ? profile.recentTopics.slice(-3).join(", ") : "None yet"}
|
||||
${profile.notes.length > 0 ? `- Notes: ${profile.notes.slice(-3).join("; ")}` : ""}
|
||||
|
||||
## Available Commands (you can tell users about these)
|
||||
- /js <code> - Run JavaScript code
|
||||
- /python <code> or /py <code> - Run Python code
|
||||
- /ts <code> - Run TypeScript code
|
||||
- /bash <code> or /sh <code> - Run shell commands
|
||||
- /run <language> <code> - Run code in any supported language (python, javascript, typescript, bash, rust, go, c, cpp, java, ruby, php)
|
||||
- /status - Check bot and sandbox status
|
||||
- /clear - Clear conversation history
|
||||
- /schedule "<cron>" "<name>" <prompt> - Schedule recurring AI tasks
|
||||
- /tasks - List scheduled tasks
|
||||
- /deltask <id> - Delete a task
|
||||
|
||||
When a user asks to run code, you can either:
|
||||
1. Tell them to use the appropriate command (e.g., "Use /js console.log('hello')")
|
||||
2. Just answer their question directly if they don't need to execute code
|
||||
|
||||
## Guidelines
|
||||
- Be helpful, accurate, and security-conscious
|
||||
- Never reveal API keys, tokens, or secrets
|
||||
- Adapt to the user's communication style
|
||||
- Remember context from this conversation
|
||||
- When users want to run code, guide them to use the right command
|
||||
${traits.commonPhrases.length > 0 ? `- Phrases you like: ${traits.commonPhrases.join(", ")}` : ""}
|
||||
${traits.avoidPhrases.length > 0 ? `- Avoid saying: ${traits.avoidPhrases.join(", ")}` : ""}`;
|
||||
|
||||
return prompt;
|
||||
},
|
||||
|
||||
async getUserProfile(userId: number): Promise<UserProfile> {
|
||||
return loadUserProfile(userId);
|
||||
},
|
||||
|
||||
async updateUserProfile(userId: number, updates: Partial<UserProfile>): Promise<void> {
|
||||
const profile = await loadUserProfile(userId);
|
||||
Object.assign(profile, updates);
|
||||
await saveUserProfile(profile);
|
||||
},
|
||||
|
||||
async learnFromConversation(
|
||||
userId: number,
|
||||
userMessage: string,
|
||||
botResponse: string
|
||||
): Promise<void> {
|
||||
const profile = await loadUserProfile(userId);
|
||||
|
||||
// Update interaction count
|
||||
profile.interactionCount++;
|
||||
profile.lastSeen = new Date();
|
||||
|
||||
// Extract topics (simple keyword extraction)
|
||||
const topics = extractTopics(userMessage);
|
||||
if (topics.length > 0) {
|
||||
// Add to recent topics, keep last 10
|
||||
profile.recentTopics = [...profile.recentTopics, ...topics].slice(-10);
|
||||
|
||||
// Add unique topics to interests
|
||||
for (const topic of topics) {
|
||||
if (!profile.interests.includes(topic)) {
|
||||
profile.interests.push(topic);
|
||||
// Keep interests manageable
|
||||
if (profile.interests.length > 20) {
|
||||
profile.interests = profile.interests.slice(-20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect user preferences from message style
|
||||
if (userMessage.length < 50 && !userMessage.includes("?")) {
|
||||
// User prefers concise communication
|
||||
profile.preferredTone = "concise";
|
||||
} else if (userMessage.includes("please") || userMessage.includes("thank")) {
|
||||
profile.preferredTone = "friendly";
|
||||
}
|
||||
|
||||
await saveUserProfile(profile);
|
||||
},
|
||||
|
||||
async getTraits(): Promise<PersonalityTraits> {
|
||||
return { ...traits };
|
||||
},
|
||||
|
||||
async updateTraits(updates: Partial<PersonalityTraits>): Promise<void> {
|
||||
traits = {
|
||||
...traits,
|
||||
...updates,
|
||||
lastUpdated: new Date(),
|
||||
version: traits.version + 1,
|
||||
};
|
||||
// Persist to storage
|
||||
await storage.savePersonalityTraits(traits);
|
||||
console.log(`[personality] Updated traits (v${traits.version})`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple topic extraction from text
|
||||
*/
|
||||
function extractTopics(text: string): string[] {
|
||||
const topics: string[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Tech topics
|
||||
const techKeywords = [
|
||||
"python", "javascript", "typescript", "rust", "go", "java",
|
||||
"docker", "kubernetes", "aws", "api", "database", "sql",
|
||||
"react", "vue", "node", "linux", "git", "ci/cd",
|
||||
"machine learning", "ai", "llm", "chatgpt", "claude",
|
||||
];
|
||||
|
||||
for (const keyword of techKeywords) {
|
||||
if (lowerText.includes(keyword)) {
|
||||
topics.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// Task types
|
||||
if (lowerText.includes("debug") || lowerText.includes("fix") || lowerText.includes("error")) {
|
||||
topics.push("debugging");
|
||||
}
|
||||
if (lowerText.includes("write") || lowerText.includes("create") || lowerText.includes("build")) {
|
||||
topics.push("development");
|
||||
}
|
||||
if (lowerText.includes("explain") || lowerText.includes("how does") || lowerText.includes("what is")) {
|
||||
topics.push("learning");
|
||||
}
|
||||
|
||||
return topics.slice(0, 3); // Max 3 topics per message
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a personalized greeting
|
||||
*/
|
||||
export function generateGreeting(traits: PersonalityTraits, profile: UserProfile): string {
|
||||
const greetings = {
|
||||
casual: ["Hey!", "Hi there!", "What's up?"],
|
||||
professional: ["Hello.", "Good day.", "Greetings."],
|
||||
friendly: ["Hey there! 👋", "Hi! Good to see you!", "Hello friend!"],
|
||||
concise: ["Hi.", "Hey.", ""],
|
||||
};
|
||||
|
||||
const options = greetings[profile.preferredTone];
|
||||
const greeting = options[Math.floor(Math.random() * options.length)];
|
||||
|
||||
if (profile.interactionCount > 10 && profile.name) {
|
||||
return `${greeting} ${profile.name}!`;
|
||||
}
|
||||
|
||||
return greeting;
|
||||
}
|
||||
14
secure/railway.json
Normal file
14
secure/railway.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "node dist/index.js",
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 60,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 3
|
||||
}
|
||||
}
|
||||
10
secure/railway.toml
Normal file
10
secure/railway.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[build]
|
||||
builder = "dockerfile"
|
||||
dockerfilePath = "Dockerfile"
|
||||
|
||||
[deploy]
|
||||
startCommand = "node dist/index.js"
|
||||
healthcheckPath = "/health"
|
||||
healthcheckTimeout = 30
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
restartPolicyMaxRetries = 3
|
||||
482
secure/sandbox.ts
Normal file
482
secure/sandbox.ts
Normal file
@ -0,0 +1,482 @@
|
||||
/**
|
||||
* AssureBot - Sandbox Execution
|
||||
*
|
||||
* Isolated code execution with multiple backends:
|
||||
* 1. Docker (local) - if Docker socket available
|
||||
* 2. Piston API (cloud) - free code execution API fallback
|
||||
*
|
||||
* Security-first: no network, read-only root, resource limits.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import type { SecureConfig } from "./config.js";
|
||||
import type { AuditLogger } from "./audit.js";
|
||||
|
||||
export type SandboxResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type SandboxRunner = {
|
||||
run: (command: string, stdin?: string) => Promise<SandboxResult>;
|
||||
runCode: (language: string, code: string) => Promise<SandboxResult>;
|
||||
isAvailable: () => Promise<boolean>;
|
||||
backend: "docker" | "piston" | "none";
|
||||
};
|
||||
|
||||
// Piston API - free cloud-based code execution
|
||||
const PISTON_API = "https://emkc.org/api/v2/piston";
|
||||
|
||||
// Supported languages for Piston
|
||||
const PISTON_LANGUAGES: Record<string, { language: string; version: string }> = {
|
||||
python: { language: "python", version: "3.10" },
|
||||
python3: { language: "python", version: "3.10" },
|
||||
py: { language: "python", version: "3.10" },
|
||||
javascript: { language: "javascript", version: "18.15.0" },
|
||||
js: { language: "javascript", version: "18.15.0" },
|
||||
node: { language: "javascript", version: "18.15.0" },
|
||||
typescript: { language: "typescript", version: "5.0.3" },
|
||||
ts: { language: "typescript", version: "5.0.3" },
|
||||
bash: { language: "bash", version: "5.2.0" },
|
||||
sh: { language: "bash", version: "5.2.0" },
|
||||
shell: { language: "bash", version: "5.2.0" },
|
||||
rust: { language: "rust", version: "1.68.2" },
|
||||
go: { language: "go", version: "1.16.2" },
|
||||
c: { language: "c", version: "10.2.0" },
|
||||
cpp: { language: "c++", version: "10.2.0" },
|
||||
java: { language: "java", version: "15.0.2" },
|
||||
ruby: { language: "ruby", version: "3.0.1" },
|
||||
php: { language: "php", version: "8.2.3" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if Docker is available
|
||||
*/
|
||||
async function checkDocker(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("docker", ["version"], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
proc.on("error", () => resolve(false));
|
||||
proc.on("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Piston API is available
|
||||
*/
|
||||
async function checkPiston(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${PISTON_API}/runtimes`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute code via Piston API
|
||||
*/
|
||||
async function runPiston(
|
||||
language: string,
|
||||
code: string,
|
||||
timeoutMs: number
|
||||
): Promise<SandboxResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const langConfig = PISTON_LANGUAGES[language.toLowerCase()];
|
||||
if (!langConfig) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: `Unsupported language: ${language}\n\nSupported: ${Object.keys(PISTON_LANGUAGES).join(", ")}`,
|
||||
timedOut: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${PISTON_API}/execute`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language: langConfig.language,
|
||||
version: langConfig.version,
|
||||
files: [{ content: code }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: `Piston API error: ${response.status} ${text}`,
|
||||
timedOut: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json() as {
|
||||
run: { stdout: string; stderr: string; code: number; signal: string | null };
|
||||
compile?: { stdout: string; stderr: string; code: number };
|
||||
};
|
||||
|
||||
// Check for compilation errors
|
||||
if (result.compile && result.compile.code !== 0) {
|
||||
return {
|
||||
exitCode: result.compile.code,
|
||||
stdout: result.compile.stdout || "",
|
||||
stderr: result.compile.stderr || "Compilation failed",
|
||||
timedOut: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: result.run.code,
|
||||
stdout: (result.run.stdout || "").slice(0, 10000),
|
||||
stderr: (result.run.stderr || "").slice(0, 10000),
|
||||
timedOut: result.run.signal === "SIGKILL",
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} catch (err) {
|
||||
const isTimeout = err instanceof Error && err.name === "TimeoutError";
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: isTimeout ? "Execution timed out" : `Error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
timedOut: isTimeout,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Docker run arguments for secure execution
|
||||
*/
|
||||
function buildDockerArgs(config: SecureConfig["sandbox"], command: string): string[] {
|
||||
const args: string[] = [
|
||||
"run",
|
||||
"--rm", // Remove container after exit
|
||||
"-i", // Interactive (for stdin)
|
||||
|
||||
// Security: No network by default
|
||||
`--network=${config.network}`,
|
||||
|
||||
// Security: Read-only root filesystem
|
||||
"--read-only",
|
||||
|
||||
// Security: tmpfs for writable areas
|
||||
"--tmpfs=/tmp:rw,noexec,nosuid,size=64m",
|
||||
"--tmpfs=/var/tmp:rw,noexec,nosuid,size=64m",
|
||||
|
||||
// Security: Drop all capabilities
|
||||
"--cap-drop=ALL",
|
||||
|
||||
// Security: No new privileges
|
||||
"--security-opt=no-new-privileges",
|
||||
|
||||
// Resource limits
|
||||
`--memory=${config.memory}`,
|
||||
`--cpus=${config.cpus}`,
|
||||
"--pids-limit=100",
|
||||
|
||||
// Timeout handled externally, but set a ulimit too
|
||||
"--ulimit=cpu=60:60",
|
||||
|
||||
// Working directory
|
||||
"--workdir=/workspace",
|
||||
|
||||
// Image
|
||||
config.image,
|
||||
|
||||
// Command (via shell for flexibility)
|
||||
"sh",
|
||||
"-c",
|
||||
command,
|
||||
];
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command via Docker
|
||||
*/
|
||||
async function runDocker(
|
||||
config: SecureConfig["sandbox"],
|
||||
command: string,
|
||||
stdin?: string
|
||||
): Promise<SandboxResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = buildDockerArgs(config, command);
|
||||
|
||||
const proc = spawn("docker", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let resolved = false;
|
||||
|
||||
const finish = (exitCode: number) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: stdout.slice(0, 10000), // Limit output size
|
||||
stderr: stderr.slice(0, 10000),
|
||||
timedOut,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
};
|
||||
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill("SIGKILL");
|
||||
}, config.timeoutMs);
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
// Prevent memory exhaustion
|
||||
if (stdout.length > 100000) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
if (stderr.length > 100000) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
stderr += `\nProcess error: ${err.message}`;
|
||||
finish(1);
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
finish(code ?? 1);
|
||||
});
|
||||
|
||||
// Write stdin if provided
|
||||
if (stdin && proc.stdin) {
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
} else {
|
||||
proc.stdin?.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createSandboxRunner(config: SecureConfig, audit: AuditLogger): SandboxRunner {
|
||||
const sandboxConfig = config.sandbox;
|
||||
|
||||
// Detect available backend at creation time
|
||||
let detectedBackend: "docker" | "piston" | "none" = "none";
|
||||
let backendChecked = false;
|
||||
|
||||
async function detectBackend(): Promise<"docker" | "piston" | "none"> {
|
||||
if (backendChecked) return detectedBackend;
|
||||
|
||||
if (!sandboxConfig.enabled) {
|
||||
detectedBackend = "none";
|
||||
backendChecked = true;
|
||||
return detectedBackend;
|
||||
}
|
||||
|
||||
// Try Docker first
|
||||
if (await checkDocker()) {
|
||||
detectedBackend = "docker";
|
||||
console.log("[sandbox] Using Docker backend");
|
||||
} else if (await checkPiston()) {
|
||||
// Fall back to Piston API
|
||||
detectedBackend = "piston";
|
||||
console.log("[sandbox] Using Piston API backend (Docker not available)");
|
||||
} else {
|
||||
detectedBackend = "none";
|
||||
console.log("[sandbox] No sandbox backend available");
|
||||
}
|
||||
|
||||
backendChecked = true;
|
||||
return detectedBackend;
|
||||
}
|
||||
|
||||
// Start detection immediately
|
||||
void detectBackend();
|
||||
|
||||
return {
|
||||
get backend() {
|
||||
return detectedBackend;
|
||||
},
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const backend = await detectBackend();
|
||||
return backend !== "none";
|
||||
},
|
||||
|
||||
async run(command: string, stdin?: string): Promise<SandboxResult> {
|
||||
const backend = await detectBackend();
|
||||
const startTime = Date.now();
|
||||
|
||||
if (backend === "none") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "Sandbox is disabled or no backend available",
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let result: SandboxResult;
|
||||
|
||||
if (backend === "docker") {
|
||||
result = await runDocker(sandboxConfig, command, stdin);
|
||||
} else {
|
||||
// Piston: run as bash
|
||||
result = await runPiston("bash", command, sandboxConfig.timeoutMs);
|
||||
}
|
||||
|
||||
audit.sandbox({
|
||||
command,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async runCode(language: string, code: string): Promise<SandboxResult> {
|
||||
const backend = await detectBackend();
|
||||
|
||||
if (backend === "none") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "Sandbox is disabled or no backend available",
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let result: SandboxResult;
|
||||
|
||||
if (backend === "piston") {
|
||||
// Use Piston directly for language support
|
||||
result = await runPiston(language, code, sandboxConfig.timeoutMs);
|
||||
} else {
|
||||
// Docker: build command for the language
|
||||
const command = buildCommand(language, code);
|
||||
result = await runDocker(sandboxConfig, command);
|
||||
}
|
||||
|
||||
audit.sandbox({
|
||||
command: `[${language}] ${code.slice(0, 100)}...`,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sandbox command from user message
|
||||
* Returns null if message doesn't request code execution
|
||||
*/
|
||||
export function parseSandboxRequest(text: string): {
|
||||
language: string;
|
||||
code: string;
|
||||
} | null {
|
||||
// Match code blocks with language
|
||||
const codeBlockMatch = text.match(/```(\w+)?\n([\s\S]*?)```/);
|
||||
if (codeBlockMatch) {
|
||||
const language = codeBlockMatch[1] || "sh";
|
||||
const code = codeBlockMatch[2].trim();
|
||||
return { language, code };
|
||||
}
|
||||
|
||||
// Match /run command
|
||||
const runMatch = text.match(/^\/run\s+(.+)$/s);
|
||||
if (runMatch) {
|
||||
return { language: "sh", code: runMatch[1].trim() };
|
||||
}
|
||||
|
||||
// Match /python command
|
||||
const pythonMatch = text.match(/^\/python\s+(.+)$/s);
|
||||
if (pythonMatch) {
|
||||
return { language: "python", code: pythonMatch[1].trim() };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build execution command for language (Docker only)
|
||||
*/
|
||||
export function buildCommand(language: string, code: string): string {
|
||||
switch (language.toLowerCase()) {
|
||||
case "python":
|
||||
case "py":
|
||||
return `python3 -c ${JSON.stringify(code)}`;
|
||||
|
||||
case "javascript":
|
||||
case "js":
|
||||
case "node":
|
||||
return `node -e ${JSON.stringify(code)}`;
|
||||
|
||||
case "bash":
|
||||
case "sh":
|
||||
case "shell":
|
||||
return code;
|
||||
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sandbox result for display
|
||||
*/
|
||||
export function formatSandboxResult(result: SandboxResult): string {
|
||||
let output = "";
|
||||
|
||||
if (result.timedOut) {
|
||||
output += "**Timed out**\n\n";
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
output += "**Output:**\n```\n" + result.stdout.trim() + "\n```\n";
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
output += "**Errors:**\n```\n" + result.stderr.trim() + "\n```\n";
|
||||
}
|
||||
|
||||
if (!result.stdout && !result.stderr) {
|
||||
output += result.exitCode === 0 ? "Command completed (no output)" : "Command failed (no output)";
|
||||
}
|
||||
|
||||
output += `\n_Exit code: ${result.exitCode}, Duration: ${result.durationMs}ms_`;
|
||||
|
||||
return output;
|
||||
}
|
||||
300
secure/scheduler.ts
Normal file
300
secure/scheduler.ts
Normal file
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Moltbot Secure - Task Scheduler
|
||||
*
|
||||
* Simple cron-like scheduler for recurring tasks.
|
||||
* Stores jobs in memory or optionally persists to file.
|
||||
*/
|
||||
|
||||
import { CronJob } from "cron";
|
||||
import type { SecureConfig } from "./config.js";
|
||||
import type { AuditLogger } from "./audit.js";
|
||||
import type { AgentCore } from "./agent.js";
|
||||
import type { Bot } from "grammy";
|
||||
import type { Storage } from "./storage.js";
|
||||
import { sendToUser } from "./telegram.js";
|
||||
|
||||
export type ScheduledTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
schedule: string; // Cron expression
|
||||
prompt: string; // What to ask the AI
|
||||
enabled: boolean;
|
||||
lastRun?: Date;
|
||||
lastStatus?: "ok" | "error";
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
export type Scheduler = {
|
||||
addTask: (task: Omit<ScheduledTask, "id">) => string;
|
||||
removeTask: (id: string) => boolean;
|
||||
enableTask: (id: string, enabled: boolean) => boolean;
|
||||
listTasks: () => ScheduledTask[];
|
||||
runTask: (id: string) => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export type SchedulerDeps = {
|
||||
config: SecureConfig;
|
||||
audit: AuditLogger;
|
||||
agent: AgentCore;
|
||||
telegramBot: Bot;
|
||||
storage?: Storage;
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
export function createScheduler(deps: SchedulerDeps): Scheduler {
|
||||
const { config, audit, agent, telegramBot, storage } = deps;
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
const cronJobs = new Map<string, CronJob<null, unknown>>();
|
||||
|
||||
async function executeTask(task: ScheduledTask): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Run the AI with the task prompt
|
||||
const response = await agent.chat([
|
||||
{ role: "user", content: task.prompt },
|
||||
]);
|
||||
|
||||
// Notify users
|
||||
const message = `**Scheduled Task: ${task.name}**\n\n${response.text}`;
|
||||
for (const userId of config.telegram.allowedUsers) {
|
||||
await sendToUser(telegramBot, userId, message);
|
||||
}
|
||||
|
||||
task.lastRun = new Date();
|
||||
task.lastStatus = "ok";
|
||||
task.lastError = undefined;
|
||||
|
||||
// Save updated task status
|
||||
if (storage) {
|
||||
void storage.saveTask(task);
|
||||
}
|
||||
|
||||
audit.cron({
|
||||
jobId: task.id,
|
||||
jobName: task.name,
|
||||
status: "ok",
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
task.lastRun = new Date();
|
||||
task.lastStatus = "error";
|
||||
task.lastError = errorMsg;
|
||||
|
||||
// Save updated task status
|
||||
if (storage) {
|
||||
void storage.saveTask(task);
|
||||
}
|
||||
|
||||
audit.cron({
|
||||
jobId: task.id,
|
||||
jobName: task.name,
|
||||
status: "error",
|
||||
error: errorMsg,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
// Notify about error
|
||||
const message = `**Scheduled Task Failed: ${task.name}**\n\nError: ${errorMsg}`;
|
||||
for (const userId of config.telegram.allowedUsers) {
|
||||
await sendToUser(telegramBot, userId, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTask(task: ScheduledTask): void {
|
||||
// Remove existing job if any
|
||||
const existing = cronJobs.get(task.id);
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
cronJobs.delete(task.id);
|
||||
}
|
||||
|
||||
if (!task.enabled || !config.scheduler.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const job = new CronJob(
|
||||
task.schedule,
|
||||
() => {
|
||||
void executeTask(task);
|
||||
},
|
||||
null,
|
||||
true, // Start immediately
|
||||
undefined, // Default timezone
|
||||
undefined,
|
||||
false // Don't run on init
|
||||
);
|
||||
cronJobs.set(task.id, job);
|
||||
} catch (err) {
|
||||
console.error(`[scheduler] Failed to schedule task ${task.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addTask(taskInput: Omit<ScheduledTask, "id">): string {
|
||||
const id = generateId();
|
||||
const task: ScheduledTask = { ...taskInput, id };
|
||||
tasks.set(id, task);
|
||||
scheduleTask(task);
|
||||
// Persist to storage
|
||||
if (storage) {
|
||||
void storage.saveTask(task);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
removeTask(id: string): boolean {
|
||||
const task = tasks.get(id);
|
||||
if (!task) return false;
|
||||
|
||||
const job = cronJobs.get(id);
|
||||
if (job) {
|
||||
job.stop();
|
||||
cronJobs.delete(id);
|
||||
}
|
||||
|
||||
tasks.delete(id);
|
||||
// Remove from storage
|
||||
if (storage) {
|
||||
void storage.deleteTask(id);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
enableTask(id: string, enabled: boolean): boolean {
|
||||
const task = tasks.get(id);
|
||||
if (!task) return false;
|
||||
|
||||
task.enabled = enabled;
|
||||
scheduleTask(task);
|
||||
return true;
|
||||
},
|
||||
|
||||
listTasks(): ScheduledTask[] {
|
||||
return Array.from(tasks.values());
|
||||
},
|
||||
|
||||
async runTask(id: string): Promise<void> {
|
||||
const task = tasks.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
await executeTask(task);
|
||||
},
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!config.scheduler.enabled) {
|
||||
console.log("[scheduler] Scheduler is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[scheduler] Starting scheduler...");
|
||||
|
||||
// Load tasks from storage
|
||||
if (storage) {
|
||||
const storedTasks = await storage.getAllTasks();
|
||||
for (const task of storedTasks) {
|
||||
tasks.set(task.id, task);
|
||||
}
|
||||
console.log(`[scheduler] Loaded ${storedTasks.length} tasks from storage`);
|
||||
}
|
||||
|
||||
for (const task of tasks.values()) {
|
||||
scheduleTask(task);
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
console.log("[scheduler] Stopping scheduler...");
|
||||
for (const job of cronJobs.values()) {
|
||||
job.stop();
|
||||
}
|
||||
cronJobs.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse schedule from human-readable format
|
||||
*/
|
||||
export function parseSchedule(input: string): string | null {
|
||||
const lower = input.toLowerCase().trim();
|
||||
|
||||
// Common patterns
|
||||
const patterns: Record<string, string> = {
|
||||
"every minute": "* * * * *",
|
||||
"every 5 minutes": "*/5 * * * *",
|
||||
"every 15 minutes": "*/15 * * * *",
|
||||
"every 30 minutes": "*/30 * * * *",
|
||||
"every hour": "0 * * * *",
|
||||
hourly: "0 * * * *",
|
||||
"every day": "0 9 * * *",
|
||||
daily: "0 9 * * *",
|
||||
"every morning": "0 9 * * *",
|
||||
"every evening": "0 18 * * *",
|
||||
"every week": "0 9 * * 1",
|
||||
weekly: "0 9 * * 1",
|
||||
"every monday": "0 9 * * 1",
|
||||
"every tuesday": "0 9 * * 2",
|
||||
"every wednesday": "0 9 * * 3",
|
||||
"every thursday": "0 9 * * 4",
|
||||
"every friday": "0 9 * * 5",
|
||||
"every saturday": "0 9 * * 6",
|
||||
"every sunday": "0 9 * * 0",
|
||||
};
|
||||
|
||||
if (patterns[lower]) {
|
||||
return patterns[lower];
|
||||
}
|
||||
|
||||
// Check if it's already a valid cron expression (5 or 6 fields)
|
||||
const parts = input.trim().split(/\s+/);
|
||||
if (parts.length >= 5 && parts.length <= 6) {
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format next run time
|
||||
*/
|
||||
export function formatNextRun(cronExpression: string): string {
|
||||
try {
|
||||
const job = new CronJob(cronExpression, () => {});
|
||||
const nextDate = job.nextDate();
|
||||
return nextDate.toLocaleString();
|
||||
} catch {
|
||||
return "Invalid schedule";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in task templates
|
||||
*/
|
||||
export const taskTemplates = {
|
||||
morningBriefing: {
|
||||
name: "Morning Briefing",
|
||||
schedule: "0 9 * * *", // 9 AM daily
|
||||
prompt: "Give me a brief morning update. Include: current date, a motivational quote, and remind me to check my priorities for the day.",
|
||||
},
|
||||
weeklyReview: {
|
||||
name: "Weekly Review",
|
||||
schedule: "0 17 * * 5", // 5 PM on Fridays
|
||||
prompt: "It's Friday. Help me reflect on the week. What should I consider for my weekly review?",
|
||||
},
|
||||
healthReminder: {
|
||||
name: "Health Reminder",
|
||||
schedule: "0 */2 * * *", // Every 2 hours
|
||||
prompt: "Give me a brief health reminder (stretch, drink water, take a break). Keep it under 2 sentences.",
|
||||
},
|
||||
};
|
||||
584
secure/storage.ts
Normal file
584
secure/storage.ts
Normal file
@ -0,0 +1,584 @@
|
||||
/**
|
||||
* AssureBot - Storage Layer
|
||||
*
|
||||
* PostgreSQL for persistent data (tasks, profiles, traits)
|
||||
* Redis for caching and sessions
|
||||
*/
|
||||
|
||||
import type { ScheduledTask } from "./scheduler.js";
|
||||
|
||||
export type StorageConfig = {
|
||||
postgres?: {
|
||||
url: string;
|
||||
};
|
||||
redis?: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Storage = {
|
||||
// Tasks
|
||||
saveTask: (task: ScheduledTask) => Promise<void>;
|
||||
getTask: (id: string) => Promise<ScheduledTask | null>;
|
||||
getAllTasks: () => Promise<ScheduledTask[]>;
|
||||
deleteTask: (id: string) => Promise<boolean>;
|
||||
|
||||
// Conversations (Redis cache)
|
||||
getConversation: (userId: number) => Promise<ConversationMessage[]>;
|
||||
saveConversation: (userId: number, messages: ConversationMessage[]) => Promise<void>;
|
||||
clearConversation: (userId: number) => Promise<void>;
|
||||
|
||||
// Personality (Redis + PostgreSQL)
|
||||
getUserProfile: (userId: number) => Promise<UserProfile | null>;
|
||||
saveUserProfile: (profile: UserProfile) => Promise<void>;
|
||||
getPersonalityTraits: () => Promise<PersonalityTraits | null>;
|
||||
savePersonalityTraits: (traits: PersonalityTraits) => Promise<void>;
|
||||
|
||||
// Health
|
||||
isHealthy: () => Promise<boolean>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ConversationMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type UserProfile = {
|
||||
userId: number;
|
||||
name?: string;
|
||||
timezone?: string;
|
||||
preferredTone: "casual" | "professional" | "friendly" | "concise";
|
||||
interests: string[];
|
||||
recentTopics: string[];
|
||||
interactionCount: number;
|
||||
lastSeen: Date;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type PersonalityTraits = {
|
||||
name: string;
|
||||
greeting: string;
|
||||
signOff: string;
|
||||
humor: "none" | "subtle" | "playful";
|
||||
verbosity: "concise" | "balanced" | "detailed";
|
||||
commonPhrases: string[];
|
||||
avoidPhrases: string[];
|
||||
expertiseAreas: string[];
|
||||
lastUpdated: Date;
|
||||
version: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory storage (fallback when no DB configured)
|
||||
*/
|
||||
function createMemoryStorage(): Storage {
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
const conversations = new Map<number, ConversationMessage[]>();
|
||||
const userProfiles = new Map<number, UserProfile>();
|
||||
let personalityTraits: PersonalityTraits | null = null;
|
||||
|
||||
return {
|
||||
async saveTask(task) {
|
||||
tasks.set(task.id, task);
|
||||
},
|
||||
async getTask(id) {
|
||||
return tasks.get(id) || null;
|
||||
},
|
||||
async getAllTasks() {
|
||||
return Array.from(tasks.values());
|
||||
},
|
||||
async deleteTask(id) {
|
||||
return tasks.delete(id);
|
||||
},
|
||||
async getConversation(userId) {
|
||||
return conversations.get(userId) || [];
|
||||
},
|
||||
async saveConversation(userId, messages) {
|
||||
conversations.set(userId, messages);
|
||||
},
|
||||
async clearConversation(userId) {
|
||||
conversations.delete(userId);
|
||||
},
|
||||
async getUserProfile(userId) {
|
||||
return userProfiles.get(userId) || null;
|
||||
},
|
||||
async saveUserProfile(profile) {
|
||||
userProfiles.set(profile.userId, profile);
|
||||
},
|
||||
async getPersonalityTraits() {
|
||||
return personalityTraits;
|
||||
},
|
||||
async savePersonalityTraits(traits) {
|
||||
personalityTraits = traits;
|
||||
},
|
||||
async isHealthy() {
|
||||
return true;
|
||||
},
|
||||
async close() {
|
||||
// Nothing to close
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL storage for tasks and personality
|
||||
*/
|
||||
async function createPostgresStorage(url: string): Promise<{
|
||||
saveTask: Storage["saveTask"];
|
||||
getTask: Storage["getTask"];
|
||||
getAllTasks: Storage["getAllTasks"];
|
||||
deleteTask: Storage["deleteTask"];
|
||||
getUserProfile: Storage["getUserProfile"];
|
||||
saveUserProfile: Storage["saveUserProfile"];
|
||||
getPersonalityTraits: Storage["getPersonalityTraits"];
|
||||
savePersonalityTraits: Storage["savePersonalityTraits"];
|
||||
isHealthy: () => Promise<boolean>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const { default: pg } = await import("pg");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
|
||||
// Create tables if not exist
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
schedule TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
last_run TIMESTAMPTZ,
|
||||
last_status TEXT,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// User profiles table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
name TEXT,
|
||||
timezone TEXT,
|
||||
preferred_tone TEXT DEFAULT 'friendly',
|
||||
interests JSONB DEFAULT '[]',
|
||||
recent_topics JSONB DEFAULT '[]',
|
||||
interaction_count INTEGER DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||
notes JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Personality traits table (singleton)
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS personality_traits (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
name TEXT DEFAULT 'AssureBot',
|
||||
greeting TEXT DEFAULT 'Hey',
|
||||
sign_off TEXT DEFAULT '',
|
||||
humor TEXT DEFAULT 'subtle',
|
||||
verbosity TEXT DEFAULT 'balanced',
|
||||
common_phrases JSONB DEFAULT '[]',
|
||||
avoid_phrases JSONB DEFAULT '[]',
|
||||
expertise_areas JSONB DEFAULT '["coding", "analysis", "automation"]',
|
||||
last_updated TIMESTAMPTZ DEFAULT NOW(),
|
||||
version INTEGER DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
console.log("[storage] PostgreSQL connected, tables ready");
|
||||
|
||||
return {
|
||||
async saveTask(task) {
|
||||
await pool.query(
|
||||
`INSERT INTO scheduled_tasks (id, name, schedule, prompt, enabled, last_run, last_status, last_error, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = $2, schedule = $3, prompt = $4, enabled = $5,
|
||||
last_run = $6, last_status = $7, last_error = $8, updated_at = NOW()`,
|
||||
[
|
||||
task.id,
|
||||
task.name,
|
||||
task.schedule,
|
||||
task.prompt,
|
||||
task.enabled,
|
||||
task.lastRun || null,
|
||||
task.lastStatus || null,
|
||||
task.lastError || null,
|
||||
]
|
||||
);
|
||||
},
|
||||
|
||||
async getTask(id) {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM scheduled_tasks WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToTask(result.rows[0]);
|
||||
},
|
||||
|
||||
async getAllTasks() {
|
||||
const result = await pool.query("SELECT * FROM scheduled_tasks ORDER BY created_at");
|
||||
return result.rows.map(rowToTask);
|
||||
},
|
||||
|
||||
async deleteTask(id) {
|
||||
const result = await pool.query(
|
||||
"DELETE FROM scheduled_tasks WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
},
|
||||
|
||||
async getUserProfile(userId) {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM user_profiles WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToUserProfile(result.rows[0]);
|
||||
},
|
||||
|
||||
async saveUserProfile(profile) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_profiles (user_id, name, timezone, preferred_tone, interests, recent_topics, interaction_count, last_seen, notes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
name = $2, timezone = $3, preferred_tone = $4, interests = $5,
|
||||
recent_topics = $6, interaction_count = $7, last_seen = $8, notes = $9, updated_at = NOW()`,
|
||||
[
|
||||
profile.userId,
|
||||
profile.name || null,
|
||||
profile.timezone || null,
|
||||
profile.preferredTone,
|
||||
JSON.stringify(profile.interests),
|
||||
JSON.stringify(profile.recentTopics),
|
||||
profile.interactionCount,
|
||||
profile.lastSeen,
|
||||
JSON.stringify(profile.notes),
|
||||
]
|
||||
);
|
||||
},
|
||||
|
||||
async getPersonalityTraits() {
|
||||
const result = await pool.query("SELECT * FROM personality_traits WHERE id = 1");
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToTraits(result.rows[0]);
|
||||
},
|
||||
|
||||
async savePersonalityTraits(traits) {
|
||||
await pool.query(
|
||||
`INSERT INTO personality_traits (id, name, greeting, sign_off, humor, verbosity, common_phrases, avoid_phrases, expertise_areas, last_updated, version)
|
||||
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = $1, greeting = $2, sign_off = $3, humor = $4, verbosity = $5,
|
||||
common_phrases = $6, avoid_phrases = $7, expertise_areas = $8, last_updated = $9, version = $10`,
|
||||
[
|
||||
traits.name,
|
||||
traits.greeting,
|
||||
traits.signOff,
|
||||
traits.humor,
|
||||
traits.verbosity,
|
||||
JSON.stringify(traits.commonPhrases),
|
||||
JSON.stringify(traits.avoidPhrases),
|
||||
JSON.stringify(traits.expertiseAreas),
|
||||
traits.lastUpdated,
|
||||
traits.version,
|
||||
]
|
||||
);
|
||||
},
|
||||
|
||||
async isHealthy() {
|
||||
try {
|
||||
await pool.query("SELECT 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async close() {
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToTask(row: Record<string, unknown>): ScheduledTask {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
schedule: row.schedule as string,
|
||||
prompt: row.prompt as string,
|
||||
enabled: row.enabled as boolean,
|
||||
lastRun: row.last_run ? new Date(row.last_run as string) : undefined,
|
||||
lastStatus: row.last_status as "ok" | "error" | undefined,
|
||||
lastError: row.last_error as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToUserProfile(row: Record<string, unknown>): UserProfile {
|
||||
return {
|
||||
userId: Number(row.user_id),
|
||||
name: row.name as string | undefined,
|
||||
timezone: row.timezone as string | undefined,
|
||||
preferredTone: row.preferred_tone as UserProfile["preferredTone"],
|
||||
interests: (row.interests as string[]) || [],
|
||||
recentTopics: (row.recent_topics as string[]) || [],
|
||||
interactionCount: row.interaction_count as number,
|
||||
lastSeen: new Date(row.last_seen as string),
|
||||
notes: (row.notes as string[]) || [],
|
||||
};
|
||||
}
|
||||
|
||||
function rowToTraits(row: Record<string, unknown>): PersonalityTraits {
|
||||
return {
|
||||
name: row.name as string,
|
||||
greeting: row.greeting as string,
|
||||
signOff: row.sign_off as string,
|
||||
humor: row.humor as PersonalityTraits["humor"],
|
||||
verbosity: row.verbosity as PersonalityTraits["verbosity"],
|
||||
commonPhrases: (row.common_phrases as string[]) || [],
|
||||
avoidPhrases: (row.avoid_phrases as string[]) || [],
|
||||
expertiseAreas: (row.expertise_areas as string[]) || [],
|
||||
lastUpdated: new Date(row.last_updated as string),
|
||||
version: row.version as number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis storage for conversations/cache and personality caching
|
||||
*/
|
||||
async function createRedisStorage(url: string): Promise<{
|
||||
getConversation: Storage["getConversation"];
|
||||
saveConversation: Storage["saveConversation"];
|
||||
clearConversation: Storage["clearConversation"];
|
||||
getUserProfile: Storage["getUserProfile"];
|
||||
saveUserProfile: Storage["saveUserProfile"];
|
||||
getPersonalityTraits: Storage["getPersonalityTraits"];
|
||||
savePersonalityTraits: Storage["savePersonalityTraits"];
|
||||
isHealthy: () => Promise<boolean>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const { createClient } = await import("redis");
|
||||
const client = createClient({ url });
|
||||
|
||||
client.on("error", (err) => console.error("[redis] Error:", err));
|
||||
await client.connect();
|
||||
|
||||
console.log("[storage] Redis connected");
|
||||
|
||||
const CONVERSATION_TTL = 60 * 60 * 24; // 24 hours
|
||||
const PROFILE_TTL = 60 * 60 * 24 * 7; // 7 days
|
||||
const TRAITS_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||
const MAX_MESSAGES = 50;
|
||||
|
||||
return {
|
||||
async getConversation(userId) {
|
||||
const key = `conv:${userId}`;
|
||||
const data = await client.get(key);
|
||||
if (!data) return [];
|
||||
try {
|
||||
return JSON.parse(data) as ConversationMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async saveConversation(userId, messages) {
|
||||
const key = `conv:${userId}`;
|
||||
// Keep only last N messages
|
||||
const trimmed = messages.slice(-MAX_MESSAGES);
|
||||
await client.setEx(key, CONVERSATION_TTL, JSON.stringify(trimmed));
|
||||
},
|
||||
|
||||
async clearConversation(userId) {
|
||||
const key = `conv:${userId}`;
|
||||
await client.del(key);
|
||||
},
|
||||
|
||||
async getUserProfile(userId) {
|
||||
const key = `profile:${userId}`;
|
||||
const data = await client.get(key);
|
||||
if (!data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
...parsed,
|
||||
lastSeen: new Date(parsed.lastSeen),
|
||||
} as UserProfile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async saveUserProfile(profile) {
|
||||
const key = `profile:${profile.userId}`;
|
||||
await client.setEx(key, PROFILE_TTL, JSON.stringify(profile));
|
||||
},
|
||||
|
||||
async getPersonalityTraits() {
|
||||
const key = "personality:traits";
|
||||
const data = await client.get(key);
|
||||
if (!data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
...parsed,
|
||||
lastUpdated: new Date(parsed.lastUpdated),
|
||||
} as PersonalityTraits;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async savePersonalityTraits(traits) {
|
||||
const key = "personality:traits";
|
||||
await client.setEx(key, TRAITS_TTL, JSON.stringify(traits));
|
||||
},
|
||||
|
||||
async isHealthy() {
|
||||
try {
|
||||
await client.ping();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async close() {
|
||||
await client.quit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create storage based on config
|
||||
* Strategy:
|
||||
* - Redis: fast cache for conversations, profiles, traits
|
||||
* - PostgreSQL: durable backing store for profiles, traits, tasks
|
||||
* - Memory: fallback when neither is available
|
||||
*/
|
||||
export async function createStorage(config: StorageConfig): Promise<Storage> {
|
||||
const memory = createMemoryStorage();
|
||||
|
||||
let pgStorage: Awaited<ReturnType<typeof createPostgresStorage>> | null = null;
|
||||
let redisStorage: Awaited<ReturnType<typeof createRedisStorage>> | null = null;
|
||||
|
||||
// Try PostgreSQL
|
||||
if (config.postgres?.url) {
|
||||
try {
|
||||
pgStorage = await createPostgresStorage(config.postgres.url);
|
||||
} catch (err) {
|
||||
console.error("[storage] PostgreSQL connection failed, using memory:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Redis
|
||||
if (config.redis?.url) {
|
||||
try {
|
||||
redisStorage = await createRedisStorage(config.redis.url);
|
||||
} catch (err) {
|
||||
console.error("[storage] Redis connection failed, using memory:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Create layered personality storage (Redis cache -> PostgreSQL backing -> memory fallback)
|
||||
async function getUserProfile(userId: number): Promise<UserProfile | null> {
|
||||
// Try Redis cache first
|
||||
if (redisStorage) {
|
||||
const cached = await redisStorage.getUserProfile(userId);
|
||||
if (cached) return cached;
|
||||
}
|
||||
// Try PostgreSQL
|
||||
if (pgStorage) {
|
||||
const profile = await pgStorage.getUserProfile(userId);
|
||||
// Cache in Redis if found
|
||||
if (profile && redisStorage) {
|
||||
await redisStorage.saveUserProfile(profile);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
// Fallback to memory
|
||||
return memory.getUserProfile(userId);
|
||||
}
|
||||
|
||||
async function saveUserProfile(profile: UserProfile): Promise<void> {
|
||||
// Save to PostgreSQL (durable)
|
||||
if (pgStorage) {
|
||||
await pgStorage.saveUserProfile(profile);
|
||||
}
|
||||
// Cache in Redis
|
||||
if (redisStorage) {
|
||||
await redisStorage.saveUserProfile(profile);
|
||||
}
|
||||
// Also update memory for consistency
|
||||
await memory.saveUserProfile(profile);
|
||||
}
|
||||
|
||||
async function getPersonalityTraits(): Promise<PersonalityTraits | null> {
|
||||
// Try Redis cache first
|
||||
if (redisStorage) {
|
||||
const cached = await redisStorage.getPersonalityTraits();
|
||||
if (cached) return cached;
|
||||
}
|
||||
// Try PostgreSQL
|
||||
if (pgStorage) {
|
||||
const traits = await pgStorage.getPersonalityTraits();
|
||||
// Cache in Redis if found
|
||||
if (traits && redisStorage) {
|
||||
await redisStorage.savePersonalityTraits(traits);
|
||||
}
|
||||
return traits;
|
||||
}
|
||||
// Fallback to memory
|
||||
return memory.getPersonalityTraits();
|
||||
}
|
||||
|
||||
async function savePersonalityTraits(traits: PersonalityTraits): Promise<void> {
|
||||
// Save to PostgreSQL (durable)
|
||||
if (pgStorage) {
|
||||
await pgStorage.savePersonalityTraits(traits);
|
||||
}
|
||||
// Cache in Redis
|
||||
if (redisStorage) {
|
||||
await redisStorage.savePersonalityTraits(traits);
|
||||
}
|
||||
// Also update memory for consistency
|
||||
await memory.savePersonalityTraits(traits);
|
||||
}
|
||||
|
||||
return {
|
||||
// Tasks: prefer PostgreSQL, fallback to memory
|
||||
saveTask: pgStorage?.saveTask ?? memory.saveTask,
|
||||
getTask: pgStorage?.getTask ?? memory.getTask,
|
||||
getAllTasks: pgStorage?.getAllTasks ?? memory.getAllTasks,
|
||||
deleteTask: pgStorage?.deleteTask ?? memory.deleteTask,
|
||||
|
||||
// Conversations: prefer Redis, fallback to memory
|
||||
getConversation: redisStorage?.getConversation ?? memory.getConversation,
|
||||
saveConversation: redisStorage?.saveConversation ?? memory.saveConversation,
|
||||
clearConversation: redisStorage?.clearConversation ?? memory.clearConversation,
|
||||
|
||||
// Personality: layered (Redis cache -> PostgreSQL -> memory)
|
||||
getUserProfile,
|
||||
saveUserProfile,
|
||||
getPersonalityTraits,
|
||||
savePersonalityTraits,
|
||||
|
||||
async isHealthy() {
|
||||
const pgOk = pgStorage ? await pgStorage.isHealthy() : true;
|
||||
const redisOk = redisStorage ? await redisStorage.isHealthy() : true;
|
||||
return pgOk && redisOk;
|
||||
},
|
||||
|
||||
async close() {
|
||||
await pgStorage?.close();
|
||||
await redisStorage?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
795
secure/telegram.ts
Normal file
795
secure/telegram.ts
Normal file
@ -0,0 +1,795 @@
|
||||
/**
|
||||
* AssureBot - Telegram Channel
|
||||
*
|
||||
* Minimal, secure Telegram bot handler with image analysis.
|
||||
* Allowlist-only: only approved users can interact.
|
||||
*/
|
||||
|
||||
import { Bot, Context } from "grammy";
|
||||
import type { SecureConfig } from "./config.js";
|
||||
import type { AuditLogger } from "./audit.js";
|
||||
import type { AgentCore, ConversationStore, ImageContent } from "./agent.js";
|
||||
import type { SandboxRunner } from "./sandbox.js";
|
||||
import type { Scheduler } from "./scheduler.js";
|
||||
import type { Personality } from "./personality.js";
|
||||
import { extractText, summarizeDocument } from "./documents.js";
|
||||
|
||||
export type TelegramBot = {
|
||||
bot: Bot;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type TelegramDeps = {
|
||||
config: SecureConfig;
|
||||
audit: AuditLogger;
|
||||
agent: AgentCore;
|
||||
conversations: ConversationStore;
|
||||
sandbox?: SandboxRunner;
|
||||
scheduler?: Scheduler;
|
||||
personality?: Personality;
|
||||
onWebhookMessage?: (userId: number, text: string) => void;
|
||||
};
|
||||
|
||||
function isUserAllowed(userId: number, allowedUsers: number[]): boolean {
|
||||
return allowedUsers.includes(userId);
|
||||
}
|
||||
|
||||
function formatUsername(ctx: Context): string {
|
||||
const user = ctx.from;
|
||||
if (!user) return "unknown";
|
||||
if (user.username) return `@${user.username}`;
|
||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
|
||||
return name || `id:${user.id}`;
|
||||
}
|
||||
|
||||
export function createTelegramBot(deps: TelegramDeps): TelegramBot {
|
||||
const { config, audit, agent, conversations, sandbox, scheduler, personality } = deps;
|
||||
const bot = new Bot(config.telegram.botToken);
|
||||
|
||||
// Error handler
|
||||
bot.catch((err) => {
|
||||
audit.error({
|
||||
error: `Telegram bot error: ${err.message}`,
|
||||
metadata: { stack: err.stack },
|
||||
});
|
||||
});
|
||||
|
||||
// Command: /start
|
||||
bot.command("start", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
audit.messageBlocked({
|
||||
userId: userId || 0,
|
||||
username: formatUsername(ctx),
|
||||
reason: "User not in allowlist",
|
||||
});
|
||||
await ctx.reply("Access denied. You are not authorized to use this bot.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
`Welcome to AssureBot.
|
||||
|
||||
You are authorized to use this bot.
|
||||
|
||||
Code Execution:
|
||||
/js <code> - Run JavaScript
|
||||
/python <code> - Run Python
|
||||
/ts <code> - Run TypeScript
|
||||
/bash <code> - Run shell commands
|
||||
/run <lang> <code> - Run any language
|
||||
|
||||
Other Commands:
|
||||
/status - Check bot status
|
||||
/clear - Clear conversation history
|
||||
/schedule - Schedule AI tasks
|
||||
/tasks - List scheduled tasks
|
||||
/help - Show full help
|
||||
|
||||
Features:
|
||||
- Chat with AI
|
||||
- Image analysis (send photos)
|
||||
- Document analysis (send PDFs)
|
||||
- Code execution (15+ languages)`
|
||||
);
|
||||
});
|
||||
|
||||
// Command: /clear
|
||||
bot.command("clear", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversations.clear(userId);
|
||||
await ctx.reply("Conversation history cleared.");
|
||||
});
|
||||
|
||||
// Command: /status
|
||||
bot.command("status", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = conversations.get(userId);
|
||||
const sandboxStatus = sandbox
|
||||
? `${sandbox.backend} (${await sandbox.isAvailable() ? "ready" : "unavailable"})`
|
||||
: "not configured";
|
||||
|
||||
await ctx.reply(
|
||||
`Status:
|
||||
- AI Provider: ${agent.provider}
|
||||
- Conversation: ${history.length} messages
|
||||
- Sandbox: ${sandboxStatus}
|
||||
- Webhooks: ${config.webhooks.enabled ? "enabled" : "disabled"}
|
||||
- Scheduler: ${config.scheduler.enabled ? "enabled" : "disabled"}`
|
||||
);
|
||||
});
|
||||
|
||||
// Command: /help
|
||||
bot.command("help", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
`AssureBot Help
|
||||
|
||||
A secure, self-hosted AI assistant.
|
||||
|
||||
CODE EXECUTION:
|
||||
/js <code> - Run JavaScript
|
||||
/python <code> or /py <code> - Run Python
|
||||
/ts <code> - Run TypeScript
|
||||
/bash <code> or /sh <code> - Run shell
|
||||
/run <lang> <code> - Run any language
|
||||
|
||||
Supported: python, javascript, typescript, bash, rust, go, c, cpp, java, ruby, php
|
||||
|
||||
SCHEDULING:
|
||||
/schedule "<cron>" "<name>" <prompt>
|
||||
/tasks - List tasks
|
||||
/deltask <id> - Delete task
|
||||
|
||||
Example: /schedule "0 9 * * *" "Morning" Good morning!
|
||||
|
||||
OTHER:
|
||||
/status - Bot & sandbox status
|
||||
/clear - Clear conversation
|
||||
/help - This message
|
||||
|
||||
FEATURES:
|
||||
- Chat naturally with AI
|
||||
- Send images for analysis
|
||||
- Send PDFs/docs for analysis
|
||||
- Code runs in isolated sandbox`
|
||||
);
|
||||
});
|
||||
|
||||
// Command: /sandbox <code>
|
||||
bot.command("sandbox", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
const username = formatUsername(ctx);
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sandbox) {
|
||||
await ctx.reply("Sandbox is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.sandbox.enabled) {
|
||||
await ctx.reply("Sandbox is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
const code = ctx.message?.text?.replace(/^\/sandbox\s*/, "").trim() ?? "";
|
||||
if (!code) {
|
||||
await ctx.reply("Usage: /sandbox <code>\n\nExample: /sandbox echo Hello World");
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
try {
|
||||
const result = await sandbox.run(code);
|
||||
const output = result.stdout || result.stderr || "(no output)";
|
||||
const status = result.exitCode === 0 ? "✓" : `✗ (exit ${result.exitCode})`;
|
||||
const timeout = result.timedOut ? " [TIMED OUT]" : "";
|
||||
|
||||
await ctx.reply(
|
||||
`**Sandbox Result** ${status}${timeout}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\`\nDuration: ${result.durationMs}ms`,
|
||||
{ parse_mode: "Markdown" }
|
||||
).catch(async () => {
|
||||
await ctx.reply(`Sandbox Result ${status}${timeout}\n\n${output.slice(0, 3500)}\n\nDuration: ${result.durationMs}ms`);
|
||||
});
|
||||
|
||||
audit.sandbox({
|
||||
command: code,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
audit.error({ error: `Sandbox error: ${errorMsg}`, metadata: { userId, code } });
|
||||
await ctx.reply(`Sandbox error: ${errorMsg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper for language-specific code execution
|
||||
async function runCodeCommand(
|
||||
ctx: Context,
|
||||
language: string,
|
||||
commandName: string
|
||||
): Promise<void> {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sandbox) {
|
||||
await ctx.reply("Sandbox is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isAvailable = await sandbox.isAvailable();
|
||||
if (!isAvailable) {
|
||||
await ctx.reply(`Sandbox unavailable. Backend: ${sandbox.backend}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = ctx.message?.text?.replace(new RegExp(`^/${commandName}\\s*`), "").trim() ?? "";
|
||||
if (!code) {
|
||||
await ctx.reply(`Usage: /${commandName} <code>\n\nExample: /${commandName} console.log("Hello!")`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
try {
|
||||
const result = await sandbox.runCode(language, code);
|
||||
const output = result.stdout || result.stderr || "(no output)";
|
||||
const status = result.exitCode === 0 ? "✓" : `✗ (exit ${result.exitCode})`;
|
||||
const timeout = result.timedOut ? " [TIMED OUT]" : "";
|
||||
const backend = sandbox.backend === "piston" ? " [Piston]" : "";
|
||||
|
||||
await ctx.reply(
|
||||
`**${language}** ${status}${timeout}${backend}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\`\nDuration: ${result.durationMs}ms`,
|
||||
{ parse_mode: "Markdown" }
|
||||
).catch(async () => {
|
||||
await ctx.reply(`${language} ${status}${timeout}${backend}\n\n${output.slice(0, 3500)}\n\nDuration: ${result.durationMs}ms`);
|
||||
});
|
||||
|
||||
audit.sandbox({
|
||||
command: `[${language}] ${code.slice(0, 100)}`,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
audit.error({ error: `Code execution error: ${errorMsg}`, metadata: { userId, language, code } });
|
||||
await ctx.reply(`Error: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Command: /js <code> - Run JavaScript
|
||||
bot.command("js", (ctx) => runCodeCommand(ctx, "javascript", "js"));
|
||||
|
||||
// Command: /python <code> - Run Python
|
||||
bot.command("python", (ctx) => runCodeCommand(ctx, "python", "python"));
|
||||
bot.command("py", (ctx) => runCodeCommand(ctx, "python", "py"));
|
||||
|
||||
// Command: /ts <code> - Run TypeScript
|
||||
bot.command("ts", (ctx) => runCodeCommand(ctx, "typescript", "ts"));
|
||||
|
||||
// Command: /bash <code> - Run Bash
|
||||
bot.command("bash", (ctx) => runCodeCommand(ctx, "bash", "bash"));
|
||||
bot.command("sh", (ctx) => runCodeCommand(ctx, "bash", "sh"));
|
||||
|
||||
// Command: /run <language> <code> - Run code in any supported language
|
||||
bot.command("run", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sandbox) {
|
||||
await ctx.reply("Sandbox is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isAvailable = await sandbox.isAvailable();
|
||||
if (!isAvailable) {
|
||||
await ctx.reply(`Sandbox unavailable. Backend: ${sandbox.backend}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = ctx.message?.text?.replace(/^\/run\s*/, "").trim() ?? "";
|
||||
const match = text.match(/^(\w+)\s+([\s\S]+)$/);
|
||||
if (!match) {
|
||||
await ctx.reply(
|
||||
`Usage: /run <language> <code>
|
||||
|
||||
Supported languages:
|
||||
- javascript, js
|
||||
- typescript, ts
|
||||
- python, py
|
||||
- bash, sh
|
||||
- rust, go, c, cpp, java, ruby, php
|
||||
|
||||
Example: /run python print("Hello!")`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, language, code] = match;
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
try {
|
||||
const result = await sandbox.runCode(language, code);
|
||||
const output = result.stdout || result.stderr || "(no output)";
|
||||
const status = result.exitCode === 0 ? "✓" : `✗ (exit ${result.exitCode})`;
|
||||
const timeout = result.timedOut ? " [TIMED OUT]" : "";
|
||||
const backend = sandbox.backend === "piston" ? " [Piston]" : "";
|
||||
|
||||
await ctx.reply(
|
||||
`**${language}** ${status}${timeout}${backend}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\`\nDuration: ${result.durationMs}ms`,
|
||||
{ parse_mode: "Markdown" }
|
||||
).catch(async () => {
|
||||
await ctx.reply(`${language} ${status}${timeout}${backend}\n\n${output.slice(0, 3500)}\n\nDuration: ${result.durationMs}ms`);
|
||||
});
|
||||
|
||||
audit.sandbox({
|
||||
command: `[${language}] ${code.slice(0, 100)}`,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
audit.error({ error: `Code execution error: ${errorMsg}`, metadata: { userId, language, code } });
|
||||
await ctx.reply(`Error: ${errorMsg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Command: /schedule <cron> <name> <prompt>
|
||||
bot.command("schedule", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduler) {
|
||||
await ctx.reply("Scheduler is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.scheduler.enabled) {
|
||||
await ctx.reply("Scheduler is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse: /schedule "*/5 * * * *" "Task Name" What to do
|
||||
const text = ctx.message?.text?.replace(/^\/schedule\s*/, "").trim() ?? "";
|
||||
const match = text.match(/^"([^"]+)"\s+"([^"]+)"\s+(.+)$/s);
|
||||
if (!match) {
|
||||
await ctx.reply(
|
||||
`Usage: /schedule "<cron>" "<name>" <prompt>
|
||||
|
||||
Example:
|
||||
/schedule "0 9 * * *" "Morning Brief" Give me a summary of what I should focus on today
|
||||
|
||||
Cron format: minute hour day month weekday
|
||||
- "0 9 * * *" = 9:00 AM daily
|
||||
- "*/30 * * * *" = Every 30 minutes
|
||||
- "0 0 * * 1" = Midnight on Mondays`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, cronExpr, name, prompt] = match;
|
||||
|
||||
try {
|
||||
const taskId = scheduler.addTask({
|
||||
name,
|
||||
schedule: cronExpr,
|
||||
prompt,
|
||||
enabled: true,
|
||||
});
|
||||
await ctx.reply(`Task scheduled!\n\nID: ${taskId}\nName: ${name}\nSchedule: ${cronExpr}`);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
await ctx.reply(`Failed to schedule task: ${errorMsg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Command: /tasks
|
||||
bot.command("tasks", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduler) {
|
||||
await ctx.reply("Scheduler is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = scheduler.listTasks();
|
||||
if (tasks.length === 0) {
|
||||
await ctx.reply("No scheduled tasks.\n\nUse /schedule to create one.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = tasks.map((t) => {
|
||||
const status = t.enabled ? "✓" : "✗";
|
||||
const lastRun = t.lastRun ? t.lastRun.toISOString().slice(0, 16) : "never";
|
||||
return `${status} **${t.name}** (${t.id})\n ${t.schedule}\n Last: ${lastRun}`;
|
||||
});
|
||||
|
||||
await ctx.reply(`**Scheduled Tasks**\n\n${lines.join("\n\n")}`, { parse_mode: "Markdown" }).catch(async () => {
|
||||
await ctx.reply(`Scheduled Tasks\n\n${lines.join("\n\n").replace(/\*\*/g, "")}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Command: /deltask <id>
|
||||
bot.command("deltask", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduler) {
|
||||
await ctx.reply("Scheduler is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = ctx.message?.text?.replace(/^\/deltask\s*/, "").trim() ?? "";
|
||||
if (!taskId) {
|
||||
await ctx.reply("Usage: /deltask <task_id>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduler.removeTask(taskId)) {
|
||||
await ctx.reply(`Task ${taskId} deleted.`);
|
||||
} else {
|
||||
await ctx.reply(`Task ${taskId} not found.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle all text messages
|
||||
bot.on("message:text", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
const username = formatUsername(ctx);
|
||||
const text = ctx.message.text;
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
// Check allowlist
|
||||
if (!isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
audit.messageBlocked({
|
||||
userId,
|
||||
username,
|
||||
reason: "User not in allowlist",
|
||||
});
|
||||
await ctx.reply("Access denied. You are not authorized to use this bot.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip commands (handled above)
|
||||
if (text.startsWith("/")) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Show typing indicator
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
// Add user message to history
|
||||
conversations.add(userId, { role: "user", content: text });
|
||||
|
||||
// Get conversation history
|
||||
const history = conversations.get(userId);
|
||||
|
||||
// Get personalized system prompt if personality is configured
|
||||
const systemPrompt = personality
|
||||
? await personality.getSystemPrompt(userId)
|
||||
: undefined;
|
||||
|
||||
// Call AI with optional personalized system prompt
|
||||
const response = await agent.chat(history, systemPrompt);
|
||||
|
||||
// Add assistant response to history
|
||||
conversations.add(userId, { role: "assistant", content: response.text });
|
||||
|
||||
// Learn from this conversation
|
||||
if (personality) {
|
||||
await personality.learnFromConversation(userId, text, response.text);
|
||||
}
|
||||
|
||||
// Send response
|
||||
await ctx.reply(response.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||
// Fallback without markdown if it fails
|
||||
await ctx.reply(response.text);
|
||||
});
|
||||
|
||||
// Audit log
|
||||
audit.message({
|
||||
userId,
|
||||
username,
|
||||
text,
|
||||
response: response.text,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
audit.error({
|
||||
error: `Failed to process message: ${errorMsg}`,
|
||||
metadata: { userId, username },
|
||||
});
|
||||
|
||||
await ctx.reply("Sorry, I encountered an error processing your message. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle forwarded messages
|
||||
bot.on("message:forward_origin", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
const username = formatUsername(ctx);
|
||||
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
audit.messageBlocked({
|
||||
userId: userId || 0,
|
||||
username,
|
||||
reason: "User not in allowlist",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = ctx.message.text || ctx.message.caption || "";
|
||||
if (!text) {
|
||||
await ctx.reply("I received your forwarded message but couldn't extract any text.");
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
// Process as a standalone analysis (don't add to conversation history)
|
||||
const response = await agent.chat([
|
||||
{
|
||||
role: "user",
|
||||
content: `Please analyze this forwarded message:\n\n${text}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await ctx.reply(response.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||
await ctx.reply(response.text);
|
||||
});
|
||||
|
||||
audit.message({
|
||||
userId,
|
||||
username,
|
||||
text: `[FORWARDED] ${text}`,
|
||||
response: response.text,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} catch (err) {
|
||||
audit.error({
|
||||
error: `Failed to process forwarded message: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
await ctx.reply("Sorry, I couldn't analyze that forwarded message.");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle photos
|
||||
bot.on("message:photo", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
const username = formatUsername(ctx);
|
||||
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
audit.messageBlocked({
|
||||
userId: userId || 0,
|
||||
username,
|
||||
reason: "User not in allowlist",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const caption = ctx.message.caption || "What's in this image? Describe it in detail.";
|
||||
|
||||
try {
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
// Get the largest photo (last in array)
|
||||
const photos = ctx.message.photo;
|
||||
const photo = photos[photos.length - 1];
|
||||
|
||||
// Get file info
|
||||
const file = await ctx.api.getFile(photo.file_id);
|
||||
if (!file.file_path) {
|
||||
await ctx.reply("Sorry, I couldn't download the image.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the file
|
||||
const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${file.file_path}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
await ctx.reply("Sorry, I couldn't download the image.");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString("base64");
|
||||
|
||||
// Determine media type from file path
|
||||
const ext = file.file_path.split(".").pop()?.toLowerCase();
|
||||
let mediaType: ImageContent["mediaType"] = "image/jpeg";
|
||||
if (ext === "png") mediaType = "image/png";
|
||||
else if (ext === "gif") mediaType = "image/gif";
|
||||
else if (ext === "webp") mediaType = "image/webp";
|
||||
|
||||
// Analyze with AI
|
||||
const result = await agent.analyzeImage(base64, mediaType, caption);
|
||||
|
||||
await ctx.reply(result.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||
await ctx.reply(result.text);
|
||||
});
|
||||
|
||||
audit.message({
|
||||
userId,
|
||||
username,
|
||||
text: `[IMAGE] ${caption}`,
|
||||
response: result.text,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
audit.error({
|
||||
error: `Failed to analyze image: ${errorMsg}`,
|
||||
metadata: { userId, username },
|
||||
});
|
||||
await ctx.reply("Sorry, I couldn't analyze that image. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle documents
|
||||
bot.on("message:document", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
const username = formatUsername(ctx);
|
||||
|
||||
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
|
||||
audit.messageBlocked({
|
||||
userId: userId || 0,
|
||||
username,
|
||||
reason: "User not in allowlist",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = ctx.message?.document;
|
||||
if (!doc) {
|
||||
await ctx.reply("Could not process document.");
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const caption = ctx.message?.caption || "Please analyze this document and summarize the key points.";
|
||||
|
||||
try {
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
// Check file size (max 20MB)
|
||||
if (doc.file_size && doc.file_size > 20 * 1024 * 1024) {
|
||||
await ctx.reply("Document too large (max 20MB).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file info
|
||||
const file = await ctx.api.getFile(doc.file_id);
|
||||
if (!file.file_path) {
|
||||
await ctx.reply("Could not download document.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the file
|
||||
const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${file.file_path}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
await ctx.reply("Failed to download document.");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const mimeType = doc.mime_type || "application/octet-stream";
|
||||
|
||||
// Extract text
|
||||
const extracted = await extractText(buffer, mimeType, doc.file_name);
|
||||
|
||||
if (extracted.format === "unsupported") {
|
||||
await ctx.reply(
|
||||
`Unsupported document format: ${mimeType}\n\nSupported: PDF, TXT, MD, JSON, CSV, code files`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extracted.format === "pdf-error") {
|
||||
await ctx.reply(`Could not parse PDF: ${extracted.text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Analyze with AI
|
||||
const result = await agent.chat([
|
||||
{
|
||||
role: "user",
|
||||
content: `${caption}\n\n--- Document Content (${summarizeDocument(extracted)}) ---\n\n${extracted.text}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await ctx.reply(result.text, { parse_mode: "Markdown" }).catch(async () => {
|
||||
await ctx.reply(result.text);
|
||||
});
|
||||
|
||||
audit.message({
|
||||
userId,
|
||||
username,
|
||||
text: `[DOCUMENT: ${doc.file_name || "unnamed"}] ${caption}`,
|
||||
response: result.text,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
audit.error({
|
||||
error: `Failed to analyze document: ${errorMsg}`,
|
||||
metadata: { userId, username, filename: doc.file_name },
|
||||
});
|
||||
await ctx.reply("Sorry, I couldn't analyze that document. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
bot,
|
||||
|
||||
async start(): Promise<void> {
|
||||
console.log("[telegram] Starting bot in polling mode...");
|
||||
await bot.start({
|
||||
onStart: (botInfo) => {
|
||||
console.log(`[telegram] Bot started: @${botInfo.username}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
console.log("[telegram] Stopping bot...");
|
||||
await bot.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a user (for webhook notifications, cron results, etc.)
|
||||
*/
|
||||
export async function sendToUser(
|
||||
bot: Bot,
|
||||
userId: number,
|
||||
message: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await bot.api.sendMessage(userId, message, { parse_mode: "Markdown" }).catch(async () => {
|
||||
// Fallback without markdown
|
||||
await bot.api.sendMessage(userId, message);
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[telegram] Failed to send message to ${userId}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
21
secure/tsconfig.json
Normal file
21
secure/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
287
secure/webhooks.ts
Normal file
287
secure/webhooks.ts
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* AssureBot - 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-AssureBot-Token header
|
||||
const tokenHeader = req.headers["x-assurebot-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