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
|
# Config with secrets
|
||||||
lettabot.yaml
|
lettabot.yaml
|
||||||
lettabot.yml
|
lettabot.yml
|
||||||
|
|
||||||
|
# Platform-specific deploy configs (generated by fly launch, etc.)
|
||||||
|
fly.toml
|
||||||
bun.lock
|
bun.lock
|
||||||
.tool-versions
|
.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`:
|
LettaBot supports pairing-based access control. When `TELEGRAM_DM_POLICY=pairing`:
|
||||||
1. Unauthorized users get a pairing code
|
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
|
3. Approved users can then chat with the bot
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
|
|||||||
## Guides
|
## Guides
|
||||||
|
|
||||||
- [Getting Started](./getting-started.md) - Installation and basic setup
|
- [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
|
- [Configuration Reference](./configuration.md) - All config options
|
||||||
- [Commands Reference](./commands.md) - Bot commands reference
|
- [Commands Reference](./commands.md) - Bot commands reference
|
||||||
- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools
|
- [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.)
|
- [Response Directives](./directives.md) - XML action directives (reactions, etc.)
|
||||||
- [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats
|
- [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats
|
||||||
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration
|
- [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
|
- [Releasing](./releasing.md) - How to create releases
|
||||||
|
|
||||||
### Channel Setup
|
### 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.
|
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
|
1. `LETTABOT_CONFIG_YAML` env var - Inline YAML or base64-encoded YAML (recommended for cloud/Docker)
|
||||||
2. `./lettabot.yaml` - Project-local (recommended)
|
2. `LETTABOT_CONFIG` env var - Explicit file path override
|
||||||
3. `./lettabot.yml` - Project-local alternate
|
3. `./lettabot.yaml` - Project-local (recommended for local dev)
|
||||||
4. `~/.lettabot/config.yaml` - User global
|
4. `./lettabot.yml` - Project-local alternate
|
||||||
5. `~/.lettabot/config.yml` - User global alternate
|
5. `~/.lettabot/config.yaml` - User global
|
||||||
|
6. `~/.lettabot/config.yml` - User global alternate
|
||||||
|
|
||||||
For global installs (`npm install -g`), either:
|
### Cloud / Docker Deployments
|
||||||
- Create `~/.lettabot/config.yaml`, or
|
|
||||||
|
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`
|
- Set `export LETTABOT_CONFIG=/path/to/your/config.yaml`
|
||||||
|
|
||||||
## Example Configuration
|
## Example Configuration
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Railway Deployment
|
# 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
|
## 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.
|
**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 |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `LETTA_API_KEY` | Your Letta API key ([get one here](https://app.letta.com)) |
|
| `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:**
|
**Telegram:**
|
||||||
```
|
```
|
||||||
@@ -39,7 +57,7 @@ SLACK_BOT_TOKEN=xoxb-...
|
|||||||
SLACK_APP_TOKEN=xapp-...
|
SLACK_APP_TOKEN=xapp-...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Optional
|
#### Optional
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -107,23 +125,39 @@ LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persi
|
|||||||
|
|
||||||
## Remote Pairing Approval
|
## 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
|
```bash
|
||||||
# List pending pairing requests for a channel
|
# List pending pairing requests for a channel
|
||||||
curl -H "X-Api-Key: $LETTABOT_API_KEY" \
|
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
|
# Approve a pairing code
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "X-Api-Key: $LETTABOT_API_KEY" \
|
-H "X-Api-Key: $LETTABOT_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"code": "ABCD1234"}' \
|
-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.
|
Alternatively, use `allowlist` DM policy and pre-configure allowed users in environment variables to skip pairing entirely.
|
||||||
|
|
||||||
## Channel Limitations
|
## Channel Limitations
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ server:
|
|||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
|
Run both the Letta server and LettaBot together:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
letta:
|
letta:
|
||||||
@@ -123,15 +125,17 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- letta
|
- letta
|
||||||
environment:
|
environment:
|
||||||
- LETTA_BASE_URL=http://letta:8283
|
- LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
|
||||||
volumes:
|
# Your YAML config should include: server.baseUrl: http://letta:8283
|
||||||
- ./lettabot.yaml:/app/lettabot.yaml
|
ports:
|
||||||
- ./lettabot-agent.json:/app/lettabot-agent.json
|
- "8080:8080"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
letta-data:
|
letta-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For general Docker and cloud deployment (without a self-hosted Letta server), see [Cloud Deployment](./cloud-deploy.md).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|||||||
@@ -210,3 +210,32 @@ describe('POST /api/v1/chat', () => {
|
|||||||
expect(events.find((e: any) => e.type === 'error').error).toBe('connection lost');
|
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;
|
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
|
// Route: 404 Not Found
|
||||||
sendError(res, 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.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(response));
|
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
|
* 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';
|
import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js';
|
||||||
const config = loadAppConfigOrExit();
|
let cachedConfig: LettaBotConfig | null = null;
|
||||||
applyConfigToEnv(config);
|
|
||||||
|
function getConfig(): LettaBotConfig {
|
||||||
|
if (!cachedConfig) {
|
||||||
|
cachedConfig = loadAppConfigOrExit();
|
||||||
|
applyConfigToEnv(cachedConfig);
|
||||||
|
}
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
|
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
|
||||||
@@ -44,6 +52,7 @@ import { onboard } from './onboard.js';
|
|||||||
async function configure() {
|
async function configure() {
|
||||||
const p = await import('@clack/prompts');
|
const p = await import('@clack/prompts');
|
||||||
const { resolveConfigPath } = await import('./config/index.js');
|
const { resolveConfigPath } = await import('./config/index.js');
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
p.intro('🤖 LettaBot Configuration');
|
p.intro('🤖 LettaBot Configuration');
|
||||||
|
|
||||||
@@ -97,21 +106,49 @@ async function configure() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function server() {
|
async function configEncode() {
|
||||||
const { resolveConfigPath } = await import('./config/index.js');
|
const { resolveConfigPath, encodeConfigForEnv } = await import('./config/index.js');
|
||||||
const configPath = resolveConfigPath();
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
// Check if configured
|
|
||||||
if (!existsSync(configPath)) {
|
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(`
|
console.log(`
|
||||||
No config file found. Searched locations:
|
No config file found. Searched locations:
|
||||||
1. LETTABOT_CONFIG env var (not set)
|
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
|
||||||
2. ./lettabot.yaml (project-local - recommended)
|
2. LETTABOT_CONFIG env var (file path)
|
||||||
3. ./lettabot.yml
|
3. ./lettabot.yaml (project-local - recommended for local dev)
|
||||||
4. ~/.lettabot/config.yaml (user global)
|
4. ./lettabot.yml
|
||||||
5. ~/.lettabot/config.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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -190,6 +227,8 @@ Commands:
|
|||||||
onboard Setup wizard (integrations, skills, configuration)
|
onboard Setup wizard (integrations, skills, configuration)
|
||||||
server Start the bot server
|
server Start the bot server
|
||||||
configure View and edit configuration
|
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 Interactive model selector
|
||||||
model show Show current agent model
|
model show Show current agent model
|
||||||
model set <handle> Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929)
|
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
|
lettabot pairing approve telegram ABCD1234 # Approve a pairing code
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
|
LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys)
|
||||||
LETTA_API_KEY API key from app.letta.com
|
LETTA_API_KEY API key from app.letta.com
|
||||||
TELEGRAM_BOT_TOKEN Bot token from @BotFather
|
TELEGRAM_BOT_TOKEN Bot token from @BotFather
|
||||||
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
|
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
|
||||||
@@ -239,6 +279,7 @@ Environment:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultTodoAgentKey(): string {
|
function getDefaultTodoAgentKey(): string {
|
||||||
|
const config = getConfig();
|
||||||
const configuredName =
|
const configuredName =
|
||||||
(config.agent?.name?.trim())
|
(config.agent?.name?.trim())
|
||||||
|| (config.agents?.length && config.agents[0].name?.trim())
|
|| (config.agents?.length && config.agents[0].name?.trim())
|
||||||
@@ -255,6 +296,19 @@ function getDefaultTodoAgentKey(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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) {
|
switch (command) {
|
||||||
case 'onboard':
|
case 'onboard':
|
||||||
case 'setup':
|
case 'setup':
|
||||||
@@ -271,7 +325,13 @@ async function main() {
|
|||||||
|
|
||||||
case 'configure':
|
case 'configure':
|
||||||
case 'config':
|
case 'config':
|
||||||
|
if (subCommand === 'encode') {
|
||||||
|
await configEncode();
|
||||||
|
} else if (subCommand === 'decode') {
|
||||||
|
await configDecode();
|
||||||
|
} else {
|
||||||
await configure();
|
await configure();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'skills': {
|
case 'skills': {
|
||||||
@@ -421,6 +481,7 @@ async function main() {
|
|||||||
|
|
||||||
case 'reset-conversation': {
|
case 'reset-conversation': {
|
||||||
const p = await import('@clack/prompts');
|
const p = await import('@clack/prompts');
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
p.intro('Reset Conversation');
|
p.intro('Reset Conversation');
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* LettaBot Configuration I/O
|
* 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';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
@@ -26,7 +30,52 @@ const CONFIG_PATHS = [
|
|||||||
const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml');
|
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:
|
* Priority:
|
||||||
* 1. LETTABOT_CONFIG env var (explicit override)
|
* 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 {
|
export function loadConfig(): LettaBotConfig {
|
||||||
_lastLoadFailed = false;
|
_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();
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
@@ -129,6 +192,18 @@ export function loadConfig(): LettaBotConfig {
|
|||||||
*/
|
*/
|
||||||
export function loadConfigStrict(): LettaBotConfig {
|
export function loadConfigStrict(): LettaBotConfig {
|
||||||
_lastLoadFailed = false;
|
_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();
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
|
|||||||
@@ -73,4 +73,35 @@ describe('loadAppConfigOrExit', () => {
|
|||||||
rmSync(tmpDir, { recursive: true, force: true });
|
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 type { LettaBotConfig } from './types.js';
|
||||||
import { loadConfigStrict, resolveConfigPath } from './io.js';
|
import { configSourceLabel, loadConfigStrict } from './io.js';
|
||||||
|
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConf
|
|||||||
try {
|
try {
|
||||||
return loadConfigStrict();
|
return loadConfigStrict();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const configPath = resolveConfigPath();
|
const source = configSourceLabel();
|
||||||
log.error(`Failed to load ${configPath}:`, err);
|
log.error(`Failed to load ${source}:`, err);
|
||||||
log.error(`Fix the errors above in ${configPath} and restart.`);
|
log.error(`Fix the errors above in ${source} and restart.`);
|
||||||
return exitFn(1);
|
return exitFn(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/main.ts
23
src/main.ts
@@ -19,6 +19,8 @@ import {
|
|||||||
applyConfigToEnv,
|
applyConfigToEnv,
|
||||||
syncProviders,
|
syncProviders,
|
||||||
resolveConfigPath,
|
resolveConfigPath,
|
||||||
|
configSourceLabel,
|
||||||
|
hasInlineConfig,
|
||||||
isDockerServerMode,
|
isDockerServerMode,
|
||||||
serverModeLabel,
|
serverModeLabel,
|
||||||
} from './config/index.js';
|
} from './config/index.js';
|
||||||
@@ -31,8 +33,7 @@ import { createLogger, setLogLevel } from './logger.js';
|
|||||||
const log = createLogger('Config');
|
const log = createLogger('Config');
|
||||||
|
|
||||||
const yamlConfig = loadAppConfigOrExit();
|
const yamlConfig = loadAppConfigOrExit();
|
||||||
const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables';
|
log.info(`Loaded from ${configSourceLabel()}`);
|
||||||
log.info(`Loaded from ${configSource}`);
|
|
||||||
if (yamlConfig.agents?.length) {
|
if (yamlConfig.agents?.length) {
|
||||||
log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
|
log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -181,19 +182,21 @@ import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/let
|
|||||||
import { isVoiceMemoConfigured } from './skills/loader.js';
|
import { isVoiceMemoConfigured } from './skills/loader.js';
|
||||||
// Skills are now installed to agent-scoped location after agent creation (see bot.ts)
|
// 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 configPath = resolveConfigPath();
|
||||||
const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY);
|
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(`
|
log.info(`
|
||||||
No config file found. Searched locations:
|
No config file found. Searched locations:
|
||||||
1. LETTABOT_CONFIG env var (not set)
|
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
|
||||||
2. ./lettabot.yaml (project-local - recommended)
|
2. LETTABOT_CONFIG env var (file path)
|
||||||
3. ./lettabot.yml
|
3. ./lettabot.yaml (project-local - recommended for local dev)
|
||||||
4. ~/.lettabot/config.yaml (user global)
|
4. ./lettabot.yml
|
||||||
5. ~/.lettabot/config.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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user