feat: cloud deployment support with inline config, Dockerfile, and admin portal (#434)
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
lettabot.yaml
|
||||
lettabot.yml
|
||||
lettabot-agent.json
|
||||
data/
|
||||
*.log
|
||||
.skills/
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,6 +51,9 @@ data/telegram-mtproto/
|
||||
# Config with secrets
|
||||
lettabot.yaml
|
||||
lettabot.yml
|
||||
|
||||
# Platform-specific deploy configs (generated by fly launch, etc.)
|
||||
fly.toml
|
||||
bun.lock
|
||||
.tool-versions
|
||||
|
||||
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -376,7 +376,10 @@ By default, the agent is restricted to **read-only** operations:
|
||||
|
||||
LettaBot supports pairing-based access control. When `TELEGRAM_DM_POLICY=pairing`:
|
||||
1. Unauthorized users get a pairing code
|
||||
2. You approve codes via `lettabot pairing approve telegram <CODE>`
|
||||
2. Approve codes via:
|
||||
- **Web portal** at `https://your-host/portal` (recommended for cloud deploys)
|
||||
- **CLI**: `lettabot pairing approve telegram <CODE>`
|
||||
- **API**: `POST /api/v1/pairing/telegram/approve`
|
||||
3. Approved users can then chat with the bot
|
||||
|
||||
## Development
|
||||
|
||||
@@ -5,7 +5,8 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
|
||||
## Guides
|
||||
|
||||
- [Getting Started](./getting-started.md) - Installation and basic setup
|
||||
- [Docker Server Setup](./selfhosted-setup.md) - Run with your own Letta server
|
||||
- [Cloud Deployment](./cloud-deploy.md) - Deploy to Fly.io, Railway, Docker, or any cloud platform
|
||||
- [Self-Hosted Letta Server](./selfhosted-setup.md) - Run with your own Letta server (instead of Letta API)
|
||||
- [Configuration Reference](./configuration.md) - All config options
|
||||
- [Commands Reference](./commands.md) - Bot commands reference
|
||||
- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools
|
||||
@@ -14,7 +15,7 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
|
||||
- [Response Directives](./directives.md) - XML action directives (reactions, etc.)
|
||||
- [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats
|
||||
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration
|
||||
- [Railway Deployment](./railway-deploy.md) - Deploy to Railway
|
||||
- [Railway Deployment](./railway-deploy.md) - Railway-specific setup (one-click deploy, volumes)
|
||||
- [Releasing](./releasing.md) - How to create releases
|
||||
|
||||
### Channel Setup
|
||||
|
||||
155
docs/cloud-deploy.md
Normal file
155
docs/cloud-deploy.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Cloud Deployment
|
||||
|
||||
Deploy LettaBot to any cloud platform that supports Docker or Node.js.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Letta API key](https://app.letta.com) (or a self-hosted Letta server -- see [Docker Server Setup](./selfhosted-setup.md))
|
||||
- At least one channel token (Telegram, Discord, or Slack)
|
||||
- A working `lettabot.yaml` config (run `lettabot onboard` to create one)
|
||||
|
||||
## Configuration
|
||||
|
||||
Cloud platforms typically don't support config files directly. LettaBot solves this with `LETTABOT_CONFIG_YAML` -- a single environment variable containing your entire config.
|
||||
|
||||
### Encoding Your Config
|
||||
|
||||
```bash
|
||||
# Using the CLI helper (recommended)
|
||||
lettabot config encode
|
||||
|
||||
# Or manually
|
||||
base64 < lettabot.yaml | tr -d '\n'
|
||||
```
|
||||
|
||||
Set the output as `LETTABOT_CONFIG_YAML` on your platform. This is the only env var you need -- everything (API key, channels, features) is in the YAML.
|
||||
|
||||
Both base64-encoded and raw YAML values are accepted. Base64 is recommended since some platforms don't handle multi-line env vars well.
|
||||
|
||||
### Verifying
|
||||
|
||||
To decode and inspect what a `LETTABOT_CONFIG_YAML` value contains:
|
||||
|
||||
```bash
|
||||
LETTABOT_CONFIG_YAML=... lettabot config decode
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
LettaBot includes a Dockerfile for containerized deployment.
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
docker build -t lettabot .
|
||||
|
||||
docker run -d \
|
||||
-e LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')" \
|
||||
-p 8080:8080 \
|
||||
lettabot
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
lettabot:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
If running alongside a self-hosted Letta server, see [Docker Server Setup](./selfhosted-setup.md) for the Letta container config.
|
||||
|
||||
## Fly.io
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
brew install flyctl
|
||||
fly auth login
|
||||
|
||||
# Launch (detects Dockerfile automatically)
|
||||
fly launch
|
||||
|
||||
# Set your config
|
||||
fly secrets set LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')"
|
||||
|
||||
# Set a stable API key (optional, prevents regeneration across deploys)
|
||||
fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# Deploy
|
||||
fly deploy
|
||||
```
|
||||
|
||||
`fly launch` generates a `fly.toml` with your app name. Edit it to keep the bot running (Fly defaults to stopping idle machines):
|
||||
|
||||
```toml
|
||||
[http_service]
|
||||
auto_stop_machines = false
|
||||
min_machines_running = 1
|
||||
```
|
||||
|
||||
Scale to 1 machine (multiple instances would conflict on channel tokens):
|
||||
|
||||
```bash
|
||||
fly scale count 1
|
||||
```
|
||||
|
||||
## Railway
|
||||
|
||||
See [Railway Deployment](./railway-deploy.md) for the full guide including one-click deploy, persistent volumes, and Railway-specific configuration.
|
||||
|
||||
The short version:
|
||||
|
||||
1. Fork the repo and connect to Railway
|
||||
2. Set `LETTABOT_CONFIG_YAML` (or individual env vars for simple setups)
|
||||
3. Deploy
|
||||
|
||||
## Other Platforms
|
||||
|
||||
Any platform that runs Docker images or Node.js works. Set `LETTABOT_CONFIG_YAML` as an env var and you're done.
|
||||
|
||||
**Render:** Deploy from GitHub, set env var in dashboard.
|
||||
|
||||
**DigitalOcean App Platform:** Use the Dockerfile, set env var in app settings.
|
||||
|
||||
**Any VPS (EC2, Linode, Hetzner):** Build the Docker image and run it, or install Node.js and run `npm start` directly.
|
||||
|
||||
## Web Portal
|
||||
|
||||
LettaBot includes an admin portal at `/portal` for managing pairing approvals from a browser. Navigate to `https://your-host/portal` and enter your API key to:
|
||||
|
||||
- View pending pairing requests across all channels
|
||||
- Approve users with one click
|
||||
- Auto-refreshes every 10 seconds
|
||||
|
||||
## API Key
|
||||
|
||||
An API key is auto-generated on first boot and printed in logs. It's required for the web portal and HTTP API endpoints.
|
||||
|
||||
To make it stable across deploys, set `LETTABOT_API_KEY` as an environment variable:
|
||||
|
||||
```bash
|
||||
# Fly.io
|
||||
fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# Railway / Render / etc.
|
||||
# Set LETTABOT_API_KEY in the platform's env var UI
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
LettaBot exposes `GET /health` which returns `ok`. Configure your platform's health check to use this endpoint.
|
||||
|
||||
## Channel Limitations
|
||||
|
||||
| Channel | Cloud Support | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Telegram | Yes | Full support |
|
||||
| Discord | Yes | Full support |
|
||||
| Slack | Yes | Full support |
|
||||
| WhatsApp | No | Requires local QR code pairing |
|
||||
| Signal | No | Requires local device registration |
|
||||
@@ -2,18 +2,36 @@
|
||||
|
||||
Complete reference for LettaBot configuration options.
|
||||
|
||||
## Config File Locations
|
||||
## Config Sources
|
||||
|
||||
LettaBot checks these locations in order:
|
||||
LettaBot checks these sources in priority order:
|
||||
|
||||
1. `LETTABOT_CONFIG` env var - Explicit path override
|
||||
2. `./lettabot.yaml` - Project-local (recommended)
|
||||
3. `./lettabot.yml` - Project-local alternate
|
||||
4. `~/.lettabot/config.yaml` - User global
|
||||
5. `~/.lettabot/config.yml` - User global alternate
|
||||
1. `LETTABOT_CONFIG_YAML` env var - Inline YAML or base64-encoded YAML (recommended for cloud/Docker)
|
||||
2. `LETTABOT_CONFIG` env var - Explicit file path override
|
||||
3. `./lettabot.yaml` - Project-local (recommended for local dev)
|
||||
4. `./lettabot.yml` - Project-local alternate
|
||||
5. `~/.lettabot/config.yaml` - User global
|
||||
6. `~/.lettabot/config.yml` - User global alternate
|
||||
|
||||
For global installs (`npm install -g`), either:
|
||||
- Create `~/.lettabot/config.yaml`, or
|
||||
### Cloud / Docker Deployments
|
||||
|
||||
On platforms where you can't include a config file (Railway, Fly.io, Render, etc.), use `LETTABOT_CONFIG_YAML` to pass your entire config as a single environment variable:
|
||||
|
||||
```bash
|
||||
# Encode your local config as base64
|
||||
lettabot config encode
|
||||
|
||||
# Or manually
|
||||
base64 < lettabot.yaml | tr -d '\n'
|
||||
```
|
||||
|
||||
Set the output as `LETTABOT_CONFIG_YAML` on your platform. Raw YAML is also accepted (for platforms that support multi-line env vars).
|
||||
|
||||
### Local Development
|
||||
|
||||
For local installs, either:
|
||||
- Create `./lettabot.yaml` in your project, or
|
||||
- Create `~/.lettabot/config.yaml` for global config, or
|
||||
- Set `export LETTABOT_CONFIG=/path/to/your/config.yaml`
|
||||
|
||||
## Example Configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Railway Deployment
|
||||
|
||||
Deploy LettaBot to [Railway](https://railway.app) for always-on hosting.
|
||||
Deploy LettaBot to [Railway](https://railway.app) for always-on hosting. For other platforms (Fly.io, Docker, Render), see [Cloud Deployment](./cloud-deploy.md).
|
||||
|
||||
## One-Click Deploy
|
||||
|
||||
@@ -11,15 +11,33 @@ Deploy LettaBot to [Railway](https://railway.app) for always-on hosting.
|
||||
|
||||
**No local setup required.** LettaBot automatically finds or creates your agent by name.
|
||||
|
||||
## Environment Variables
|
||||
## Configuration
|
||||
|
||||
### Required
|
||||
### Option A: Full YAML Config (Recommended)
|
||||
|
||||
Use `LETTABOT_CONFIG_YAML` to pass your entire `lettabot.yaml` as a single base64-encoded environment variable. This gives you access to the full config schema (multi-agent, conversation routing, group policies, etc.) without managing dozens of individual env vars.
|
||||
|
||||
```bash
|
||||
# Encode your local config
|
||||
base64 < lettabot.yaml | tr -d '\n'
|
||||
|
||||
# Or use the CLI helper
|
||||
lettabot config encode
|
||||
```
|
||||
|
||||
Set the output as `LETTABOT_CONFIG_YAML` in your Railway service variables. That's it -- no other env vars needed (everything is in the YAML).
|
||||
|
||||
### Option B: Individual Environment Variables
|
||||
|
||||
For simple setups (one channel, basic config), you can use individual env vars instead.
|
||||
|
||||
#### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `LETTA_API_KEY` | Your Letta API key ([get one here](https://app.letta.com)) |
|
||||
|
||||
### Channel Configuration (at least one required)
|
||||
#### Channel Configuration (at least one required)
|
||||
|
||||
**Telegram:**
|
||||
```
|
||||
@@ -39,7 +57,7 @@ SLACK_BOT_TOKEN=xoxb-...
|
||||
SLACK_APP_TOKEN=xapp-...
|
||||
```
|
||||
|
||||
### Optional
|
||||
#### Optional
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
@@ -107,23 +125,39 @@ LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persi
|
||||
|
||||
## Remote Pairing Approval
|
||||
|
||||
When using `pairing` DM policy on Railway, you can approve new users via the HTTP API instead of the CLI:
|
||||
When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access.
|
||||
|
||||
### Web Portal
|
||||
|
||||
Navigate to `https://your-app/portal` to access the admin portal. It provides a UI for managing pairing requests across all channels (Telegram, Discord, Slack).
|
||||
|
||||
You'll need your `LETTABOT_API_KEY` to log in. The key is auto-generated on first boot and printed in logs. Set it as an environment variable for stable access across deploys:
|
||||
|
||||
```bash
|
||||
# Railway
|
||||
# Set LETTABOT_API_KEY in service variables
|
||||
|
||||
# Fly.io
|
||||
fly secrets set LETTABOT_API_KEY=your-key -a your-app
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
You can also approve pairings via the HTTP API:
|
||||
|
||||
```bash
|
||||
# List pending pairing requests for a channel
|
||||
curl -H "X-Api-Key: $LETTABOT_API_KEY" \
|
||||
https://your-app.railway.app/api/v1/pairing/telegram
|
||||
https://your-app/api/v1/pairing/telegram
|
||||
|
||||
# Approve a pairing code
|
||||
curl -X POST \
|
||||
-H "X-Api-Key: $LETTABOT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"code": "ABCD1234"}' \
|
||||
https://your-app.railway.app/api/v1/pairing/telegram/approve
|
||||
https://your-app/api/v1/pairing/telegram/approve
|
||||
```
|
||||
|
||||
`LETTABOT_API_KEY` is auto-generated on first boot and printed in logs. Set it as a Railway variable for stable access across deploys.
|
||||
|
||||
Alternatively, use `allowlist` DM policy and pre-configure allowed users in environment variables to skip pairing entirely.
|
||||
|
||||
## Channel Limitations
|
||||
|
||||
@@ -107,6 +107,8 @@ server:
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Run both the Letta server and LettaBot together:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
letta:
|
||||
@@ -123,15 +125,17 @@ services:
|
||||
depends_on:
|
||||
- letta
|
||||
environment:
|
||||
- LETTA_BASE_URL=http://letta:8283
|
||||
volumes:
|
||||
- ./lettabot.yaml:/app/lettabot.yaml
|
||||
- ./lettabot-agent.json:/app/lettabot-agent.json
|
||||
- LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
|
||||
# Your YAML config should include: server.baseUrl: http://letta:8283
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
letta-data:
|
||||
```
|
||||
|
||||
For general Docker and cloud deployment (without a self-hosted Letta server), see [Cloud Deployment](./cloud-deploy.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
|
||||
@@ -210,3 +210,32 @@ describe('POST /api/v1/chat', () => {
|
||||
expect(events.find((e: any) => e.type === 'error').error).toBe('connection lost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /portal', () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createApiServer(createMockRouter(), {
|
||||
port: TEST_PORT,
|
||||
apiKey: TEST_API_KEY,
|
||||
host: '127.0.0.1',
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server.listening) { resolve(); return; }
|
||||
server.once('listening', resolve);
|
||||
});
|
||||
port = getPort(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('serves the pairing portal HTML without requiring an API key', async () => {
|
||||
const res = await request(port, 'GET', '/portal');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.body).toContain('<title>LettaBot Portal</title>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -484,6 +484,13 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: GET /portal - Admin portal for pairing approvals
|
||||
if ((req.url === '/portal' || req.url === '/portal/') && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(portalHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: 404 Not Found
|
||||
sendError(res, 404, 'Not found');
|
||||
});
|
||||
@@ -569,3 +576,173 @@ function sendError(res: http.ServerResponse, status: number, message: string, fi
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin portal HTML - self-contained page for pairing approvals
|
||||
*/
|
||||
const portalHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LettaBot Portal</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; min-height: 100vh; }
|
||||
.container { max-width: 640px; margin: 0 auto; padding: 24px 16px; }
|
||||
h1 { font-size: 18px; font-weight: 600; margin-bottom: 24px; color: #fff; }
|
||||
h1 span { color: #666; font-weight: 400; }
|
||||
|
||||
/* Auth */
|
||||
.auth { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 24px; margin-bottom: 24px; }
|
||||
.auth label { display: block; font-size: 13px; color: #888; margin-bottom: 8px; }
|
||||
.auth input { width: 100%; padding: 10px 12px; background: #0a0a0a; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 14px; font-family: monospace; }
|
||||
.auth input:focus { outline: none; border-color: #555; }
|
||||
.auth button { margin-top: 12px; padding: 8px 20px; background: #fff; color: #000; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||
.auth button:hover { background: #ddd; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||
.tab { padding: 6px 14px; background: #141414; border: 1px solid #222; border-radius: 6px; font-size: 13px; cursor: pointer; color: #888; }
|
||||
.tab:hover { color: #ccc; border-color: #333; }
|
||||
.tab.active { background: #1a1a1a; color: #fff; border-color: #444; }
|
||||
.tab .count { background: #333; color: #aaa; font-size: 11px; padding: 1px 6px; border-radius: 10px; margin-left: 6px; }
|
||||
.tab.active .count { background: #fff; color: #000; }
|
||||
|
||||
/* Table */
|
||||
.requests { background: #141414; border: 1px solid #222; border-radius: 8px; overflow: hidden; }
|
||||
.request { display: flex; align-items: center; padding: 14px 16px; border-bottom: 1px solid #1a1a1a; gap: 16px; }
|
||||
.request:last-child { border-bottom: none; }
|
||||
.code { font-family: monospace; font-size: 15px; font-weight: 600; color: #fff; min-width: 90px; }
|
||||
.meta { flex: 1; }
|
||||
.meta .name { font-size: 13px; color: #ccc; }
|
||||
.meta .time { font-size: 12px; color: #555; margin-top: 2px; }
|
||||
.approve-btn { padding: 6px 16px; background: #1a7f37; color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||
.approve-btn:hover { background: #238636; }
|
||||
.approve-btn:disabled { background: #333; color: #666; cursor: default; }
|
||||
|
||||
/* Empty */
|
||||
.empty { padding: 40px 16px; text-align: center; color: #555; font-size: 14px; }
|
||||
|
||||
/* Toast */
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.ok { background: #1a7f37; color: #fff; }
|
||||
.toast.err { background: #d1242f; color: #fff; }
|
||||
|
||||
/* Status bar */
|
||||
.status { font-size: 12px; color: #444; text-align: center; margin-top: 16px; }
|
||||
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>LettaBot <span>Portal</span></h1>
|
||||
|
||||
<div class="auth" id="auth">
|
||||
<label for="key">API Key</label>
|
||||
<input type="password" id="key" placeholder="Paste your LETTABOT_API_KEY" autocomplete="off" onkeydown="if(event.key==='Enter')login()">
|
||||
<button onclick="login()">Connect</button>
|
||||
</div>
|
||||
|
||||
<div id="app" class="hidden">
|
||||
<div class="tabs" id="tabs"></div>
|
||||
<div class="requests" id="list"></div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const CHANNELS = ['telegram', 'discord', 'slack'];
|
||||
let apiKey = sessionStorage.getItem('lbkey') || '';
|
||||
let activeChannel = 'telegram';
|
||||
let data = {};
|
||||
let refreshTimer;
|
||||
|
||||
function login() {
|
||||
apiKey = document.getElementById('key').value.trim();
|
||||
if (!apiKey) return;
|
||||
sessionStorage.setItem('lbkey', apiKey);
|
||||
init();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
document.getElementById('auth').classList.add('hidden');
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
await refresh();
|
||||
refreshTimer = setInterval(refresh, 10000);
|
||||
}
|
||||
|
||||
async function apiFetch(path, opts = {}) {
|
||||
const res = await fetch(path, { ...opts, headers: { 'X-Api-Key': apiKey, 'Content-Type': 'application/json', ...opts.headers } });
|
||||
if (res.status === 401) { sessionStorage.removeItem('lbkey'); apiKey = ''; document.getElementById('auth').classList.remove('hidden'); document.getElementById('app').classList.add('hidden'); clearInterval(refreshTimer); toast('Invalid API key', true); throw new Error('Unauthorized'); }
|
||||
return res;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
for (const ch of CHANNELS) {
|
||||
try {
|
||||
const res = await apiFetch('/api/v1/pairing/' + ch);
|
||||
const json = await res.json();
|
||||
data[ch] = json.requests || [];
|
||||
} catch (e) { if (e.message === 'Unauthorized') return; data[ch] = []; }
|
||||
}
|
||||
renderTabs();
|
||||
renderList();
|
||||
document.getElementById('status').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const el = document.getElementById('tabs');
|
||||
el.innerHTML = CHANNELS.map(ch => {
|
||||
const n = (data[ch] || []).length;
|
||||
const cls = ch === activeChannel ? 'tab active' : 'tab';
|
||||
const count = n > 0 ? '<span class="count">' + n + '</span>' : '';
|
||||
return '<div class="' + cls + '" onclick="switchTab(\\'' + ch + '\\')">' + ch.charAt(0).toUpperCase() + ch.slice(1) + count + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('list');
|
||||
const items = data[activeChannel] || [];
|
||||
if (items.length === 0) { el.innerHTML = '<div class="empty">No pending pairing requests</div>'; return; }
|
||||
el.innerHTML = items.map(r => {
|
||||
const name = r.meta?.username ? '@' + r.meta.username : r.meta?.firstName || 'User ' + r.id;
|
||||
const ago = timeAgo(r.createdAt);
|
||||
return '<div class="request"><div class="code">' + esc(r.code) + '</div><div class="meta"><div class="name">' + esc(name) + '</div><div class="time">' + ago + '</div></div><button class="approve-btn" onclick="approve(\\'' + activeChannel + '\\',\\'' + r.code + '\\', this)">Approve</button></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function switchTab(ch) { activeChannel = ch; renderTabs(); renderList(); }
|
||||
|
||||
async function approve(channel, code, btn) {
|
||||
btn.disabled = true; btn.textContent = '...';
|
||||
try {
|
||||
const res = await apiFetch('/api/v1/pairing/' + channel + '/approve', { method: 'POST', body: JSON.stringify({ code }) });
|
||||
const json = await res.json();
|
||||
if (json.success) { toast('Approved'); await refresh(); }
|
||||
else { toast(json.error || 'Failed', true); btn.disabled = false; btn.textContent = 'Approve'; }
|
||||
} catch (e) { toast('Error: ' + e.message, true); btn.disabled = false; btn.textContent = 'Approve'; }
|
||||
}
|
||||
|
||||
function toast(msg, err) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg; el.className = 'toast show ' + (err ? 'err' : 'ok');
|
||||
setTimeout(() => el.className = 'toast', 2500);
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||||
return Math.floor(s/3600) + 'h ago';
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
if (apiKey) init();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
|
||||
85
src/cli.ts
85
src/cli.ts
@@ -8,10 +8,18 @@
|
||||
* lettabot configure - Configure settings
|
||||
*/
|
||||
|
||||
// Config loaded from lettabot.yaml
|
||||
// Config loaded from lettabot.yaml (lazily, so debug/help commands can run with broken config)
|
||||
import type { LettaBotConfig } from './config/index.js';
|
||||
import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js';
|
||||
const config = loadAppConfigOrExit();
|
||||
applyConfigToEnv(config);
|
||||
let cachedConfig: LettaBotConfig | null = null;
|
||||
|
||||
function getConfig(): LettaBotConfig {
|
||||
if (!cachedConfig) {
|
||||
cachedConfig = loadAppConfigOrExit();
|
||||
applyConfigToEnv(cachedConfig);
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
|
||||
@@ -44,6 +52,7 @@ import { onboard } from './onboard.js';
|
||||
async function configure() {
|
||||
const p = await import('@clack/prompts');
|
||||
const { resolveConfigPath } = await import('./config/index.js');
|
||||
const config = getConfig();
|
||||
|
||||
p.intro('🤖 LettaBot Configuration');
|
||||
|
||||
@@ -97,21 +106,49 @@ async function configure() {
|
||||
}
|
||||
}
|
||||
|
||||
async function server() {
|
||||
const { resolveConfigPath } = await import('./config/index.js');
|
||||
async function configEncode() {
|
||||
const { resolveConfigPath, encodeConfigForEnv } = await import('./config/index.js');
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
// Check if configured
|
||||
if (!existsSync(configPath)) {
|
||||
console.error(`No config file found at ${configPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
const encoded = encodeConfigForEnv(content);
|
||||
console.log('Set this environment variable on your cloud platform:\n');
|
||||
console.log(`LETTABOT_CONFIG_YAML=${encoded}`);
|
||||
console.log(`\nSource: ${configPath} (${content.length} bytes -> ${encoded.length} chars base64)`);
|
||||
}
|
||||
|
||||
async function configDecode() {
|
||||
if (!process.env.LETTABOT_CONFIG_YAML) {
|
||||
console.error('LETTABOT_CONFIG_YAML is not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { decodeYamlOrBase64 } = await import('./config/index.js');
|
||||
console.log(decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML));
|
||||
}
|
||||
|
||||
async function server() {
|
||||
const { resolveConfigPath, hasInlineConfig } = await import('./config/index.js');
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
// Check if configured (inline config or file)
|
||||
if (!existsSync(configPath) && !hasInlineConfig()) {
|
||||
console.log(`
|
||||
No config file found. Searched locations:
|
||||
1. LETTABOT_CONFIG env var (not set)
|
||||
2. ./lettabot.yaml (project-local - recommended)
|
||||
3. ./lettabot.yml
|
||||
4. ~/.lettabot/config.yaml (user global)
|
||||
5. ~/.lettabot/config.yml
|
||||
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
|
||||
2. LETTABOT_CONFIG env var (file path)
|
||||
3. ./lettabot.yaml (project-local - recommended for local dev)
|
||||
4. ./lettabot.yml
|
||||
5. ~/.lettabot/config.yaml (user global)
|
||||
6. ~/.lettabot/config.yml
|
||||
|
||||
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG=/path/to/config.yaml
|
||||
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys.
|
||||
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -190,6 +227,8 @@ Commands:
|
||||
onboard Setup wizard (integrations, skills, configuration)
|
||||
server Start the bot server
|
||||
configure View and edit configuration
|
||||
config encode Encode config file as base64 for LETTABOT_CONFIG_YAML
|
||||
config decode Decode and print LETTABOT_CONFIG_YAML env var
|
||||
model Interactive model selector
|
||||
model show Show current agent model
|
||||
model set <handle> Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929)
|
||||
@@ -225,6 +264,7 @@ Examples:
|
||||
lettabot pairing approve telegram ABCD1234 # Approve a pairing code
|
||||
|
||||
Environment:
|
||||
LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys)
|
||||
LETTA_API_KEY API key from app.letta.com
|
||||
TELEGRAM_BOT_TOKEN Bot token from @BotFather
|
||||
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
|
||||
@@ -239,6 +279,7 @@ Environment:
|
||||
}
|
||||
|
||||
function getDefaultTodoAgentKey(): string {
|
||||
const config = getConfig();
|
||||
const configuredName =
|
||||
(config.agent?.name?.trim())
|
||||
|| (config.agents?.length && config.agents[0].name?.trim())
|
||||
@@ -255,6 +296,19 @@ function getDefaultTodoAgentKey(): string {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Most commands expect config-derived env vars to be applied.
|
||||
// Skip bootstrap for help/no-command and config encode/decode so these still work
|
||||
// when the current config is broken.
|
||||
if (
|
||||
command &&
|
||||
command !== 'help' &&
|
||||
command !== '-h' &&
|
||||
command !== '--help' &&
|
||||
!(command === 'config' && (subCommand === 'encode' || subCommand === 'decode'))
|
||||
) {
|
||||
getConfig();
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'onboard':
|
||||
case 'setup':
|
||||
@@ -271,7 +325,13 @@ async function main() {
|
||||
|
||||
case 'configure':
|
||||
case 'config':
|
||||
if (subCommand === 'encode') {
|
||||
await configEncode();
|
||||
} else if (subCommand === 'decode') {
|
||||
await configDecode();
|
||||
} else {
|
||||
await configure();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'skills': {
|
||||
@@ -421,6 +481,7 @@ async function main() {
|
||||
|
||||
case 'reset-conversation': {
|
||||
const p = await import('@clack/prompts');
|
||||
const config = getConfig();
|
||||
|
||||
p.intro('Reset Conversation');
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* LettaBot Configuration I/O
|
||||
*
|
||||
* Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project)
|
||||
* Config sources (checked in priority order):
|
||||
* 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64-encoded YAML)
|
||||
* 2. LETTABOT_CONFIG env var (file path)
|
||||
* 3. ./lettabot.yaml or ./lettabot.yml (project-local)
|
||||
* 4. ~/.lettabot/config.yaml or ~/.lettabot/config.yml (user global)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
@@ -26,7 +30,52 @@ const CONFIG_PATHS = [
|
||||
const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml');
|
||||
|
||||
/**
|
||||
* Find the config file path (first existing, or default)
|
||||
* Whether inline config is available via LETTABOT_CONFIG_YAML env var.
|
||||
* When set, this takes priority over all file-based config sources.
|
||||
*/
|
||||
export function hasInlineConfig(): boolean {
|
||||
return !!process.env.LETTABOT_CONFIG_YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a value that may be raw YAML or base64-encoded YAML.
|
||||
* Detection: if the value contains a colon, it's raw YAML (every valid config
|
||||
* has key: value pairs). Otherwise it's base64 (which uses only [A-Za-z0-9+/=]).
|
||||
*/
|
||||
export function decodeYamlOrBase64(value: string): string {
|
||||
if (value.includes(':')) {
|
||||
return value;
|
||||
}
|
||||
return Buffer.from(value, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode inline config from LETTABOT_CONFIG_YAML env var.
|
||||
*/
|
||||
function decodeInlineConfig(): string {
|
||||
return decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for where config was loaded from.
|
||||
*/
|
||||
export function configSourceLabel(): string {
|
||||
if (hasInlineConfig()) return 'LETTABOT_CONFIG_YAML';
|
||||
const path = resolveConfigPath();
|
||||
return existsSync(path) ? path : 'defaults + environment variables';
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a YAML config file as a base64 string suitable for LETTABOT_CONFIG_YAML.
|
||||
*/
|
||||
export function encodeConfigForEnv(yamlContent: string): string {
|
||||
return Buffer.from(yamlContent, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the config file path (first existing, or default).
|
||||
* Note: when LETTABOT_CONFIG_YAML is set, file-based config is bypassed
|
||||
* entirely -- use hasInlineConfig() to check.
|
||||
*
|
||||
* Priority:
|
||||
* 1. LETTABOT_CONFIG env var (explicit override)
|
||||
@@ -102,10 +151,24 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from YAML file
|
||||
* Load config from inline env var or YAML file
|
||||
*/
|
||||
export function loadConfig(): LettaBotConfig {
|
||||
_lastLoadFailed = false;
|
||||
|
||||
// Inline config takes priority over file-based config
|
||||
if (hasInlineConfig()) {
|
||||
try {
|
||||
const content = decodeInlineConfig();
|
||||
return parseAndNormalizeConfig(content);
|
||||
} catch (err) {
|
||||
_lastLoadFailed = true;
|
||||
log.error('Failed to parse LETTABOT_CONFIG_YAML:', err);
|
||||
log.warn('Using default configuration. Check your YAML syntax.');
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
@@ -129,6 +192,18 @@ export function loadConfig(): LettaBotConfig {
|
||||
*/
|
||||
export function loadConfigStrict(): LettaBotConfig {
|
||||
_lastLoadFailed = false;
|
||||
|
||||
// Inline config takes priority over file-based config
|
||||
if (hasInlineConfig()) {
|
||||
try {
|
||||
const content = decodeInlineConfig();
|
||||
return parseAndNormalizeConfig(content);
|
||||
} catch (err) {
|
||||
_lastLoadFailed = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
|
||||
@@ -73,4 +73,35 @@ describe('loadAppConfigOrExit', () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should mention LETTABOT_CONFIG_YAML when inline config is invalid', () => {
|
||||
const originalInline = process.env.LETTABOT_CONFIG_YAML;
|
||||
const originalPath = process.env.LETTABOT_CONFIG;
|
||||
|
||||
try {
|
||||
process.env.LETTABOT_CONFIG_YAML = 'server:\n api: port: 6702\n';
|
||||
delete process.env.LETTABOT_CONFIG;
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const exit = (code: number): never => {
|
||||
throw new Error(`exit:${code}`);
|
||||
};
|
||||
|
||||
expect(() => loadAppConfigOrExit(exit)).toThrow('exit:1');
|
||||
expect(errorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('Failed to load LETTABOT_CONFIG_YAML'),
|
||||
expect.anything()
|
||||
);
|
||||
expect(errorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('Fix the errors above in LETTABOT_CONFIG_YAML')
|
||||
);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
} finally {
|
||||
process.env.LETTABOT_CONFIG_YAML = originalInline;
|
||||
process.env.LETTABOT_CONFIG = originalPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LettaBotConfig } from './types.js';
|
||||
import { loadConfigStrict, resolveConfigPath } from './io.js';
|
||||
import { configSourceLabel, loadConfigStrict } from './io.js';
|
||||
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
@@ -14,9 +14,9 @@ export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConf
|
||||
try {
|
||||
return loadConfigStrict();
|
||||
} catch (err) {
|
||||
const configPath = resolveConfigPath();
|
||||
log.error(`Failed to load ${configPath}:`, err);
|
||||
log.error(`Fix the errors above in ${configPath} and restart.`);
|
||||
const source = configSourceLabel();
|
||||
log.error(`Failed to load ${source}:`, err);
|
||||
log.error(`Fix the errors above in ${source} and restart.`);
|
||||
return exitFn(1);
|
||||
}
|
||||
}
|
||||
|
||||
23
src/main.ts
23
src/main.ts
@@ -19,6 +19,8 @@ import {
|
||||
applyConfigToEnv,
|
||||
syncProviders,
|
||||
resolveConfigPath,
|
||||
configSourceLabel,
|
||||
hasInlineConfig,
|
||||
isDockerServerMode,
|
||||
serverModeLabel,
|
||||
} from './config/index.js';
|
||||
@@ -31,8 +33,7 @@ import { createLogger, setLogLevel } from './logger.js';
|
||||
const log = createLogger('Config');
|
||||
|
||||
const yamlConfig = loadAppConfigOrExit();
|
||||
const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables';
|
||||
log.info(`Loaded from ${configSource}`);
|
||||
log.info(`Loaded from ${configSourceLabel()}`);
|
||||
if (yamlConfig.agents?.length) {
|
||||
log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
|
||||
} else {
|
||||
@@ -181,19 +182,21 @@ import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/let
|
||||
import { isVoiceMemoConfigured } from './skills/loader.js';
|
||||
// Skills are now installed to agent-scoped location after agent creation (see bot.ts)
|
||||
|
||||
// Check if config exists (skip in Railway/Docker where env vars are used directly)
|
||||
// Check if config exists (skip when inline config, container deploy, or env vars are used)
|
||||
const configPath = resolveConfigPath();
|
||||
const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY);
|
||||
if (!existsSync(configPath) && !isContainerDeploy) {
|
||||
if (!existsSync(configPath) && !isContainerDeploy && !hasInlineConfig()) {
|
||||
log.info(`
|
||||
No config file found. Searched locations:
|
||||
1. LETTABOT_CONFIG env var (not set)
|
||||
2. ./lettabot.yaml (project-local - recommended)
|
||||
3. ./lettabot.yml
|
||||
4. ~/.lettabot/config.yaml (user global)
|
||||
5. ~/.lettabot/config.yml
|
||||
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
|
||||
2. LETTABOT_CONFIG env var (file path)
|
||||
3. ./lettabot.yaml (project-local - recommended for local dev)
|
||||
4. ./lettabot.yml
|
||||
5. ~/.lettabot/config.yaml (user global)
|
||||
6. ~/.lettabot/config.yml
|
||||
|
||||
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG=/path/to/config.yaml
|
||||
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys.
|
||||
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user