feat: add Bluesky channel adapter and runtime tooling (supersedes #401) (#486)

This commit is contained in:
Cameron
2026-03-10 13:59:27 -07:00
committed by GitHub
parent 6e8d1fc19d
commit 7f44043962
35 changed files with 4622 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
# LettaBot
Your personal AI assistant that remembers everything across **Telegram, Slack, Discord, WhatsApp, and Signal**. Powered by the [Letta Code SDK](https://github.com/letta-ai/letta-code-sdk).
Your personal AI assistant that remembers everything across **Telegram, Slack, Discord, WhatsApp, and Signal** — plus Bluesky Jetstream feed ingestion. Powered by the [Letta Code SDK](https://github.com/letta-ai/letta-code-sdk).
<img width="750" alt="lettabot-preview" src="assets/preview.jpg" />
@@ -16,6 +16,7 @@ Your personal AI assistant that remembers everything across **Telegram, Slack, D
## Features
- **Multi-Channel** - Chat seamlessly across Telegram, Slack, Discord, WhatsApp, and Signal
- **Feed Ingestion** - Read-only Bluesky Jetstream stream for selected DID(s)
- **Unified Memory** - Single agent remembers everything from all channels
- **Persistent Memory** - Agent remembers conversations across sessions (days/weeks/months)
- **Local Tool Execution** - Agent can read files, search code, run commands on your machine
@@ -236,6 +237,7 @@ agents:
| Discord | [Setup Guide](docs/discord-setup.md) | Discord bot + Message Content Intent |
| WhatsApp | [Setup Guide](docs/whatsapp-setup.md) | Phone with WhatsApp |
| Signal | [Setup Guide](docs/signal-setup.md) | signal-cli + phone number |
| Bluesky (read-only) | [Setup Guide](docs/bluesky-setup.md) | Jetstream WebSocket + DID filter |
At least one channel is required. Telegram is the easiest to start with.

19
bluesky-jetstream.json Normal file
View File

@@ -0,0 +1,19 @@
{
"version": 1,
"updatedAt": "2026-03-10T19:26:51.496Z",
"agents": {
"LettaBot": {
"cursor": 1773168967870655,
"wantedDids": [
"did:plc:gfrmhdmjvxn2sjedzboeudef"
],
"wantedCollections": [
"app.bsky.feed.post"
],
"auth": {
"did": "did:plc:gfrmhdmjvxn2sjedzboeudef",
"handle": "cameron.stream"
}
}
}
}

184
docs/bluesky-setup.md Normal file
View File

@@ -0,0 +1,184 @@
# Bluesky Jetstream Setup
LettaBot can ingest Bluesky events using the Jetstream WebSocket feed. This channel is read-only by default, with optional reply posting if you provide a Bluesky app password.
## Overview
- Jetstream provides a firehose of ATProto commit events.
- You filter by DID(s) and optionally by collection.
- Events are delivered to the agent in listening mode by default (read-only).
- If enabled, the bot can auto-reply to posts using the ATProto XRPC API.
## Configuration (lettabot.yaml)
```yaml
channels:
bluesky:
enabled: true
# autoReply: true # Enable auto-replies (default: false / read-only)
wantedDids: ["did:plc:..."]
# lists:
# "at://did:plc:.../app.bsky.graph.list/xyz": { mode: listen }
# wantedCollections: ["app.bsky.feed.post"]
# notifications:
# enabled: true
# intervalSec: 60
# reasons: ["mention", "reply", "quote"]
# handle: you.bsky.social
# appPassword: xxxx-xxxx-xxxx-xxxx
# serviceUrl: https://bsky.social
# appViewUrl: https://public.api.bsky.app
```
### Conversation routing
If you want Bluesky to keep its own conversation history while other channels stay shared, add a per-channel override:
```yaml
conversations:
mode: shared
perChannel: ["bluesky"]
```
### Filters (how Jetstream is narrowed)
- `wantedDids`: list of DID(s) to include. Multiple entries are ORed.
- `wantedCollections`: list of collections to include. Multiple entries are ORed.
- Both filters are ANDed together.
- Example: wantedDids=[A] + wantedCollections=[app.bsky.feed.post] => only posts by DID A.
If you omit `wantedCollections`, you'll see all collections for the included DIDs (posts, likes, reposts, follows, blocks, etc.).
If there are no `wantedDids` (after list expansion), Jetstream does not connect. Notifications polling can still run if auth is configured.
### Manual posting (skill/CLI)
Bluesky is read-only by default. To post, reply, like, or repost, use the CLI:
```bash
lettabot-bluesky post --text "Hello" --agent <name>
lettabot-bluesky post --reply-to at://did:plc:.../app.bsky.feed.post/... --text "Reply" --agent <name>
lettabot-bluesky like at://did:plc:.../app.bsky.feed.post/... --agent <name>
lettabot-bluesky repost at://did:plc:.../app.bsky.feed.post/... --agent <name>
```
Posts over 300 characters require `--threaded` to explicitly split into a reply thread.
If there are **no** `wantedDids` (after list expansion), Jetstream does **not** connect. Notifications polling can still run if auth is configured.
### Mentions
Jetstream does not provide mention notifications. Mentions are surfaced via the Notifications API (see below). `mention-only` mode only triggers replies for mention notifications.
## Notifications (mentions, replies, likes, etc.)
Jetstream does not include notifications. To get mentions/replies like the Bluesky app, enable polling via the Notifications API:
```yaml
channels:
bluesky:
notifications:
enabled: true
intervalSec: 60
reasons: ["mention", "reply", "quote"]
```
If you supply posting credentials (`handle` + `appPassword`) and do not explicitly disable notifications, polling is enabled with defaults (60s, reasons: mention/reply/quote). Notifications polling works even if `wantedDids` is empty.
Notification reasons include (non-exhaustive): `like`, `repost`, `follow`, `mention`, `reply`, `quote`, `starterpack-joined`, `verified`, `unverified`, `like-via-repost`, `repost-via-repost`, `subscribed-post`.
Only `mention`, `reply`, and `quote` are considered "actionable" for reply behavior (based on your `groups` mode). Other reasons are always listening-only.
## Runtime Kill Switch (per agent)
Disable or re-enable Bluesky without restarting the server:
```bash
lettabot bluesky disable --agent MyAgent
lettabot bluesky enable --agent MyAgent
```
Refresh list expansions on the running server:
```bash
lettabot bluesky refresh-lists --agent MyAgent
```
Kill switch state is stored in `bluesky-runtime.json` (per agent) under the data directory and polled by the running server.
When you use `bluesky add-did`, `bluesky add-list`, or `bluesky set-default`, the CLI also triggers a runtime config reload so the running server updates Jetstream subscriptions without restart.
## Per-DID Modes (using `groups` syntax)
Bluesky uses the same `groups` pattern as other channels, where `"*"` is the default:
```yaml
channels:
bluesky:
enabled: true
wantedDids: ["did:plc:author1"]
groups:
"*": { mode: listen }
"did:plc:author1": { mode: open }
"did:plc:author2": { mode: listen }
"did:plc:spammy": { mode: disabled }
```
Mode mapping:
- `open` -> reply to posts for that DID
- `listen` -> listening-only
- `mention-only` -> reply only for mention notifications
- `disabled` -> ignore events from that DID
Default behavior:
- If `"*"` is set, it is used as the default for any DID without an explicit override.
- If `"*"` is not set, default is `listen`.
## Lists
You can target a Bluesky list by URI and assign a mode. On startup, the list is expanded to member DIDs and added to the stream filter.
```yaml
channels:
bluesky:
lists:
"at://did:plc:.../app.bsky.graph.list/xyz": { mode: listen }
```
If a DID appears in both `groups` and a list, the explicit `groups` mode wins.
List expansion uses the AppView API (default: `https://public.api.bsky.app`). Set `appViewUrl` if you need a different AppView (e.g., for private lists).
## Reply Posting (optional)
To allow replies, set posting credentials and choose a default mode that allows replies (`open` or `mention-only`):
```yaml
channels:
bluesky:
groups:
"*": { mode: open }
handle: you.bsky.social
appPassword: xxxx-xxxx-xxxx-xxxx
```
Notes:
- You must use a Bluesky app password (Settings -> App Passwords).
- Replies are posted only for `app.bsky.feed.post` events.
- Replies go to the latest post from the DID currently being processed.
- Posts are capped to 300 characters.
## Embeds (summary output)
Post embeds are summarized in a compact form, for example:
- `Embed: 2 image(s) (alt: ...)`
- `Embed: link "Title" https://...`
- `Embed: record at://...`
## Troubleshooting
### No messages appearing
- Ensure `wantedDids` contains DID values (e.g. `did:plc:...`), not handles.
- Confirm `wantedCollections` isn't filtering out posts (omit it to see all collections).
- Check logs for the warning about missing `wantedDids` (firehose may be too noisy).
- Verify the Jetstream URL is reachable.

View File

@@ -1,6 +1,6 @@
# LettaBot Configuration
# Copy this to lettabot.yaml and fill in your values.
#
#
# Server modes:
# - 'api': Use Letta API (api.letta.com) with API key
# - 'docker': Use a Docker/custom Letta server
@@ -61,6 +61,24 @@ agents:
# whatsapp:
# enabled: true
# selfChat: false
# bluesky:
# enabled: true
# wantedDids: ["did:plc:..."]
# # groups:
# # "*": { mode: listen } # listen = observe only; open = observe + auto-reply; mention-only = auto-reply to @mentions only
# # "did:plc:...": { mode: open }
# # lists:
# # "at://did:plc:.../app.bsky.graph.list/xyz": { mode: listen }
# # wantedCollections: ["app.bsky.feed.post"]
# # notifications:
# # enabled: true
# # intervalSec: 60
# # reasons: ["mention", "reply", "quote"] # also: like, repost, follow
# # backfill: false # process initial backlog on startup
# # jetstreamUrl: wss://jetstream2.us-east.bsky.network/subscribe
# # handle: you.bsky.social
# # appPassword: xxxx-xxxx-xxxx-xxxx
# # serviceUrl: https://bsky.social
# BYOK Providers (optional, api mode only)
# These will be synced to Letta API on startup
@@ -93,7 +111,7 @@ features:
# display:
# showToolCalls: false # Show tool invocations in chat (e.g. "Using tool: Read (file_path: ...)")
# showReasoning: false # Show agent reasoning/thinking in chat
# reasoningMaxChars: 0 # Truncate reasoning to N chars (0 = no limit, default)
# reasoningMaxChars: 0 # Truncate reasoning to N chars (0 = no limit, default)
# Attachment handling (defaults to 20MB if omitted)
# attachments:

138
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"@atproto/api": "^0.19.1",
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.12",
@@ -35,6 +36,7 @@
},
"bin": {
"lettabot": "dist/cli.js",
"lettabot-bluesky": "dist/channels/bluesky/cli.js",
"lettabot-channels": "dist/cli/channels.js",
"lettabot-history": "dist/cli/history.js",
"lettabot-message": "dist/cli/message.js",
@@ -91,6 +93,88 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@atproto/api": {
"version": "0.19.3",
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.3.tgz",
"integrity": "sha512-G8YpBpRouHdTAIagi/QQIUZOhGd1jfBQWkJy9QfxAzjjEpPvaVOSk4e1S85QzGLm/xbzVONzGkmdtiOSfP6wVg==",
"license": "MIT",
"dependencies": {
"@atproto/common-web": "^0.4.18",
"@atproto/lexicon": "^0.6.2",
"@atproto/syntax": "^0.5.0",
"@atproto/xrpc": "^0.7.7",
"await-lock": "^2.2.2",
"multiformats": "^9.9.0",
"tlds": "^1.234.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/common-web": {
"version": "0.4.18",
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.18.tgz",
"integrity": "sha512-ilImzP+9N/mtse440kN60pGrEzG7wi4xsV13nGeLrS+Zocybc/ISOpKlbZM13o+twPJ+Q7veGLw9CtGg0GAFoQ==",
"license": "MIT",
"dependencies": {
"@atproto/lex-data": "^0.0.13",
"@atproto/lex-json": "^0.0.13",
"@atproto/syntax": "^0.5.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/lex-data": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.13.tgz",
"integrity": "sha512-7Z7RwZ1Y/JzBF/Tcn/I4UJ/vIGfh5zn1zjv0KX+flke2JtgFkSE8uh2hOtqgBQMNqE3zdJFM+dcSWln86hR3MQ==",
"license": "MIT",
"dependencies": {
"multiformats": "^9.9.0",
"tslib": "^2.8.1",
"uint8arrays": "3.0.0",
"unicode-segmenter": "^0.14.0"
}
},
"node_modules/@atproto/lex-json": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.13.tgz",
"integrity": "sha512-hwLhkKaIHulGJpt0EfXAEWdrxqM2L1tV/tvilzhMp3QxPqYgXchFnrfVmLsyFDx6P6qkH1GsX/XC2V36U0UlPQ==",
"license": "MIT",
"dependencies": {
"@atproto/lex-data": "^0.0.13",
"tslib": "^2.8.1"
}
},
"node_modules/@atproto/lexicon": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz",
"integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==",
"license": "MIT",
"dependencies": {
"@atproto/common-web": "^0.4.18",
"@atproto/syntax": "^0.5.0",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/syntax": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.0.tgz",
"integrity": "sha512-UA2DSpGdOQzUQ4gi5SH+NEJz/YR3a3Fg3y2oh+xETDSiTRmA4VhHRCojhXAVsBxUT6EnItw190C/KN+DWW90kw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@atproto/xrpc": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz",
"integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==",
"license": "MIT",
"dependencies": {
"@atproto/lexicon": "^0.6.0",
"zod": "^3.23.8"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
@@ -2842,6 +2926,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz",
@@ -5031,6 +5121,12 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/iso-datestring-validator": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
"integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==",
"license": "MIT"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -6257,6 +6353,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/music-metadata": {
"version": "11.11.0",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.0.tgz",
@@ -8489,6 +8591,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -8545,8 +8656,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
@@ -8629,6 +8739,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
"license": "MIT",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -8645,6 +8764,12 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/unicode-segmenter": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz",
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -9757,6 +9882,15 @@
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -7,6 +7,7 @@
"lettabot": "./dist/cli.js",
"lettabot-schedule": "./dist/cron/cli.js",
"lettabot-message": "./dist/cli/message.js",
"lettabot-bluesky": "./dist/channels/bluesky/cli.js",
"lettabot-react": "./dist/cli/react.js",
"lettabot-history": "./dist/cli/history.js",
"lettabot-channels": "./dist/cli/channels.js"
@@ -64,6 +65,7 @@
"patches/"
],
"dependencies": {
"@atproto/api": "^0.19.1",
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.12",

87
skills/bluesky/SKILL.md Normal file
View File

@@ -0,0 +1,87 @@
---
name: bluesky
description: Post, reply, like, and repost on Bluesky using the lettabot-bluesky CLI. Read-only by default; explicit actions required.
metadata: |
{
"clawdbot": {
"emoji": "🦋",
"primaryEnv": "BLUESKY_HANDLE"
}
}
---
# Bluesky
Bluesky is **read-only by default** in Lettabot. To post, reply, like, or repost you must use the `lettabot-bluesky` CLI.
## Availability
When this skill is enabled, it ships a skill-local `lettabot-bluesky` shim,
so the command is available to agent subprocesses without separate npm install.
Both entrypoints are supported and equivalent:
```bash
lettabot-bluesky <command> ...
lettabot bluesky <command> ...
```
The shim prefers project-local entrypoints (`./dist/cli.js` or `./src/cli.ts`) before falling back to an installed `lettabot` binary on PATH.
## Quick Reference
```bash
lettabot-bluesky post --text "Hello" --agent <name>
lettabot-bluesky post --reply-to at://did:plc:.../app.bsky.feed.post/... --text "Reply" --agent <name>
lettabot-bluesky post --text "Long..." --threaded --agent <name>
lettabot-bluesky post --text "Check this out" --image data/outbound/photo.jpg --alt "Alt text" --agent <name>
lettabot-bluesky post --text "Gallery" --image data/outbound/a.jpg --alt "First" --image data/outbound/b.jpg --alt "Second" --agent <name>
lettabot-bluesky like at://did:plc:.../app.bsky.feed.post/... --agent <name>
lettabot-bluesky repost at://did:plc:.../app.bsky.feed.post/... --agent <name>
lettabot-bluesky repost at://did:plc:.../app.bsky.feed.post/... --text "Quote" --agent <name> [--threaded]
```
## Read Commands (public API)
```bash
lettabot-bluesky profile <did|handle> --agent <name>
lettabot-bluesky thread <at://did:plc:.../app.bsky.feed.post/...> --agent <name>
lettabot-bluesky author-feed <did|handle> --limit 25 --cursor <cursor> --agent <name>
lettabot-bluesky list-feed <listUri> --limit 25 --cursor <cursor> --agent <name>
lettabot-bluesky resolve <handle> --agent <name>
lettabot-bluesky followers <did|handle> --limit 25 --agent <name>
lettabot-bluesky follows <did|handle> --limit 25 --agent <name>
lettabot-bluesky lists <did|handle> --limit 25 --agent <name>
lettabot-bluesky actor-feeds <did|handle> --limit 25 --agent <name>
```
## AuthRequired Reads (uses app password)
```bash
lettabot-bluesky search --query "memory agents" --limit 25 --cursor <cursor> --agent <name>
lettabot-bluesky timeline --limit 25 --cursor <cursor> --agent <name>
lettabot-bluesky notifications --limit 25 --cursor <cursor> --reasons mention,reply --agent <name>
```
## Moderation (Mute / Block)
```bash
lettabot-bluesky mute <did|handle> --agent <name>
lettabot-bluesky unmute <did|handle> --agent <name>
lettabot-bluesky block <did|handle> --agent <name>
lettabot-bluesky unblock <blockUri> --agent <name>
lettabot-bluesky blocks --limit 50 --cursor <cursor> --agent <name>
lettabot-bluesky mutes --limit 50 --cursor <cursor> --agent <name>
```
Notes:
- `unblock` requires the **block record URI** (returned by the `block` command).
- Pagination: many commands support `--cursor` (use the `cursor` field from the previous response).
## Notes
- Posts are capped at 300 characters unless you pass `--threaded`.
- `--threaded` splits text into a reply thread (explicit optin).
- Replies and quotes require the target `at://` URI (included in incoming Bluesky messages).
- The CLI uses the Bluesky app password from your `lettabot.yaml` for the selected agent.
- **Images**: up to 4 per post; supported formats: JPEG, PNG, GIF, WebP. Use `--image <path>` (up to 4×) and `--alt <text>` after each image for alt text. `--alt` applies to the immediately preceding `--image`. Images must be inside the configured `sendFileDir` (default: `data/outbound`). Images attach to the first post only when `--threaded`.

23
skills/bluesky/lettabot-bluesky Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Skill-local shim so `lettabot-bluesky` is available to agent subprocesses
# whenever the Bluesky skill is installed.
set -euo pipefail
# Prefer project-local entrypoints so development worktrees run their current code.
if [[ -f "./dist/cli.js" ]]; then
exec node "./dist/cli.js" bluesky "$@"
fi
if [[ -f "./src/cli.ts" ]] && command -v npx >/dev/null 2>&1; then
exec npx tsx "./src/cli.ts" bluesky "$@"
fi
# Fallback to an installed base CLI.
if command -v lettabot >/dev/null 2>&1; then
exec lettabot bluesky "$@"
fi
echo "Error: unable to resolve a Bluesky CLI entrypoint." >&2
echo "Expected one of: ./dist/cli.js, ./src/cli.ts (via npx tsx), or lettabot on PATH." >&2
exit 1

View File

@@ -0,0 +1,397 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { BlueskyAdapter } from './bluesky.js';
import { splitPostText } from './bluesky/utils.js';
vi.mock('../config/io.js', () => ({
loadConfig: vi.fn(),
}));
const listUri = 'at://did:plc:tester/app.bsky.graph.list/abcd';
function makeAdapter(overrides: Partial<ConstructorParameters<typeof BlueskyAdapter>[0]> = {}) {
return new BlueskyAdapter({
enabled: true,
agentName: 'TestAgent',
groups: { '*': { mode: 'listen' } },
...overrides,
});
}
describe('BlueskyAdapter', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('uses groups wildcard and explicit overrides when resolving mode', () => {
const adapter = makeAdapter({
groups: {
'*': { mode: 'open' },
'did:plc:explicit': { mode: 'disabled' },
},
});
const getDidMode = (adapter as any).getDidMode.bind(adapter);
expect(getDidMode('did:plc:explicit')).toBe('disabled');
expect(getDidMode('did:plc:other')).toBe('open');
});
it('expands list DIDs and respects explicit group overrides', async () => {
(globalThis.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
items: [
{ subject: { did: 'did:plc:one' } },
{ subject: { did: 'did:plc:two' } },
],
}),
text: async () => '',
});
const adapter = makeAdapter({
lists: {
[listUri]: { mode: 'open' },
},
groups: {
'*': { mode: 'listen' },
'did:plc:two': { mode: 'disabled' },
},
appViewUrl: 'https://public.api.bsky.app',
});
await (adapter as any).expandLists();
const listModes = (adapter as any).listModes as Record<string, string>;
expect(listModes['did:plc:one']).toBe('open');
expect(listModes['did:plc:two']).toBeUndefined();
});
it('mention-only replies only on mention notifications', async () => {
const adapter = makeAdapter({
groups: { '*': { mode: 'mention-only' } },
});
const messages: any[] = [];
adapter.onMessage = async (msg) => {
messages.push(msg);
};
const notificationBase = {
uri: 'at://did:plc:author/app.bsky.feed.post/aaa',
cid: 'cid1',
author: { did: 'did:plc:author', handle: 'author.bsky.social' },
record: {
$type: 'app.bsky.feed.post',
text: 'Hello',
createdAt: new Date().toISOString(),
},
indexedAt: new Date().toISOString(),
};
await (adapter as any).processNotification({
...notificationBase,
reason: 'mention',
});
await (adapter as any).processNotification({
...notificationBase,
reason: 'reply',
});
expect(messages).toHaveLength(2);
expect(messages[0].isListeningMode).toBe(false);
expect(messages[1].isListeningMode).toBe(true);
});
it('uses post uri as chatId and defaults notification reply root to the post itself', async () => {
const adapter = makeAdapter();
const notification = {
uri: 'at://did:plc:author/app.bsky.feed.post/abc',
cid: 'cid-post',
author: { did: 'did:plc:author', handle: 'author.bsky.social' },
reason: 'reply',
record: {
$type: 'app.bsky.feed.post',
text: 'Hello',
createdAt: new Date().toISOString(),
},
indexedAt: new Date().toISOString(),
};
const messages: any[] = [];
adapter.onMessage = async (msg) => {
messages.push(msg);
};
await (adapter as any).processNotification(notification);
expect(messages[0].chatId).toBe(notification.uri);
const lastPostByChatId = (adapter as any).lastPostByChatId as Map<string, any>;
const entry = lastPostByChatId.get(notification.uri);
expect(entry?.rootUri).toBe(notification.uri);
expect(entry?.rootCid).toBe(notification.cid);
});
it('deduplicates Jetstream delivery after notifications', async () => {
const adapter = makeAdapter();
const messages: any[] = [];
adapter.onMessage = async (msg) => {
messages.push(msg);
};
const cid = 'cid-dup';
const notification = {
uri: 'at://did:plc:author/app.bsky.feed.post/dup',
cid,
author: { did: 'did:plc:author', handle: 'author.bsky.social' },
reason: 'mention',
record: {
$type: 'app.bsky.feed.post',
text: 'Hello',
createdAt: new Date().toISOString(),
},
indexedAt: new Date().toISOString(),
};
await (adapter as any).processNotification(notification);
const event = {
data: JSON.stringify({
did: 'did:plc:author',
time_us: Date.now() * 1000,
identity: { handle: 'author.bsky.social' },
commit: {
collection: 'app.bsky.feed.post',
rkey: 'dup',
cid,
record: {
$type: 'app.bsky.feed.post',
text: 'Hello',
createdAt: new Date().toISOString(),
},
},
}),
};
await (adapter as any).handleMessageEvent(event);
expect(messages).toHaveLength(1);
});
it('excludes disabled DIDs from wantedDids', () => {
const adapter = makeAdapter({
wantedDids: ['did:plc:disabled'],
groups: {
'*': { mode: 'listen' },
'did:plc:disabled': { mode: 'disabled' },
},
});
const wanted = (adapter as any).getWantedDids();
expect(wanted).toEqual([]);
});
it('splits long replies into multiple posts', () => {
const text = Array.from({ length: 120 }, () => 'word').join(' ');
const chunks = splitPostText(text);
expect(chunks.length).toBeGreaterThan(1);
const segmenter = new Intl.Segmenter();
const graphemeCount = (s: string) => [...segmenter.segment(s)].length;
expect(chunks.every(chunk => graphemeCount(chunk) <= 300)).toBe(true);
const total = chunks.reduce((sum, chunk) => sum + graphemeCount(chunk), 0);
expect(total).toBeGreaterThan(300);
});
it('non-post Jetstream events are dropped without calling onMessage', async () => {
const adapter = makeAdapter({ wantedDids: ['did:plc:author'] });
const messages: any[] = [];
adapter.onMessage = async (msg) => { messages.push(msg); };
const likeEvent = {
data: JSON.stringify({
did: 'did:plc:author',
time_us: Date.now() * 1000,
commit: {
operation: 'create',
collection: 'app.bsky.feed.like',
rkey: 'aaa',
cid: 'cid-like',
record: {
$type: 'app.bsky.feed.like',
subject: { uri: 'at://did:plc:other/app.bsky.feed.post/xyz', cid: 'cid-post' },
createdAt: new Date().toISOString(),
},
},
}),
};
await (adapter as any).handleMessageEvent(likeEvent);
expect(messages).toHaveLength(0);
});
it('embedLines are included in extraContext for posts with images', async () => {
const adapter = makeAdapter({ wantedDids: ['did:plc:author'] });
const messages: any[] = [];
adapter.onMessage = async (msg) => { messages.push(msg); };
const eventWithEmbed = {
data: JSON.stringify({
did: 'did:plc:author',
time_us: Date.now() * 1000,
commit: {
operation: 'create',
collection: 'app.bsky.feed.post',
rkey: 'bbb',
cid: 'cid-embed',
record: {
$type: 'app.bsky.feed.post',
text: 'Check this out',
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.images',
images: [{ alt: 'A cat photo' }],
},
},
},
}),
};
await (adapter as any).handleMessageEvent(eventWithEmbed);
expect(messages).toHaveLength(1);
expect(messages[0].extraContext?.['Embeds']).toContain('1 image');
});
it('sendMessage throws when kill switch is active', async () => {
const adapter = makeAdapter();
(adapter as any).runtimeDisabled = true;
await expect(adapter.sendMessage({ chatId: 'some-chat', text: 'hello' }))
.rejects.toThrow('kill switch');
});
it('reloadConfig preserves handle and appPassword when new config omits them', async () => {
const { loadConfig } = await import('../config/io.js');
vi.mocked(loadConfig).mockReturnValue({
channels: {
bluesky: {
enabled: true,
groups: { '*': { mode: 'open' } },
// handle and appPassword intentionally absent (set via env vars)
},
},
} as any);
const adapter = makeAdapter({ handle: 'env@bsky.social', appPassword: 'env-pass' });
(adapter as any).reloadConfig();
expect((adapter as any).config.handle).toBe('env@bsky.social');
expect((adapter as any).config.appPassword).toBe('env-pass');
expect((adapter as any).config.groups?.['*']?.mode).toBe('open');
});
it('loadState does not override config wantedDids from persisted state', () => {
const tempDir = join(tmpdir(), `bluesky-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
const statePath = join(tempDir, 'bluesky-jetstream.json');
writeFileSync(statePath, JSON.stringify({
version: 1,
agents: {
TestAgent: {
cursor: 123456,
wantedDids: ['did:plc:stale-from-state'],
wantedCollections: ['app.bsky.feed.like'],
auth: { did: 'did:plc:auth', handle: 'test.bsky.social' },
},
},
}));
const adapter = makeAdapter({
wantedDids: ['did:plc:from-config'],
wantedCollections: ['app.bsky.feed.post'],
});
// Point adapter at our temp state file and load it
(adapter as any).statePath = statePath;
(adapter as any).loadState();
// Config values must remain authoritative -- state must not overwrite them
expect((adapter as any).config.wantedDids).toEqual(['did:plc:from-config']);
expect((adapter as any).config.wantedCollections).toEqual(['app.bsky.feed.post']);
// Cursor and auth should still be restored from state
expect((adapter as any).lastCursor).toBe(123456);
expect((adapter as any).sessionDid).toBe('did:plc:auth');
rmSync(tempDir, { recursive: true, force: true });
});
it('flushState does not persist wantedDids or wantedCollections', () => {
const tempDir = join(tmpdir(), `bluesky-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
const statePath = join(tempDir, 'bluesky-jetstream.json');
const adapter = makeAdapter({
wantedDids: ['did:plc:configured'],
wantedCollections: ['app.bsky.feed.post'],
});
(adapter as any).statePath = statePath;
(adapter as any).lastCursor = 999;
(adapter as any).sessionDid = 'did:plc:me';
(adapter as any).stateDirty = true;
(adapter as any).flushState();
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
const entry = state.agents.TestAgent;
expect(entry.cursor).toBe(999);
expect(entry).not.toHaveProperty('wantedDids');
expect(entry).not.toHaveProperty('wantedCollections');
rmSync(tempDir, { recursive: true, force: true });
});
it('pauseRuntime clears pending reconnect timer', () => {
vi.useFakeTimers();
try {
const adapter = makeAdapter();
(adapter as any).running = true;
// Simulate a scheduled reconnect
const timerCallback = vi.fn();
(adapter as any).reconnectTimer = setTimeout(timerCallback, 60000);
(adapter as any).runtimeDisabled = true;
(adapter as any).pauseRuntime();
expect((adapter as any).reconnectTimer).toBeNull();
// Verify the timer was actually cleared (callback should not fire)
vi.advanceTimersByTime(60000);
expect(timerCallback).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('connect() refuses to proceed when runtimeDisabled is true', () => {
const adapter = makeAdapter({ wantedDids: ['did:plc:target'] });
(adapter as any).running = true;
(adapter as any).runtimeDisabled = true;
(adapter as any).ws = null;
// connect() should bail out before creating a WebSocket
(adapter as any).connect();
expect((adapter as any).ws).toBeNull();
});
});

1
src/channels/bluesky.ts Normal file
View File

@@ -0,0 +1 @@
export * from './bluesky/index.js';

File diff suppressed because it is too large Load Diff

878
src/channels/bluesky/cli.ts Normal file
View File

@@ -0,0 +1,878 @@
#!/usr/bin/env node
/**
* lettabot-bluesky - Post, reply, like, or repost on Bluesky
*
* Usage:
* lettabot-bluesky post --text "Hello" --agent <name>
* lettabot-bluesky post --text "Hello" --image data/outbound/photo.jpg --alt "Alt text" --agent <name>
* lettabot-bluesky post --reply-to <at://...> --text "Reply" --agent <name>
* lettabot-bluesky post --text "Long..." --threaded --agent <name>
* lettabot-bluesky like <at://...> --agent <name>
* lettabot-bluesky repost <at://...> --agent <name>
* lettabot-bluesky repost <at://...> --text "Quote" --agent <name> [--threaded]
* lettabot-bluesky profile <did|handle> --agent <name>
* lettabot-bluesky thread <at://...> --agent <name>
* lettabot-bluesky author-feed <did|handle> --limit 25 --cursor <cursor> --agent <name>
* lettabot-bluesky list-feed <listUri> --limit 25 --cursor <cursor> --agent <name>
* lettabot-bluesky search --query "..." --limit 25 --cursor <cursor> --agent <name>
* lettabot-bluesky notifications --limit 25 --cursor <cursor> --reasons mention,reply --agent <name>
* lettabot-bluesky block <did|handle> --agent <name>
* lettabot-bluesky unblock <blockUri> --agent <name>
* lettabot-bluesky mute <did|handle> --agent <name>
* lettabot-bluesky unmute <did|handle> --agent <name>
* lettabot-bluesky blocks --limit 50 --cursor <cursor> --agent <name>
* lettabot-bluesky mutes --limit 50 --cursor <cursor> --agent <name>
*/
import { readFileSync, statSync } from 'node:fs';
import { resolve, extname, join } from 'node:path';
import { loadAppConfigOrExit, normalizeAgents } from '../../config/index.js';
import type { AgentConfig, BlueskyConfig } from '../../config/types.js';
import { isPathAllowed } from '../../core/bot.js';
import { createLogger } from '../../logger.js';
import { DEFAULT_SERVICE_URL, POST_MAX_CHARS } from './constants.js';
import { AtpAgent } from '@atproto/api';
import { fetchWithTimeout, getAppViewUrl, parseAtUri, parseFacets, splitPostText } from './utils.js';
const log = createLogger('Bluesky');
// Bluesky supports JPEG, PNG, GIF, and WebP; max 976,560 bytes per image (AT Protocol lexicon limit)
const BLUESKY_IMAGE_MIMES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
const BLUESKY_IMAGE_MAX_BYTES = 976_560;
const BLUESKY_IMAGE_MAX_COUNT = 4;
function usage(): void {
console.log(`\nUsage:\n lettabot-bluesky post --text "Hello" --agent <name>\n lettabot-bluesky post --reply-to <at://...> --text "Reply" --agent <name>\n lettabot-bluesky post --text "Long..." --threaded --agent <name>\n lettabot-bluesky like <at://...> --agent <name>\n lettabot-bluesky repost <at://...> --agent <name>\n lettabot-bluesky repost <at://...> --text "Quote" --agent <name> [--threaded]\n lettabot-bluesky profile <did|handle> --agent <name>\n lettabot-bluesky thread <at://...> --agent <name>\n lettabot-bluesky author-feed <did|handle> --limit 25 --cursor <cursor> --agent <name>\n lettabot-bluesky list-feed <listUri> --limit 25 --cursor <cursor> --agent <name>\n lettabot-bluesky search --query \"...\" --limit 25 --cursor <cursor> --agent <name>\n lettabot-bluesky notifications --limit 25 --cursor <cursor> --reasons mention,reply --agent <name>\n lettabot-bluesky block <did|handle> --agent <name>\n lettabot-bluesky unblock <blockUri> --agent <name>\n lettabot-bluesky mute <did|handle> --agent <name>\n lettabot-bluesky unmute <did|handle> --agent <name>\n lettabot-bluesky blocks --limit 50 --cursor <cursor> --agent <name>\n lettabot-bluesky mutes --limit 50 --cursor <cursor> --agent <name>\n`);
}
function resolveAgentConfig(agents: AgentConfig[], agentName?: string): AgentConfig {
if (agents.length === 0) {
throw new Error('No agents configured.');
}
if (agents.length === 1 && !agentName) {
return agents[0];
}
if (!agentName) {
throw new Error('Multiple agents configured. Use --agent <name>.');
}
const exact = agents.find(agent => agent.name === agentName);
if (exact) return exact;
const lower = agentName.toLowerCase();
const found = agents.find(agent => agent.name.toLowerCase() === lower);
if (!found) throw new Error(`Agent not found: ${agentName}`);
return found;
}
function resolveBlueskyConfig(agent: AgentConfig): BlueskyConfig {
const config = agent.channels?.bluesky as BlueskyConfig | undefined;
if (!config || config.enabled === false) {
throw new Error(`Bluesky not configured for agent ${agent.name}.`);
}
if (!config.handle || !config.appPassword) {
throw new Error('Bluesky handle/app password not configured for this agent.');
}
return config;
}
async function createSession(serviceUrl: string, handle: string, appPassword: string): Promise<{ accessJwt: string; refreshJwt: string; handle: string; did: string }> {
const res = await fetchWithTimeout(`${serviceUrl}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: handle, password: appPassword }),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`createSession failed: ${detail}`);
}
const data = await res.json() as { accessJwt: string; refreshJwt: string; handle: string; did: string };
return data;
}
async function fetchJson(url: string, headers?: Record<string, string>): Promise<unknown> {
const res = await fetchWithTimeout(url, { headers });
if (!res.ok) {
const detail = await res.text();
throw new Error(`Request failed: ${detail}`);
}
return res.json();
}
async function postJson(url: string, body: Record<string, unknown>, headers?: Record<string, string>): Promise<unknown> {
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(headers || {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`Request failed: ${detail}`);
}
if (res.status === 204) return {};
return res.json();
}
async function getRecord(serviceUrl: string, accessJwt: string, uri: string): Promise<{ cid: string; value: Record<string, unknown> }> {
const parsed = parseAtUri(uri);
if (!parsed) throw new Error(`Invalid at:// URI: ${uri}`);
const qs = new URLSearchParams({
repo: parsed.did,
collection: parsed.collection,
rkey: parsed.rkey,
});
const res = await fetchWithTimeout(`${serviceUrl}/xrpc/com.atproto.repo.getRecord?${qs.toString()}`, {
headers: { 'Authorization': `Bearer ${accessJwt}` },
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`getRecord failed: ${detail}`);
}
const data = await res.json() as { cid?: string; value?: Record<string, unknown> };
if (!data.cid || !data.value) throw new Error('getRecord missing cid/value');
return { cid: data.cid, value: data.value };
}
async function getPostFromAppView(appViewUrl: string, accessJwt: string, uri: string): Promise<{ cid: string; value: Record<string, unknown> }> {
const res = await fetchWithTimeout(`${appViewUrl}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}`, {
headers: { 'Authorization': `Bearer ${accessJwt}` },
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`getPostThread failed: ${detail}`);
}
const data = await res.json() as { thread?: { post?: { cid?: string; record?: Record<string, unknown> } } };
const post = data.thread?.post;
if (!post?.cid || !post?.record) throw new Error('getPostThread missing cid/record');
return { cid: post.cid, value: post.record };
}
async function resolveHandleToDid(bluesky: BlueskyConfig, handle: string): Promise<string> {
const appViewUrl = getAppViewUrl(bluesky.appViewUrl);
const url = `${appViewUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
const data = await fetchJson(url) as { did?: string };
if (!data.did) throw new Error(`resolveHandle failed for ${handle}`);
return data.did;
}
const DID_PATTERN = /^did:[a-z]+:[a-zA-Z0-9._:-]+$/;
async function resolveActorDid(bluesky: BlueskyConfig, actor: string): Promise<string> {
if (actor.startsWith('did:')) {
if (!DID_PATTERN.test(actor)) throw new Error(`Invalid DID format: "${actor}"`);
return actor;
}
const did = await resolveHandleToDid(bluesky, actor);
if (!DID_PATTERN.test(did)) throw new Error(`Handle "${actor}" resolved to invalid DID: "${did}"`);
return did;
}
async function ensureCid(serviceUrl: string, appViewUrl: string, accessJwt: string, uri: string, cid?: string): Promise<string> {
if (cid) return cid;
try {
const record = await getRecord(serviceUrl, accessJwt, uri);
return record.cid;
} catch (err) {
// Fallback to AppView if PDS lookup fails (e.g., cross-PDS records)
const record = await getPostFromAppView(appViewUrl, accessJwt, uri);
return record.cid;
}
}
async function createRecord(
serviceUrl: string,
accessJwt: string,
repo: string,
collection: string,
record: Record<string, unknown>,
): Promise<{ uri?: string; cid?: string }> {
const res = await fetchWithTimeout(`${serviceUrl}/xrpc/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessJwt}`,
},
body: JSON.stringify({ repo, collection, record }),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`createRecord failed: ${detail}`);
}
return res.json() as Promise<{ uri?: string; cid?: string }>;
}
async function uploadBlob(
serviceUrl: string,
accessJwt: string,
filePath: string,
mimeType: string,
): Promise<{ blob: unknown }> {
const data = readFileSync(filePath);
const res = await fetchWithTimeout(`${serviceUrl}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessJwt}`,
'Content-Type': mimeType,
},
body: data,
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`uploadBlob failed (${mimeType}): ${detail}`);
}
const result = await res.json() as { blob?: unknown };
if (!result.blob) throw new Error('uploadBlob response missing blob field');
return { blob: result.blob };
}
type ImageFeatures = { sendFileDir?: string; sendFileMaxSize?: number } | undefined;
type ImageEntry = { path: string; alt: string };
async function validateAndUploadImages(
serviceUrl: string,
accessJwt: string,
entries: ImageEntry[],
features: ImageFeatures,
): Promise<Array<{ image: unknown; alt: string }>> {
if (entries.length === 0) return [];
if (entries.length > BLUESKY_IMAGE_MAX_COUNT) {
throw new Error(`Too many images: max ${BLUESKY_IMAGE_MAX_COUNT}, got ${entries.length}`);
}
const workingDir = process.cwd();
const allowedDir = resolve(workingDir, features?.sendFileDir ?? join('data', 'outbound'));
const maxSize = Math.min(features?.sendFileMaxSize ?? 50 * 1024 * 1024, BLUESKY_IMAGE_MAX_BYTES);
const result: Array<{ image: unknown; alt: string }> = [];
for (const { path: imagePath, alt } of entries) {
const resolvedPath = resolve(workingDir, imagePath);
if (!await isPathAllowed(resolvedPath, allowedDir)) {
throw new Error(`Image path is outside the allowed directory (${allowedDir}): ${imagePath}`);
}
let size: number;
try {
size = statSync(resolvedPath).size;
} catch {
throw new Error(`Image file not found or not readable: ${imagePath}`);
}
if (size > maxSize) {
throw new Error(`Image too large (${size} bytes, max ${maxSize}): ${imagePath}`);
}
const imageExt = extname(resolvedPath).toLowerCase();
const mimeType = BLUESKY_IMAGE_MIMES[imageExt];
if (!mimeType) {
throw new Error(
`Unsupported image type "${imageExt}" for ${imagePath}. Supported: ${Object.keys(BLUESKY_IMAGE_MIMES).join(', ')}`,
);
}
const { blob } = await uploadBlob(serviceUrl, accessJwt, resolvedPath, mimeType);
result.push({ image: blob, alt });
}
return result;
}
async function handlePost(
bluesky: BlueskyConfig,
features: ImageFeatures,
text: string,
replyTo?: string,
threaded = false,
images: ImageEntry[] = [],
): Promise<void> {
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const appViewUrl = getAppViewUrl(bluesky.appViewUrl);
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
const charCount = [...new Intl.Segmenter().segment(text)].length;
if (charCount > POST_MAX_CHARS && !threaded) {
throw new Error(`Post is ${charCount} chars. Use --threaded to split into a thread.`);
}
const chunks = threaded ? splitPostText(text) : [text.trim()];
if (!chunks[0] || !chunks[0].trim()) {
throw new Error('Refusing to post empty text.');
}
if (images.length > 0 && threaded && chunks.length > 1) {
log.warn('Images will only be attached to the first post in the thread.');
}
const imageBlobs = await validateAndUploadImages(serviceUrl, session.accessJwt, images, features);
const agent = new AtpAgent({ service: serviceUrl });
await agent.resumeSession({ accessJwt: session.accessJwt, refreshJwt: session.refreshJwt, did: session.did, handle: session.handle, active: true });
let rootUri: string | undefined;
let rootCid: string | undefined;
let parentUri: string | undefined;
let parentCid: string | undefined;
if (replyTo) {
let parent;
try {
parent = await getRecord(serviceUrl, session.accessJwt, replyTo);
} catch (err) {
// Fallback to AppView if PDS lookup fails (e.g., cross-PDS records)
parent = await getPostFromAppView(appViewUrl, session.accessJwt, replyTo);
}
parentUri = replyTo;
parentCid = parent.cid;
const reply = (parent.value.reply as { root?: { uri?: string; cid?: string } } | undefined) || undefined;
rootUri = reply?.root?.uri || parentUri;
rootCid = reply?.root?.cid || parentCid;
}
const createdAt = new Date().toISOString();
let lastUri = '';
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
// Parse facets for clickable links, mentions, hashtags
const facets = await parseFacets(chunk, agent);
const record: Record<string, unknown> = {
text: chunk,
createdAt,
};
// Add facets if any were detected (links, mentions, hashtags)
if (facets.length > 0) {
record.facets = facets;
}
if (parentUri && parentCid && rootUri && rootCid) {
record.reply = {
root: { uri: rootUri, cid: rootCid },
parent: { uri: parentUri, cid: parentCid },
};
}
if (i === 0 && imageBlobs.length > 0) {
record.embed = { $type: 'app.bsky.embed.images', images: imageBlobs };
}
const created = await createRecord(serviceUrl, session.accessJwt, session.did, 'app.bsky.feed.post', record);
if (!created.uri) throw new Error('createRecord returned no uri');
lastUri = created.uri;
if (i === 0 && !replyTo && chunks.length > 1) {
rootUri = created.uri;
rootCid = await ensureCid(serviceUrl, appViewUrl, session.accessJwt, created.uri, created.cid);
parentUri = rootUri;
parentCid = rootCid;
} else if (i < chunks.length - 1) {
parentUri = created.uri;
parentCid = await ensureCid(serviceUrl, appViewUrl, session.accessJwt, created.uri, created.cid);
if (!rootUri || !rootCid) {
rootUri = parentUri;
rootCid = parentCid;
}
}
}
console.log(`✓ Posted: ${lastUri}`);
}
async function handleQuote(
bluesky: BlueskyConfig,
targetUri: string,
text: string,
threaded = false,
): Promise<void> {
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const appViewUrl = getAppViewUrl(bluesky.appViewUrl);
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
const charCount = [...new Intl.Segmenter().segment(text)].length;
if (charCount > POST_MAX_CHARS && !threaded) {
throw new Error(`Post is ${charCount} chars. Use --threaded to split into a thread.`);
}
let target;
try {
target = await getRecord(serviceUrl, session.accessJwt, targetUri);
} catch (err) {
// Fallback to AppView if PDS lookup fails (e.g., cross-PDS records)
target = await getPostFromAppView(appViewUrl, session.accessJwt, targetUri);
}
const chunks = threaded ? splitPostText(text) : [text.trim()];
if (!chunks[0] || !chunks[0].trim()) {
throw new Error('Refusing to post empty text.');
}
const agent = new AtpAgent({ service: serviceUrl });
await agent.resumeSession({ accessJwt: session.accessJwt, refreshJwt: session.refreshJwt, did: session.did, handle: session.handle, active: true });
let rootUri: string | undefined;
let rootCid: string | undefined;
let parentUri: string | undefined;
let parentCid: string | undefined;
const createdAt = new Date().toISOString();
let lastUri = '';
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
// Parse facets for clickable links, mentions, hashtags
const facets = await parseFacets(chunk, agent);
const record: Record<string, unknown> = {
text: chunk,
createdAt,
};
// Add facets if any were detected (links, mentions, hashtags)
if (facets.length > 0) {
record.facets = facets;
}
if (i === 0) {
record.embed = {
$type: 'app.bsky.embed.record',
record: {
uri: targetUri,
cid: target.cid,
},
};
}
if (parentUri && parentCid && rootUri && rootCid) {
record.reply = {
root: { uri: rootUri, cid: rootCid },
parent: { uri: parentUri, cid: parentCid },
};
}
const created = await createRecord(serviceUrl, session.accessJwt, session.did, 'app.bsky.feed.post', record);
if (!created.uri) throw new Error('createRecord returned no uri');
lastUri = created.uri;
if (i === 0 && chunks.length > 1) {
rootUri = created.uri;
rootCid = await ensureCid(serviceUrl, appViewUrl, session.accessJwt, created.uri, created.cid);
parentUri = rootUri;
parentCid = rootCid;
} else if (i < chunks.length - 1) {
parentUri = created.uri;
parentCid = await ensureCid(serviceUrl, appViewUrl, session.accessJwt, created.uri, created.cid);
if (!rootUri || !rootCid) {
rootUri = parentUri;
rootCid = parentCid;
}
}
}
console.log(`✓ Quoted: ${lastUri}`);
}
async function handleSubjectRecord(
bluesky: BlueskyConfig,
uri: string,
collection: 'app.bsky.feed.like' | 'app.bsky.feed.repost',
): Promise<void> {
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const appViewUrl = getAppViewUrl(bluesky.appViewUrl);
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
let record;
try {
record = await getRecord(serviceUrl, session.accessJwt, uri);
} catch (err) {
// Fallback to AppView if PDS lookup fails (e.g., cross-PDS records)
record = await getPostFromAppView(appViewUrl, session.accessJwt, uri);
}
const createdAt = new Date().toISOString();
const res = await createRecord(serviceUrl, session.accessJwt, session.did, collection, {
subject: { uri, cid: record.cid },
createdAt,
});
if (!res.uri) throw new Error('createRecord returned no uri');
console.log(`${collection === 'app.bsky.feed.like' ? 'Liked' : 'Reposted'}: ${uri}`);
}
async function handleMute(bluesky: BlueskyConfig, actor: string, muted: boolean): Promise<void> {
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
const did = await resolveActorDid(bluesky, actor);
const endpoint = muted ? 'app.bsky.graph.muteActor' : 'app.bsky.graph.unmuteActor';
await postJson(`${serviceUrl}/xrpc/${endpoint}`, { actor: did }, { 'Authorization': `Bearer ${session.accessJwt}` });
console.log(`${muted ? 'Muted' : 'Unmuted'}: ${did}`);
}
async function handleBlock(bluesky: BlueskyConfig, actor: string): Promise<void> {
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
const did = await resolveActorDid(bluesky, actor);
const createdAt = new Date().toISOString();
const res = await createRecord(serviceUrl, session.accessJwt, session.did, 'app.bsky.graph.block', {
subject: did,
createdAt,
});
if (!res.uri) throw new Error('createRecord returned no uri');
console.log(`✓ Blocked: ${did} (${res.uri})`);
}
async function handleUnblock(bluesky: BlueskyConfig, blockUri: string): Promise<void> {
const parsed = parseAtUri(blockUri);
if (!parsed || parsed.collection !== 'app.bsky.graph.block') {
throw new Error('unblock requires a block record URI (at://.../app.bsky.graph.block/...)');
}
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
await postJson(
`${serviceUrl}/xrpc/com.atproto.repo.deleteRecord`,
{
repo: session.did,
collection: parsed.collection,
rkey: parsed.rkey,
},
{ 'Authorization': `Bearer ${session.accessJwt}` },
);
console.log(`✓ Unblocked: ${blockUri}`);
}
async function handleReadCommand(
bluesky: BlueskyConfig,
command: string,
uriArg: string,
query: string,
cursor: string,
limit?: number,
reasons?: string[],
priority?: boolean,
): Promise<void> {
const appViewUrl = getAppViewUrl(bluesky.appViewUrl);
const serviceUrl = (bluesky.serviceUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
const effectiveLimit = limit ?? 25;
if (command === 'resolve') {
if (!uriArg) throw new Error('Missing handle');
const url = `${appViewUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(uriArg)}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'profile') {
if (!uriArg) throw new Error('Missing actor');
const url = `${appViewUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(uriArg)}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'thread') {
if (!uriArg) throw new Error('Missing post URI');
const url = `${appViewUrl}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uriArg)}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'author-feed') {
if (!uriArg) throw new Error('Missing actor');
const qs = new URLSearchParams();
qs.set('actor', uriArg);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${appViewUrl}/xrpc/app.bsky.feed.getAuthorFeed?${qs.toString()}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'list-feed') {
if (!uriArg) throw new Error('Missing list URI');
const qs = new URLSearchParams();
qs.set('list', uriArg);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${appViewUrl}/xrpc/app.bsky.feed.getListFeed?${qs.toString()}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'actor-feeds') {
if (!uriArg) throw new Error('Missing actor');
const qs = new URLSearchParams();
qs.set('actor', uriArg);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${appViewUrl}/xrpc/app.bsky.feed.getActorFeeds?${qs.toString()}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'followers') {
if (!uriArg) throw new Error('Missing actor');
const qs = new URLSearchParams();
qs.set('actor', uriArg);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${appViewUrl}/xrpc/app.bsky.graph.getFollowers?${qs.toString()}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'follows') {
if (!uriArg) throw new Error('Missing actor');
const qs = new URLSearchParams();
qs.set('actor', uriArg);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${appViewUrl}/xrpc/app.bsky.graph.getFollows?${qs.toString()}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'lists') {
if (!uriArg) throw new Error('Missing actor');
const qs = new URLSearchParams();
qs.set('actor', uriArg);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${appViewUrl}/xrpc/app.bsky.graph.getLists?${qs.toString()}`;
const data = await fetchJson(url);
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'search' || command === 'timeline' || command === 'notifications' || command === 'blocks' || command === 'mutes') {
const session = await createSession(serviceUrl, bluesky.handle!, bluesky.appPassword!);
if (command === 'search') {
if (!query) throw new Error('Missing query');
const qs = new URLSearchParams();
qs.set('q', query);
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${serviceUrl}/xrpc/app.bsky.feed.searchPosts?${qs.toString()}`;
const data = await fetchJson(url, { 'Authorization': `Bearer ${session.accessJwt}` });
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'timeline') {
const qs = new URLSearchParams();
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${serviceUrl}/xrpc/app.bsky.feed.getTimeline?${qs.toString()}`;
const data = await fetchJson(url, { 'Authorization': `Bearer ${session.accessJwt}` });
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'notifications') {
const qs = new URLSearchParams();
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
if (priority) qs.set('priority', 'true');
if (reasons && reasons.length > 0) {
for (const reason of reasons) qs.append('reasons', reason);
}
const url = `${serviceUrl}/xrpc/app.bsky.notification.listNotifications?${qs.toString()}`;
const data = await fetchJson(url, { 'Authorization': `Bearer ${session.accessJwt}` });
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'blocks') {
const qs = new URLSearchParams();
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${serviceUrl}/xrpc/app.bsky.graph.getBlocks?${qs.toString()}`;
const data = await fetchJson(url, { 'Authorization': `Bearer ${session.accessJwt}` });
console.log(JSON.stringify(data, null, 2));
return;
}
if (command === 'mutes') {
const qs = new URLSearchParams();
qs.set('limit', String(effectiveLimit));
if (cursor) qs.set('cursor', cursor);
const url = `${serviceUrl}/xrpc/app.bsky.graph.getMutes?${qs.toString()}`;
const data = await fetchJson(url, { 'Authorization': `Bearer ${session.accessJwt}` });
console.log(JSON.stringify(data, null, 2));
return;
}
}
throw new Error(`Unknown read command: ${command}`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args.shift();
if (!command || command === 'help' || command === '--help' || command === '-h') {
usage();
process.exit(command ? 0 : 1);
}
let agentName = '';
let text = '';
let replyTo = '';
let threaded = false;
let uriArg = '';
let actor = '';
let blockUri = '';
let cursor = '';
let query = '';
let limit: number | undefined;
let reasons: string[] | undefined;
let priority = false;
const imageEntries: ImageEntry[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
if (arg === '--agent' && next) {
agentName = next;
i++;
} else if ((arg === '--text' || arg === '-t') && next) {
text = next;
i++;
} else if ((arg === '--query' || arg === '-q') && next) {
query = next;
i++;
} else if (arg === '--reply-to' && next) {
replyTo = next;
i++;
} else if (arg === '--actor' && next) {
actor = next;
i++;
} else if (arg === '--block-uri' && next) {
blockUri = next;
i++;
} else if (arg === '--cursor' && next) {
cursor = next;
i++;
} else if (arg === '--threaded') {
threaded = true;
} else if (arg === '--limit' && next) {
const parsed = parseInt(next, 10);
if (!Number.isNaN(parsed)) limit = parsed;
i++;
} else if (arg === '--reasons' && next) {
reasons = next.split(',').map(v => v.trim()).filter(Boolean);
i++;
} else if (arg === '--priority') {
priority = true;
} else if ((arg === '--image' || arg === '-i') && next) {
imageEntries.push({ path: next, alt: '' });
i++;
} else if ((arg === '--alt' || arg === '-a') && next) {
if (imageEntries.length === 0) throw new Error('--alt requires a preceding --image');
imageEntries[imageEntries.length - 1].alt = next;
i++;
} else if (arg === '--quote' && next) {
uriArg = next;
i++;
} else if (!arg.startsWith('-') && !uriArg) {
uriArg = arg;
} else {
log.warn(`Unknown arg: ${arg}`);
}
}
const config = loadAppConfigOrExit();
const agents = normalizeAgents(config);
const agent = resolveAgentConfig(agents, agentName || undefined);
const bluesky = resolveBlueskyConfig(agent);
if (command === 'post') {
if (!text) throw new Error('Missing --text');
await handlePost(bluesky, agent.features, text, replyTo || undefined, threaded, imageEntries);
return;
}
if (command === 'like') {
if (!uriArg) throw new Error('Missing post URI');
await handleSubjectRecord(bluesky, uriArg, 'app.bsky.feed.like');
return;
}
if (command === 'repost') {
if (!uriArg) throw new Error('Missing post URI');
if (text) {
await handleQuote(bluesky, uriArg, text, threaded);
} else {
await handleSubjectRecord(bluesky, uriArg, 'app.bsky.feed.repost');
}
return;
}
if (command === 'mute') {
const target = actor || uriArg;
if (!target) throw new Error('Missing actor');
await handleMute(bluesky, target, true);
return;
}
if (command === 'unmute') {
const target = actor || uriArg;
if (!target) throw new Error('Missing actor');
await handleMute(bluesky, target, false);
return;
}
if (command === 'block') {
const target = actor || uriArg;
if (!target) throw new Error('Missing actor');
await handleBlock(bluesky, target);
return;
}
if (command === 'unblock') {
const target = blockUri || uriArg;
if (!target) throw new Error('Missing block URI');
await handleUnblock(bluesky, target);
return;
}
if ([
'resolve',
'profile',
'thread',
'author-feed',
'list-feed',
'actor-feeds',
'followers',
'follows',
'lists',
'search',
'timeline',
'notifications',
'blocks',
'mutes',
].includes(command)) {
await handleReadCommand(bluesky, command, uriArg, query, cursor, limit, reasons, priority);
return;
}
console.error(`Unknown command: ${command}`);
usage();
process.exit(1);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});

View File

@@ -0,0 +1,15 @@
export const DEFAULT_JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe';
export const RECONNECT_BASE_MS = 1000;
export const RECONNECT_MAX_MS = 60000;
export const CURSOR_BACKTRACK_US = 5_000_000; // 5 seconds
export const STATE_FILENAME = 'bluesky-jetstream.json';
export const STATE_FLUSH_INTERVAL_MS = 10_000;
export const DEFAULT_SERVICE_URL = 'https://bsky.social';
export const DEFAULT_APPVIEW_URL = 'https://public.api.bsky.app';
export const POST_MAX_CHARS = 300;
export const DEFAULT_NOTIFICATIONS_INTERVAL_SEC = 60;
export const DEFAULT_NOTIFICATIONS_LIMIT = 50;
export const HANDLE_CACHE_MAX = 10_000;
export const LAST_POST_CACHE_MAX = 5_000;
export const SEEN_MESSAGE_IDS_MAX = 5_000;
export const STATE_VERSION = 1;

View File

@@ -0,0 +1,98 @@
import { isRecord, readString, readStringArray, truncate } from './utils.js';
export function parseReplyRefs(record: Record<string, unknown>): {
rootUri?: string;
rootCid?: string;
parentUri?: string;
parentCid?: string;
} {
const reply = isRecord(record.reply) ? record.reply : undefined;
if (!reply) return {};
const root = isRecord(reply.root) ? reply.root : undefined;
const parent = isRecord(reply.parent) ? reply.parent : undefined;
return {
rootUri: readString(root?.uri),
rootCid: readString(root?.cid),
parentUri: readString(parent?.uri),
parentCid: readString(parent?.cid),
};
}
export function extractPostDetails(record: Record<string, unknown>): {
text?: string;
createdAt?: string;
langs: string[];
replyRefs: ReturnType<typeof parseReplyRefs>;
embedLines: string[];
} {
const text = readString(record.text)?.trim();
const createdAt = readString(record.createdAt);
const langs = readStringArray(record.langs);
const replyRefs = parseReplyRefs(record);
const embedLines = summarizeEmbed(record.embed);
return { text, createdAt, langs, replyRefs, embedLines };
}
export function summarizeEmbed(embed: unknown): string[] {
if (!isRecord(embed)) return [];
const embedType = readString(embed.$type);
const lines: string[] = [];
if (embedType === 'app.bsky.embed.images') {
const images = Array.isArray(embed.images) ? embed.images : [];
const altTexts = images
.map((img) => (isRecord(img) ? readString(img.alt) : undefined))
.filter((alt): alt is string => !!alt && alt.trim().length > 0);
const summary = `Embed: ${images.length} image(s)`;
if (altTexts.length > 0) {
lines.push(`${summary} (alt: ${truncate(altTexts[0], 120)})`);
} else {
lines.push(summary);
}
return lines;
}
if (embedType === 'app.bsky.embed.external') {
const external = isRecord(embed.external) ? embed.external : undefined;
const title = external ? readString(external.title) : undefined;
const uri = external ? readString(external.uri) : undefined;
const description = external ? readString(external.description) : undefined;
const titlePart = title ? ` "${truncate(title, 160)}"` : '';
const uriPart = uri ? ` ${uri}` : '';
lines.push(`Embed: link${titlePart}${uriPart}`);
if (description) {
lines.push(`Embed description: ${truncate(description, 240)}`);
}
return lines;
}
if (embedType === 'app.bsky.embed.record') {
const record = isRecord(embed.record) ? embed.record : undefined;
const uri = record ? readString(record.uri) : undefined;
if (uri) {
lines.push(`Embed: record ${uri}`);
} else {
lines.push('Embed: record');
}
return lines;
}
if (embedType === 'app.bsky.embed.recordWithMedia') {
const record = isRecord(embed.record) ? embed.record : undefined;
const uri = record ? readString(record.uri) : undefined;
if (uri) {
lines.push(`Embed: record ${uri}`);
} else {
lines.push('Embed: record');
}
lines.push(...summarizeEmbed(embed.media));
return lines;
}
if (embedType) {
lines.push(`Embed: ${embedType}`);
}
return lines;
}

View File

@@ -0,0 +1,2 @@
export { BlueskyAdapter } from './adapter.js';
export type { BlueskyConfig, JetstreamEvent, JetstreamCommit, DidMode } from './types.js';

View File

@@ -0,0 +1,69 @@
import type { InboundMessage } from '../../core/types.js';
export type DidMode = 'open' | 'listen' | 'mention-only' | 'disabled';
/**
* AT Protocol source identifiers attached to Bluesky inbound messages.
* Used by the adapter to construct replies, reposts, and likes.
*/
export interface BlueskySource {
uri?: string;
collection?: string;
cid?: string;
rkey?: string;
threadRootUri?: string;
threadParentUri?: string;
threadRootCid?: string;
threadParentCid?: string;
subjectUri?: string;
subjectCid?: string;
}
/**
* Bluesky-specific inbound message carrying AT Protocol source metadata
* and display context. Extends InboundMessage without polluting the core type.
*/
export interface BlueskyInboundMessage extends InboundMessage {
source?: BlueskySource;
extraContext?: Record<string, string>;
}
export interface BlueskyConfig {
enabled?: boolean;
agentName?: string;
jetstreamUrl?: string;
wantedDids?: string[] | string;
wantedCollections?: string[] | string;
cursor?: number;
handle?: string;
appPassword?: string;
serviceUrl?: string;
appViewUrl?: string;
groups?: Record<string, { mode?: DidMode }>;
lists?: Record<string, { mode?: DidMode }>;
notifications?: {
enabled?: boolean;
intervalSec?: number;
limit?: number;
priority?: boolean;
reasons?: string[] | string;
backfill?: boolean;
};
}
export interface JetstreamCommit {
operation?: string;
collection?: string;
rkey?: string;
cid?: string;
record?: Record<string, unknown>;
}
export interface JetstreamEvent {
kind?: string;
did?: string;
time_us?: number;
commit?: JetstreamCommit;
identity?: { handle?: string };
account?: { handle?: string };
}

View File

@@ -0,0 +1,172 @@
export function normalizeList(value?: string[] | string): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.map(v => v.trim()).filter(Boolean);
return value.split(',').map(v => v.trim()).filter(Boolean);
}
export function uniqueList(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
export function truncate(value: string, max = 2000): string {
if (value.length <= max) return value;
return value.slice(0, max) + '...';
}
export function pruneMap<T>(map: Map<string, T>, max: number): void {
while (map.size > max) {
const oldest = map.keys().next().value;
if (!oldest) break;
map.delete(oldest);
}
}
export function buildAtUri(did?: string, collection?: string, rkey?: string): string | undefined {
if (!did || !collection || !rkey) return undefined;
return `at://${did}/${collection}/${rkey}`;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function readString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
export function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.map(v => (typeof v === 'string' ? v.trim() : '')).filter(Boolean)
: [];
}
/** Default timeout for Bluesky API calls (15 seconds) */
export const FETCH_TIMEOUT_MS = 15_000;
/**
* fetch() wrapper with an AbortController timeout.
* Throws on timeout just like a network error.
*/
export async function fetchWithTimeout(
url: string,
init: RequestInit = {},
timeoutMs = FETCH_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | undefined {
if (!uri.startsWith('at://')) return undefined;
const parts = uri.slice('at://'.length).split('/');
if (parts.length < 3) return undefined;
return { did: parts[0], collection: parts[1], rkey: parts[2] };
}
export function getAppViewUrl(appViewUrl?: string, defaultUrl = 'https://public.api.bsky.app'): string {
return (appViewUrl || defaultUrl).replace(/\/+$/, '');
}
export function splitPostText(text: string, maxChars = 300): string[] {
const segmenter = new Intl.Segmenter();
const graphemes = [...segmenter.segment(text)].map(s => s.segment);
if (graphemes.length === 0) return [];
if (graphemes.length <= maxChars) {
const trimmed = text.trim();
return trimmed ? [trimmed] : [];
}
const chunks: string[] = [];
let start = 0;
while (start < graphemes.length) {
let end = Math.min(start + maxChars, graphemes.length);
if (end < graphemes.length) {
let split = end;
for (let i = end - 1; i > start; i--) {
if (/\s/.test(graphemes[i])) {
split = i;
break;
}
}
end = split > start ? split : end;
}
let chunk = graphemes.slice(start, end).join('');
chunk = chunk.replace(/^\s+/, '').replace(/\s+$/, '');
if (chunk) chunks.push(chunk);
start = end;
while (start < graphemes.length && /\s/.test(graphemes[start])) {
start++;
}
}
return chunks;
}
import { AtpAgent, RichText } from '@atproto/api';
/**
* Parse text and generate AT Protocol facets (links, mentions, hashtags).
* When an authenticated agent is provided, @mention handles are resolved to DIDs.
* Without an agent, links and hashtags work but mentions won't have DIDs.
*/
export async function parseFacets(text: string, agent?: AtpAgent): Promise<Record<string, unknown>[]> {
const rt = new RichText({ text });
if (agent) {
await rt.detectFacets(agent);
} else {
rt.detectFacetsWithoutResolution();
}
if (!rt.facets || rt.facets.length === 0) return [];
return rt.facets.map(facet => ({
index: { byteStart: facet.index.byteStart, byteEnd: facet.index.byteEnd },
features: facet.features.map(feature => {
const type = feature.$type;
if (type === 'app.bsky.richtext.facet#link') {
const f = feature as { $type: string; uri: string };
return { $type: type, uri: f.uri };
}
if (type === 'app.bsky.richtext.facet#mention') {
const f = feature as { $type: string; did: string };
return { $type: type, did: f.did };
}
if (type === 'app.bsky.richtext.facet#tag') {
const f = feature as { $type: string; tag: string };
return { $type: type, tag: f.tag };
}
return { $type: type };
}),
}));
}
export function decodeJwtExp(jwt: string): number | undefined {
const parts = jwt.split('.');
if (parts.length < 2) return undefined;
try {
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = payload.padEnd(payload.length + (4 - (payload.length % 4 || 4)), '=');
const json = Buffer.from(padded, 'base64').toString('utf-8');
const data = JSON.parse(json) as { exp?: number };
if (typeof data.exp === 'number') {
return data.exp * 1000;
}
} catch {
// ignore
}
return undefined;
}

View File

@@ -1,3 +1,4 @@
import { BlueskyAdapter } from './bluesky.js';
import { DiscordAdapter } from './discord.js';
import { SignalAdapter } from './signal.js';
import { SlackAdapter } from './slack.js';
@@ -182,5 +183,30 @@ export function createChannelsForAgent(
}
}
// Bluesky: only start if there's something to subscribe to
if (agentConfig.channels.bluesky?.enabled) {
const bsky = agentConfig.channels.bluesky;
const hasWantedDids = !!bsky.wantedDids?.length;
const hasLists = !!(bsky.lists && Object.keys(bsky.lists).length > 0);
const hasAuth = !!bsky.handle;
const wantsNotifications = !!bsky.notifications?.enabled;
if (hasWantedDids || hasLists || hasAuth || wantsNotifications) {
adapters.push(new BlueskyAdapter({
agentName: agentConfig.name,
jetstreamUrl: bsky.jetstreamUrl,
wantedDids: bsky.wantedDids,
wantedCollections: bsky.wantedCollections,
cursor: bsky.cursor,
handle: bsky.handle,
appPassword: bsky.appPassword,
serviceUrl: bsky.serviceUrl,
appViewUrl: bsky.appViewUrl,
groups: bsky.groups,
lists: bsky.lists,
notifications: bsky.notifications,
}));
}
}
return adapters;
}

View File

@@ -10,3 +10,4 @@ export * from './slack.js';
export * from './whatsapp/index.js';
export * from './signal.js';
export * from './discord.js';
export * from './bluesky.js';

View File

@@ -7,6 +7,7 @@
import { spawnSync } from 'node:child_process';
import * as p from '@clack/prompts';
import type { BlueskyConfig } from '../config/types.js';
// ============================================================================
// Channel Metadata
@@ -18,6 +19,7 @@ export const CHANNELS = [
{ id: 'discord', displayName: 'Discord', hint: 'Bot token + Message Content intent' },
{ id: 'whatsapp', displayName: 'WhatsApp', hint: 'QR code pairing' },
{ id: 'signal', displayName: 'Signal', hint: 'signal-cli daemon' },
{ id: 'bluesky', displayName: 'Bluesky', hint: 'Jetstream feed (read-only)' },
] as const;
export type ChannelId = typeof CHANNELS[number]['id'];
@@ -56,6 +58,8 @@ const GROUP_ID_HINTS: Record<ChannelId, string> = {
'(e.g., 120363123456@g.us).',
signal:
'Group IDs appear in bot logs on first group message.',
bluesky:
'Bluesky does not support groups. This setting is not used.',
};
// ============================================================================
@@ -562,6 +566,158 @@ export async function setupSignal(existing?: any): Promise<any> {
};
}
export async function setupBluesky(existing?: BlueskyConfig): Promise<BlueskyConfig> {
p.note(
'Uses the Bluesky Jetstream WebSocket feed (read-only).\n' +
'Provide one or more DID(s) to filter the stream.\n' +
'Example DID: did:plc:i3n7ma327gght4kiea5dvpyn',
'Bluesky Setup'
);
const didsRaw = await p.text({
message: 'Wanted DID(s) (comma-separated)',
placeholder: 'did:plc:...',
initialValue: Array.isArray(existing?.wantedDids)
? existing.wantedDids.join(',')
: (existing?.wantedDids || ''),
});
if (p.isCancel(didsRaw)) {
p.cancel('Cancelled');
process.exit(0);
}
const wantedDids = typeof didsRaw === 'string'
? didsRaw.split(',').map(s => s.trim()).filter(Boolean)
: [];
if (wantedDids.length === 0) {
p.log.warn('No DID provided. The stream may be very noisy without filters.');
}
const collectionsRaw = await p.text({
message: 'Wanted collections (optional, comma-separated)',
placeholder: 'app.bsky.feed.post',
initialValue: Array.isArray(existing?.wantedCollections)
? existing.wantedCollections.join(',')
: (existing?.wantedCollections || ''),
});
if (p.isCancel(collectionsRaw)) {
p.cancel('Cancelled');
process.exit(0);
}
const wantedCollections = typeof collectionsRaw === 'string'
? collectionsRaw.split(',').map(s => s.trim()).filter(Boolean)
: [];
const jetstreamUrl = await p.text({
message: 'Jetstream WebSocket URL (blank = default)',
placeholder: 'wss://jetstream2.us-east.bsky.network/subscribe',
initialValue: existing?.jetstreamUrl || '',
});
if (p.isCancel(jetstreamUrl)) {
p.cancel('Cancelled');
process.exit(0);
}
const defaultMode = await p.select({
message: 'Default Bluesky behavior',
options: [
{ value: 'listen', label: 'Listen (recommended)', hint: 'Observe only' },
{ value: 'open', label: 'Open', hint: 'Reply to posts' },
{ value: 'mention-only', label: 'Mention-only', hint: 'Reply only when @mentioned' },
{ value: 'disabled', label: 'Disabled', hint: 'Ignore all events' },
],
initialValue: existing?.groups?.['*']?.mode || 'listen',
});
if (p.isCancel(defaultMode)) {
p.cancel('Cancelled');
process.exit(0);
}
const enablePosting = await p.confirm({
message: 'Configure Bluesky posting credentials? (required to reply)',
initialValue: !!(existing?.handle && existing?.appPassword),
});
if (p.isCancel(enablePosting)) {
p.cancel('Cancelled');
process.exit(0);
}
let handle: string | undefined;
let appPassword: string | undefined;
let serviceUrl: string | undefined;
if (enablePosting) {
p.note(
'Replies require a Bluesky app password.\n' +
'Create one in Settings → App passwords.',
'Bluesky Posting'
);
const handleInput = await p.text({
message: 'Bluesky handle (e.g., you.bsky.social)',
placeholder: 'you.bsky.social',
initialValue: existing?.handle || '',
});
if (p.isCancel(handleInput)) {
p.cancel('Cancelled');
process.exit(0);
}
const appPasswordInput = await p.password({
message: 'Bluesky app password (format: xxxx-xxxx-xxxx-xxxx)',
validate: (v) => {
if (!v) return 'App password is required.';
if (!/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(v)) {
return 'Expected format: xxxx-xxxx-xxxx-xxxx (lowercase letters and digits).';
}
},
});
if (p.isCancel(appPasswordInput)) {
p.cancel('Cancelled');
process.exit(0);
}
const serviceUrlInput = await p.text({
message: 'ATProto service URL (blank = https://bsky.social)',
placeholder: 'https://bsky.social',
initialValue: existing?.serviceUrl || '',
});
if (p.isCancel(serviceUrlInput)) {
p.cancel('Cancelled');
process.exit(0);
}
handle = handleInput || undefined;
appPassword = appPasswordInput || undefined;
serviceUrl = serviceUrlInput || undefined;
}
const groups = {
'*': { mode: defaultMode as 'open' | 'listen' | 'mention-only' | 'disabled' },
};
return {
enabled: true,
wantedDids,
wantedCollections: wantedCollections.length > 0 ? wantedCollections : undefined,
jetstreamUrl: jetstreamUrl || undefined,
groups,
handle,
appPassword,
serviceUrl,
};
}
/** Get the setup function for a channel */
export function getSetupFunction(id: ChannelId): (existing?: any) => Promise<any> {
const setupFunctions: Record<ChannelId, (existing?: any) => Promise<any>> = {
@@ -570,6 +726,7 @@ export function getSetupFunction(id: ChannelId): (existing?: any) => Promise<any
discord: setupDiscord,
whatsapp: setupWhatsApp,
signal: setupSignal,
bluesky: setupBluesky,
};
return setupFunctions[id];
}

View File

@@ -37,6 +37,7 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-
updateNotifier({ pkg }).notify();
import * as readline from 'node:readline';
import { join } from 'node:path';
const args = process.argv.slice(2);
const command = args[0];
@@ -245,6 +246,7 @@ Commands:
channels list-groups List group/channel IDs for Slack/Discord
channels add <ch> Add a channel (telegram, slack, discord, whatsapp, signal)
channels remove <ch> Remove a channel
bluesky Manage Bluesky and run action commands (post/like/repost/read)
logout Logout from Letta Platform (revoke OAuth tokens)
skills Configure which skills are enabled
skills status Show skills status
@@ -268,6 +270,8 @@ Examples:
lettabot channels # Interactive channel management
lettabot channels add discord # Add Discord integration
lettabot channels remove telegram # Remove Telegram
lettabot bluesky post --text "Hello" --agent MyAgent
lettabot bluesky like at://did:plc:.../app.bsky.feed.post/... --agent MyAgent
lettabot todo add "Deliver morning report" --recurring "daily 8am"
lettabot todo list --actionable
lettabot pairing list telegram # Show pending Telegram pairings
@@ -306,6 +310,288 @@ function getDefaultTodoAgentKey(): string {
return configuredName;
}
const BLUESKY_MANAGEMENT_ACTIONS = new Set([
'add-did',
'add-list',
'set-default',
'refresh-lists',
'disable',
'enable',
'status',
]);
function showBlueskyCommandHelp(): void {
console.log(`
Bluesky Commands:
# Management
bluesky add-did <did> --agent <name> [--mode <open|listen|mention-only|disabled>]
bluesky add-list <listUri> --agent <name> [--mode <open|listen|mention-only|disabled>]
bluesky set-default <open|listen|mention-only|disabled> --agent <name>
bluesky refresh-lists --agent <name>
bluesky disable --agent <name>
bluesky enable --agent <name>
bluesky status --agent <name>
# Actions (same behavior as lettabot-bluesky)
bluesky post --text "Hello" --agent <name>
bluesky post --reply-to <at://...> --text "Reply" --agent <name>
bluesky like <at://...> --agent <name>
bluesky repost <at://...> --agent <name>
bluesky profile <did|handle> --agent <name>
`);
}
function runBlueskyActionCommand(action: string, rest: string[]): void {
const distCliPath = resolve(__dirname, 'channels/bluesky/cli.js');
const srcCliPath = resolve(__dirname, 'channels/bluesky/cli.ts');
let commandToRun: string;
let argsToRun: string[];
if (existsSync(distCliPath)) {
commandToRun = 'node';
argsToRun = [distCliPath, action, ...rest];
} else if (existsSync(srcCliPath)) {
commandToRun = 'npx';
argsToRun = ['tsx', srcCliPath, action, ...rest];
} else {
console.error('Bluesky action commands are unavailable in this install.');
console.error('Expected channels/bluesky/cli to exist in either dist/ or src/.');
process.exit(1);
}
const result = spawnSync(commandToRun, argsToRun, {
stdio: 'inherit',
cwd: process.cwd(),
env: process.env,
});
if (result.error) {
console.error(`Failed to run Bluesky action command: ${result.error.message}`);
process.exit(1);
}
if (typeof result.status === 'number' && result.status !== 0) {
process.exit(result.status);
}
}
async function blueskyCommand(action?: string, rest: string[] = []): Promise<void> {
if (!action) {
showBlueskyCommandHelp();
return;
}
if (!BLUESKY_MANAGEMENT_ACTIONS.has(action)) {
runBlueskyActionCommand(action, rest);
return;
}
const { saveConfig, resolveConfigPath } = await import('./config/index.js');
const config = getConfig();
const getAgentConfig = () => {
if (config.agents && config.agents.length > 0) {
const agent = config.agents.find(a => a.name === agentName);
if (!agent) {
console.error(`Unknown agent: ${agentName}`);
console.error(`Available agents: ${config.agents.map(a => a.name).join(', ')}`);
process.exit(1);
}
if (!agent.channels) {
agent.channels = {} as any;
}
return agent;
}
const configuredName = config.agent?.name?.trim() || 'LettaBot';
if (agentName && agentName !== configuredName) {
console.error(`Unknown agent: ${agentName}`);
console.error(`Available agents: ${configuredName}`);
process.exit(1);
}
if (!config.channels) {
config.channels = {} as any;
}
return { name: configuredName, channels: config.channels } as any;
};
const getAgentChannels = () => getAgentConfig().channels;
const ensureBlueskyConfig = () => {
const channels = getAgentChannels();
if (!channels.bluesky) {
channels.bluesky = { enabled: true } as any;
}
if (!channels.bluesky.groups) {
channels.bluesky.groups = { '*': { mode: 'listen' } } as any;
}
return channels.bluesky as any;
};
const parseModeArg = (args: string[]): string | undefined => {
const idx = args.findIndex(arg => arg === '--mode' || arg === '-m');
if (idx >= 0 && args[idx + 1]) return args[idx + 1];
return undefined;
};
const parseAgentArg = (args: string[]): { agent: string; rest: string[] } => {
const idx = args.findIndex(arg => arg === '--agent' || arg === '-a');
if (idx >= 0 && args[idx + 1]) {
const next = [...args];
next.splice(idx, 2);
return { agent: args[idx + 1], rest: next };
}
return { agent: '', rest: args };
};
const { agent: agentName, rest: args } = parseAgentArg(rest);
if (!agentName) {
console.error('Error: --agent is required for bluesky commands');
process.exit(1);
}
const runtimePath = join(getDataDir(), 'bluesky-runtime.json');
const writeRuntimeState = (patch: Partial<{ disabled: boolean; refreshListsAt: string; reloadConfigAt: string }>): void => {
let state: { agents?: Record<string, { disabled?: boolean; refreshListsAt?: string; reloadConfigAt?: string }> } = {};
if (existsSync(runtimePath)) {
try {
state = JSON.parse(readFileSync(runtimePath, 'utf-8'));
} catch {
state = {};
}
}
const agents = state.agents && typeof state.agents === 'object'
? { ...state.agents }
: {};
agents[agentName] = {
...(agents[agentName] || {}),
...patch,
};
const next = { agents, updatedAt: new Date().toISOString() };
writeFileSync(runtimePath, JSON.stringify(next, null, 2), { mode: 0o600 });
};
switch (action) {
case 'add-did': {
const did = args[0];
if (!did) {
console.error('Usage: lettabot bluesky add-did <did> --agent <name> [--mode <mode>]');
process.exit(1);
}
if (!did.startsWith('did:')) {
console.error(`Error: "${did}" does not look like a DID (must start with "did:")`);
process.exit(1);
}
const agentChannels = getAgentChannels();
const mode = parseModeArg(args) || agentChannels.bluesky?.groups?.['*']?.mode || 'listen';
const validModes = ['open', 'listen', 'mention-only', 'disabled'];
if (!validModes.includes(mode)) {
console.error(`Error: unknown mode "${mode}". Valid modes: ${validModes.join(', ')}`);
process.exit(1);
}
const bluesky = ensureBlueskyConfig();
bluesky.groups = bluesky.groups || { '*': { mode: 'listen' } };
bluesky.groups[did] = { mode: mode as any };
saveConfig(config);
writeRuntimeState({ reloadConfigAt: new Date().toISOString() });
console.log(`✓ Added DID ${did} with mode ${mode}`);
console.log(` Config: ${resolveConfigPath()}`);
break;
}
case 'add-list': {
const listUri = args[0];
if (!listUri) {
console.error('Usage: lettabot bluesky add-list <listUri> --agent <name> [--mode <mode>]');
process.exit(1);
}
const agentChannels = getAgentChannels();
const mode = parseModeArg(args) || agentChannels.bluesky?.groups?.['*']?.mode || 'listen';
const bluesky = ensureBlueskyConfig();
bluesky.lists = bluesky.lists || {};
bluesky.lists[listUri] = { mode: mode as any };
saveConfig(config);
writeRuntimeState({ reloadConfigAt: new Date().toISOString(), refreshListsAt: new Date().toISOString() });
console.log(`✓ Added list ${listUri} with mode ${mode}`);
console.log(` Config: ${resolveConfigPath()}`);
break;
}
case 'set-default': {
const mode = args[0];
if (!mode) {
console.error('Usage: lettabot bluesky set-default <open|listen|mention-only|disabled> --agent <name>');
process.exit(1);
}
const validModes = ['open', 'listen', 'mention-only', 'disabled'];
if (!validModes.includes(mode)) {
console.error(`Error: unknown mode "${mode}". Valid modes: ${validModes.join(', ')}`);
process.exit(1);
}
const bluesky = ensureBlueskyConfig();
bluesky.groups = bluesky.groups || {};
bluesky.groups['*'] = { mode: mode as any };
saveConfig(config);
writeRuntimeState({ reloadConfigAt: new Date().toISOString() });
console.log(`✓ Set Bluesky default mode to ${mode}`);
console.log(` Config: ${resolveConfigPath()}`);
break;
}
case 'disable': {
writeRuntimeState({ disabled: true });
console.log('✓ Bluesky runtime disabled (kill switch set)');
break;
}
case 'enable': {
writeRuntimeState({ disabled: false });
console.log('✓ Bluesky runtime enabled (kill switch cleared)');
break;
}
case 'refresh-lists': {
writeRuntimeState({ refreshListsAt: new Date().toISOString() });
console.log('✓ Requested Bluesky list refresh');
break;
}
case 'status': {
const agentChannels = getAgentChannels();
const bluesky = agentChannels.bluesky;
if (!bluesky || bluesky.enabled === false) {
console.log('Bluesky: disabled in config');
return;
}
console.log('Bluesky: enabled');
if (bluesky.wantedDids?.length) {
console.log(` wantedDids: ${bluesky.wantedDids.join(', ')}`);
}
if (bluesky.lists && Object.keys(bluesky.lists).length > 0) {
console.log(` lists: ${Object.keys(bluesky.lists).length}`);
}
const defaultMode = bluesky.groups?.['*']?.mode || 'listen';
console.log(` default mode: ${defaultMode}`);
if (existsSync(runtimePath)) {
try {
const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) as {
agents?: Record<string, { disabled?: boolean }>;
};
const agentRuntime = runtime.agents?.[agentName];
if (typeof agentRuntime?.disabled === 'boolean') {
console.log(` runtime: ${agentRuntime.disabled ? 'disabled' : 'enabled'}`);
}
} catch {
// ignore
}
}
break;
}
default: {
console.error(`Unknown Bluesky management command: ${action}`);
showBlueskyCommandHelp();
process.exit(1);
}
}
}
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
@@ -404,6 +690,11 @@ async function main() {
await channelManagementCommand(subCommand, args[2], args.slice(3));
break;
}
case 'bluesky': {
await blueskyCommand(subCommand, args.slice(2));
break;
}
case 'pairing': {
const channel = subCommand;
@@ -691,7 +982,7 @@ async function main() {
case undefined:
console.log('Usage: lettabot <command>\n');
console.log('Commands: onboard, server, configure, connect, model, channels, skills, set-conversation, reset-conversation, destroy, help\n');
console.log('Commands: onboard, server, configure, connect, model, channels, bluesky, skills, set-conversation, reset-conversation, destroy, help\n');
console.log('Run "lettabot help" for more information.');
break;

View File

@@ -41,6 +41,10 @@ function getChannelDetails(id: ChannelId, channelConfig: any): string | undefine
case 'whatsapp':
case 'signal':
return channelConfig.selfChat !== false ? 'self-chat mode' : 'dedicated number';
case 'bluesky':
return channelConfig.wantedDids?.length
? `${channelConfig.wantedDids.length} DID(s)`
: 'Jetstream feed';
default:
return undefined;
}

View File

@@ -228,6 +228,61 @@ async function sendDiscord(chatId: string, text: string): Promise<void> {
console.log(`✓ Sent to discord:${chatId} (id: ${result.id || 'unknown'})`);
}
async function sendBluesky(text: string): Promise<void> {
const handle = process.env.BLUESKY_HANDLE;
const appPassword = process.env.BLUESKY_APP_PASSWORD;
const serviceUrl = (process.env.BLUESKY_SERVICE_URL || 'https://bsky.social').replace(/\/+$/, '');
if (!handle || !appPassword) {
throw new Error('BLUESKY_HANDLE/BLUESKY_APP_PASSWORD not set');
}
const sessionRes = await fetch(`${serviceUrl}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: handle, password: appPassword }),
});
if (!sessionRes.ok) {
const detail = await sessionRes.text();
throw new Error(`Bluesky createSession failed: ${detail}`);
}
const session = await sessionRes.json() as { accessJwt: string; did: string };
const chars = Array.from(text);
const trimmed = chars.length > 300 ? chars.slice(0, 300).join('') : text;
if (!trimmed.trim()) {
throw new Error('Bluesky post text is empty');
}
const record = {
text: trimmed,
createdAt: new Date().toISOString(),
};
const postRes = await fetch(`${serviceUrl}/xrpc/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessJwt}`,
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record,
}),
});
if (!postRes.ok) {
const detail = await postRes.text();
throw new Error(`Bluesky createRecord failed: ${detail}`);
}
const result = await postRes.json() as { uri?: string };
console.log(`✓ Sent to bluesky (uri: ${result.uri || 'unknown'})`);
}
async function sendToChannel(channel: string, chatId: string, text: string): Promise<void> {
switch (channel.toLowerCase()) {
case 'telegram':
@@ -240,8 +295,10 @@ async function sendToChannel(channel: string, chatId: string, text: string): Pro
return sendWhatsApp(chatId, text);
case 'discord':
return sendDiscord(chatId, text);
case 'bluesky':
return sendBluesky(text);
default:
throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal, whatsapp, discord`);
throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal, whatsapp, discord, bluesky`);
}
}
@@ -286,21 +343,25 @@ async function sendCommand(args: string[]): Promise<void> {
}
// Resolve defaults from last target
if (!channel || !chatId) {
if (!channel || (!chatId && channel !== 'bluesky')) {
const lastTarget = loadLastTarget();
if (lastTarget) {
channel = channel || lastTarget.channel;
chatId = chatId || lastTarget.chatId;
if (!channel) {
channel = lastTarget.channel;
}
if (!chatId && channel !== 'bluesky') {
chatId = lastTarget.chatId;
}
}
}
if (!channel) {
console.error('Error: --channel is required (no default available)');
console.error('Specify: --channel telegram|slack|signal|discord|whatsapp');
console.error('Specify: --channel telegram|slack|signal|discord|whatsapp|bluesky');
process.exit(1);
}
if (!chatId) {
if (!chatId && channel !== 'bluesky') {
console.error('Error: --chat is required (no default available)');
console.error('Specify: --chat <chat_id>');
process.exit(1);
@@ -335,8 +396,8 @@ Send options:
--file, -f <path> File path (optional, for file messages)
--image Treat file as image (vs document)
--voice Treat file as voice note (sends as native voice memo)
--channel, -c <name> Channel: telegram, slack, whatsapp, discord (default: last used)
--chat, --to <id> Chat/conversation ID (default: last messaged)
--channel, -c <name> Channel: telegram, slack, whatsapp, discord, bluesky (default: last used)
--chat, --to <id> Chat/conversation ID (default: last messaged; not required for bluesky)
Examples:
# Send text message
@@ -363,6 +424,9 @@ Environment variables:
DISCORD_BOT_TOKEN Required for Discord
SIGNAL_PHONE_NUMBER Required for Signal (text only, no files)
LETTABOT_API_KEY Override API key (auto-read from lettabot-api.json if not set)
BLUESKY_HANDLE Required for Bluesky posts
BLUESKY_APP_PASSWORD Required for Bluesky posts
BLUESKY_SERVICE_URL Optional override (default https://bsky.social)
LETTABOT_API_URL API server URL (default: http://localhost:8080)
SIGNAL_CLI_REST_API_URL Signal daemon URL (default: http://127.0.0.1:8090)

View File

@@ -420,6 +420,47 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
if (config.channels.discord?.listeningGroups?.length) {
env.DISCORD_LISTENING_GROUPS = config.channels.discord.listeningGroups.join(',');
}
if (config.channels.bluesky?.enabled) {
if (config.channels.bluesky.wantedDids?.length) {
env.BLUESKY_WANTED_DIDS = config.channels.bluesky.wantedDids.join(',');
}
if (config.channels.bluesky.wantedCollections?.length) {
env.BLUESKY_WANTED_COLLECTIONS = config.channels.bluesky.wantedCollections.join(',');
}
if (config.channels.bluesky.jetstreamUrl) {
env.BLUESKY_JETSTREAM_URL = config.channels.bluesky.jetstreamUrl;
}
if (config.channels.bluesky.cursor !== undefined) {
env.BLUESKY_CURSOR = String(config.channels.bluesky.cursor);
}
if (config.channels.bluesky.handle) {
env.BLUESKY_HANDLE = config.channels.bluesky.handle;
}
if (config.channels.bluesky.appPassword) {
env.BLUESKY_APP_PASSWORD = config.channels.bluesky.appPassword;
}
if (config.channels.bluesky.serviceUrl) {
env.BLUESKY_SERVICE_URL = config.channels.bluesky.serviceUrl;
}
if (config.channels.bluesky.appViewUrl) {
env.BLUESKY_APPVIEW_URL = config.channels.bluesky.appViewUrl;
}
if (config.channels.bluesky.notifications?.enabled) {
env.BLUESKY_NOTIFICATIONS_ENABLED = 'true';
if (config.channels.bluesky.notifications.intervalSec !== undefined) {
env.BLUESKY_NOTIFICATIONS_INTERVAL_SEC = String(config.channels.bluesky.notifications.intervalSec);
}
if (config.channels.bluesky.notifications.limit !== undefined) {
env.BLUESKY_NOTIFICATIONS_LIMIT = String(config.channels.bluesky.notifications.limit);
}
if (config.channels.bluesky.notifications.priority !== undefined) {
env.BLUESKY_NOTIFICATIONS_PRIORITY = config.channels.bluesky.notifications.priority ? 'true' : 'false';
}
if (config.channels.bluesky.notifications.reasons?.length) {
env.BLUESKY_NOTIFICATIONS_REASONS = config.channels.bluesky.notifications.reasons.join(',');
}
}
}
// Features
if (config.features?.cron) {

View File

@@ -72,6 +72,7 @@ export interface AgentConfig {
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
bluesky?: BlueskyConfig;
};
/** Conversation routing */
conversations?: {
@@ -161,6 +162,7 @@ export interface LettaBotConfig {
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
bluesky?: BlueskyConfig;
};
// Conversation routing
@@ -390,6 +392,30 @@ export interface DiscordConfig {
ignoreBotReactions?: boolean; // Ignore all bot reactions (default: true). Set false for multi-bot setups.
}
export interface BlueskyConfig {
enabled: boolean;
jetstreamUrl?: string;
wantedDids?: string[]; // DID(s) to follow (e.g., did:plc:...)
wantedCollections?: string[]; // Optional collection filters (e.g., app.bsky.feed.post)
cursor?: number; // Jetstream cursor (microseconds)
handle?: string; // Bluesky handle (for posting)
appPassword?: string; // App password (for posting)
serviceUrl?: string; // ATProto service URL (default: https://bsky.social)
appViewUrl?: string; // AppView URL for list/notification APIs
groups?: Record<string, GroupConfig>; // Use "*" for defaults, DID for overrides
notifications?: BlueskyNotificationsConfig;
lists?: Record<string, GroupConfig>; // List URI -> mode
}
export interface BlueskyNotificationsConfig {
enabled?: boolean; // Poll notifications API (requires auth)
intervalSec?: number; // Poll interval (default: 60s)
limit?: number; // Max notifications per request (default: 50)
priority?: boolean; // Priority only
reasons?: string[]; // Filter reasons (e.g., ['mention','reply'])
backfill?: boolean; // Process unread notifications on startup (default: false)
}
/**
* Telegram MTProto (user account) configuration.
* Uses TDLib for user account mode instead of Bot API.
@@ -581,6 +607,16 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
normalizeLegacyGroupFields(discord, `${sourcePath}.discord`);
normalized.discord = discord;
}
if (channels.bluesky && channels.bluesky.enabled !== false) {
const bluesky = { ...channels.bluesky, enabled: channels.bluesky.enabled ?? true };
const wantsDids = Array.isArray(bluesky.wantedDids) && bluesky.wantedDids.length > 0;
const canReply = !!(bluesky.handle && bluesky.appPassword);
const hasLists = !!(bluesky.lists && Object.keys(bluesky.lists).length > 0);
const wantsNotifications = !!bluesky.notifications?.enabled;
if (wantsDids || canReply || hasLists || wantsNotifications) {
normalized.bluesky = bluesky;
}
}
// Warn when a channel block exists but was dropped due to missing credentials
const channelCredentials: Array<[string, unknown, boolean]> = [
@@ -676,6 +712,32 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
allowedUsers: parseList(process.env.DISCORD_ALLOWED_USERS),
};
}
if (!channels.bluesky && process.env.BLUESKY_WANTED_DIDS) {
channels.bluesky = {
enabled: true,
wantedDids: parseList(process.env.BLUESKY_WANTED_DIDS),
wantedCollections: parseList(process.env.BLUESKY_WANTED_COLLECTIONS),
jetstreamUrl: process.env.BLUESKY_JETSTREAM_URL,
cursor: process.env.BLUESKY_CURSOR ? parseInt(process.env.BLUESKY_CURSOR, 10) : undefined,
handle: process.env.BLUESKY_HANDLE,
appPassword: process.env.BLUESKY_APP_PASSWORD,
serviceUrl: process.env.BLUESKY_SERVICE_URL,
appViewUrl: process.env.BLUESKY_APPVIEW_URL,
notifications: process.env.BLUESKY_NOTIFICATIONS_ENABLED === 'true'
? {
enabled: true,
intervalSec: process.env.BLUESKY_NOTIFICATIONS_INTERVAL_SEC
? parseInt(process.env.BLUESKY_NOTIFICATIONS_INTERVAL_SEC, 10)
: undefined,
limit: process.env.BLUESKY_NOTIFICATIONS_LIMIT
? parseInt(process.env.BLUESKY_NOTIFICATIONS_LIMIT, 10)
: undefined,
priority: process.env.BLUESKY_NOTIFICATIONS_PRIORITY === 'true',
reasons: parseList(process.env.BLUESKY_NOTIFICATIONS_REASONS),
}
: undefined,
};
}
// Field-level env var fallback for features (heartbeat, cron).
// Unlike channels (all-or-nothing), features are independent toggles so we

View File

@@ -1208,6 +1208,11 @@ export class LettaBot implements AgentSession {
| { kind: 'tool_call'; runId: string; msg: StreamMsg }
> = [];
const msgTypeCounts: Record<string, number> = {};
const bashCommandByToolCallId = new Map<string, string>();
let lastBashCommand = '';
let repeatedBashFailureKey: string | null = null;
let repeatedBashFailureCount = 0;
const maxRepeatedBashFailures = 3;
const parseAndHandleDirectives = async () => {
if (!response.trim()) return;
@@ -1442,12 +1447,68 @@ export class LettaBot implements AgentSession {
const tcName = streamMsg.toolName || 'unknown';
const tcId = streamMsg.toolCallId?.slice(0, 12) || '?';
log.info(`>>> TOOL CALL: ${tcName} (id: ${tcId})`);
if (tcName === 'Bash') {
const toolInput = (streamMsg.toolInput && typeof streamMsg.toolInput === 'object')
? streamMsg.toolInput as Record<string, unknown>
: null;
const command = typeof toolInput?.command === 'string' ? toolInput.command : '';
if (command) {
lastBashCommand = command;
if (streamMsg.toolCallId) {
bashCommandByToolCallId.set(streamMsg.toolCallId, command);
}
}
}
sawNonAssistantSinceLastUuid = true;
// Display tool call (args are fully accumulated by dedupedStream buffer-and-flush)
await sendToolCallDisplay(streamMsg);
} else if (streamMsg.type === 'tool_result') {
log.info(`<<< TOOL RESULT: error=${streamMsg.isError}, len=${(streamMsg as any).content?.length || 0}`);
sawNonAssistantSinceLastUuid = true;
const toolCallId = typeof streamMsg.toolCallId === 'string' ? streamMsg.toolCallId : '';
const mappedCommand = toolCallId ? bashCommandByToolCallId.get(toolCallId) : undefined;
if (toolCallId) {
bashCommandByToolCallId.delete(toolCallId);
}
const bashCommand = (mappedCommand || lastBashCommand || '').trim();
const toolResultContent = typeof (streamMsg as any).content === 'string'
? (streamMsg as any).content
: typeof (streamMsg as any).result === 'string'
? (streamMsg as any).result
: '';
const lowerContent = toolResultContent.toLowerCase();
const isLettabotCliCall = /^lettabot(?:-[a-z0-9-]+)?\b/i.test(bashCommand);
const looksCliCommandError = lowerContent.includes('unknown command')
|| lowerContent.includes('command not found')
|| lowerContent.includes('usage: lettabot')
|| lowerContent.includes('usage: lettabot-bluesky')
|| lowerContent.includes('error: --agent is required for bluesky commands');
if (streamMsg.isError && bashCommand && isLettabotCliCall && looksCliCommandError) {
const errorKind = lowerContent.includes('unknown command') || lowerContent.includes('command not found')
? 'unknown-command'
: 'usage-error';
const failureKey = `${bashCommand.toLowerCase()}::${errorKind}`;
if (repeatedBashFailureKey === failureKey) {
repeatedBashFailureCount += 1;
} else {
repeatedBashFailureKey = failureKey;
repeatedBashFailureCount = 1;
}
if (repeatedBashFailureCount >= maxRepeatedBashFailures) {
log.error(`Stopping run after repeated Bash command failures (${repeatedBashFailureCount}) for: ${bashCommand}`);
session.abort().catch(() => {});
response = `(I stopped after repeated CLI command failures while running: ${bashCommand}. The command path appears mismatched. Please confirm Bluesky CLI commands are available, then resend your request.)`;
break;
}
} else {
repeatedBashFailureKey = null;
repeatedBashFailureCount = 0;
}
} else if (streamMsg.type === 'assistant' && lastMsgType !== 'assistant') {
log.info(`Generating response...`);
} else if (streamMsg.type === 'reasoning') {

View File

@@ -209,10 +209,9 @@ describe('formatMessageEnvelope', () => {
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
expect(result).toContain('<no-reply/>');
expect(result).toContain('<actions>');
});
it('omits <actions> directives when reactions are not supported', () => {
it('omits react directive when reactions are not supported', () => {
const msg = createMessage({ isGroup: false });
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
@@ -247,6 +246,49 @@ describe('formatMessageEnvelope', () => {
expect(result).toContain('react to show you saw this');
expect(result).not.toContain('react and reply');
});
it('omits <actions> directives when reactions are not supported', () => {
const msg = createMessage({ isGroup: false });
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
expect(result).toContain('<no-reply/>');
expect(result).not.toContain('<react');
});
it('shows minimal directives in listening mode', () => {
const msg = createMessage({
isGroup: true,
isListeningMode: true,
formatterHints: { supportsReactions: true },
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
expect(result).toContain('<no-reply/>');
expect(result).toContain('<actions><react emoji="eyes" /></actions>');
// Should NOT show full directives
expect(result).not.toContain('react and reply');
});
it('shows Mode line in listening mode', () => {
const msg = createMessage({
isGroup: true,
isListeningMode: true,
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Mode**: Listen only');
});
it('listening mode without reactions still shows no-reply', () => {
const msg = createMessage({
isGroup: true,
isListeningMode: true,
formatterHints: { supportsReactions: false },
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
expect(result).toContain('<no-reply/>');
expect(result).not.toContain('<actions>');
});
});
describe('format hints', () => {
@@ -420,7 +462,7 @@ describe('formatGroupBatchEnvelope', () => {
it('includes OBSERVATION ONLY header when isListeningMode=true', () => {
const msgs = createBatchMessages(2);
const result = formatGroupBatchEnvelope(msgs, {}, true);
expect(result).toContain('[OBSERVATION ONLY - Update memories. Do not reply unless addressed.]');
expect(result).toContain('[OBSERVATION ONLY Update memories, do not send text replies]');
});
it('does not include OBSERVATION ONLY header when isListeningMode=false', () => {
@@ -449,5 +491,28 @@ describe('formatGroupBatchEnvelope', () => {
expect(result).toContain('User 0: Message 0');
expect(result).toContain('User 1: Message 1');
});
it('includes minimal directives in listening mode', () => {
const msgs = createBatchMessages(2);
const result = formatGroupBatchEnvelope(msgs, {}, true);
expect(result).toContain('Directives:');
expect(result).toContain('<no-reply/>');
expect(result).toContain('to acknowledge');
});
it('includes react directive in listening mode when reactions supported', () => {
const msgs = createBatchMessages(2);
msgs[0].formatterHints = { supportsReactions: true };
const result = formatGroupBatchEnvelope(msgs, {}, true);
expect(result).toContain('<actions><react emoji="eyes" /></actions>');
});
it('does not include react directive in listening mode when reactions not supported', () => {
const msgs = createBatchMessages(2);
msgs[0].formatterHints = { supportsReactions: false };
const result = formatGroupBatchEnvelope(msgs, {}, true);
expect(result).toContain('<no-reply/>');
expect(result).not.toContain('<actions>');
});
});
});

View File

@@ -236,7 +236,9 @@ function buildMetadataLines(msg: InboundMessage, options: EnvelopeOptions): stri
function buildChatContextLines(msg: InboundMessage, options: EnvelopeOptions): string[] {
const lines: string[] = [];
if (msg.isGroup) {
const messageType = msg.messageType ?? (msg.isGroup ? 'group' : 'dm');
if (messageType === 'group') {
lines.push(`- **Type**: Group chat`);
if (options.includeGroup !== false && msg.groupName?.trim()) {
if (msg.channel === 'slack' || msg.channel === 'discord') {
@@ -256,6 +258,8 @@ function buildChatContextLines(msg: InboundMessage, options: EnvelopeOptions): s
} else {
lines.push(`- **Hint**: See Response Directives below for \`<no-reply/>\``);
}
} else if (messageType === 'public') {
lines.push(`- **Type**: Public post`);
} else {
lines.push(`- **Type**: Direct message`);
}
@@ -279,6 +283,13 @@ function buildChatContextLines(msg: InboundMessage, options: EnvelopeOptions): s
lines.push(...attachmentLines);
}
// Channel-specific display context (e.g. Bluesky operation/URI metadata)
if (msg.extraContext) {
for (const [key, value] of Object.entries(msg.extraContext)) {
lines.push(`- **${key}**: ${value}`);
}
}
return lines;
}
@@ -309,7 +320,8 @@ function buildResponseDirectives(msg: InboundMessage): string[] {
const lines: string[] = [];
const supportsReactions = msg.formatterHints?.supportsReactions ?? false;
const supportsFiles = msg.formatterHints?.supportsFiles ?? false;
const isGroup = !!msg.isGroup;
const messageType = msg.messageType ?? (msg.isGroup ? 'group' : 'dm');
const isGroup = messageType === 'group';
const isListeningMode = msg.isListeningMode ?? false;
// Listening mode: minimal directives only
@@ -317,6 +329,7 @@ function buildResponseDirectives(msg: InboundMessage): string[] {
lines.push(`- \`<no-reply/>\` — acknowledge without replying (recommended)`);
if (supportsReactions) {
lines.push(`- \`<actions><react emoji="eyes" /></actions>\` — react to show you saw this`);
lines.push(`- Emoji names: eyes, thumbsup, heart, fire, tada, clap — or unicode`);
}
return lines;
}
@@ -382,7 +395,7 @@ export function formatMessageEnvelope(
const opts = { ...DEFAULT_OPTIONS, ...options };
const sections: string[] = [];
// Session context section (for first message in a chat session)
// Session context section (agent/server info, shown first)
if (sessionContext) {
const sessionLines = buildSessionContext(sessionContext);
if (sessionLines.length > 0) {
@@ -400,9 +413,19 @@ export function formatMessageEnvelope(
sections.push(`## Chat Context\n${contextLines.join('\n')}`);
}
// Channel-aware response directives
const directiveLines = buildResponseDirectives(msg);
sections.push(`## Response Directives\n${directiveLines.join('\n')}`);
// Channel-specific action hints (Bluesky: replaces standard directives)
if (msg.formatterHints?.actionsSection && msg.formatterHints.actionsSection.length > 0) {
sections.push(`## Channel Actions\n${msg.formatterHints.actionsSection.join('\n')}`);
}
// Response directives (skip if channel provides its own actionsSection)
const hasCustomActions = (msg.formatterHints?.actionsSection?.length ?? 0) > 0;
if (!hasCustomActions && !msg.formatterHints?.skipDirectives) {
const directiveLines = buildResponseDirectives(msg);
sections.push(`## Response Directives\n${directiveLines.join('\n')}`);
}
// Build the full system-reminder block
const reminderContent = sections.join('\n\n');
const reminder = `${SYSTEM_REMINDER_OPEN}\n${reminderContent}\n${SYSTEM_REMINDER_CLOSE}`;
@@ -448,7 +471,7 @@ export function formatGroupBatchEnvelope(
headerParts.push(`${messages.length} message${messages.length === 1 ? '' : 's'}`);
let header = `[${headerParts.join(' - ')}]`;
if (isListeningMode) {
header += '\n[OBSERVATION ONLY - Update memories. Do not reply unless addressed.]';
header += '\n[OBSERVATION ONLY Update memories, do not send text replies]';
}
// Chat log lines

View File

@@ -105,6 +105,55 @@ describe('result divergence guard', () => {
expect(sentTexts).toEqual(['streamed-segment']);
});
it('stops after repeated failing lettabot CLI bash calls', async () => {
const bot = new LettaBot({
workingDir: workDir,
allowedTools: [],
maxToolCalls: 100,
});
const abort = vi.fn(async () => {});
const adapter = {
id: 'mock',
name: 'Mock',
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
isRunning: vi.fn(() => true),
sendMessage: vi.fn(async (_msg: OutboundMessage) => ({ messageId: 'msg-1' })),
editMessage: vi.fn(async () => {}),
sendTypingIndicator: vi.fn(async () => {}),
stopTypingIndicator: vi.fn(async () => {}),
supportsEditing: vi.fn(() => false),
sendFile: vi.fn(async () => ({ messageId: 'file-1' })),
};
(bot as any).sessionManager.runSession = vi.fn(async () => ({
session: { abort },
stream: async function* () {
yield { type: 'tool_call', toolCallId: 'tc-1', toolName: 'Bash', toolInput: { command: 'lettabot bluesky post --text "hi" --agent Bot' } };
yield { type: 'tool_result', toolCallId: 'tc-1', isError: true, content: 'Unknown command: bluesky' };
yield { type: 'tool_call', toolCallId: 'tc-2', toolName: 'Bash', toolInput: { command: 'lettabot bluesky post --text "hi" --agent Bot' } };
yield { type: 'tool_result', toolCallId: 'tc-2', isError: true, content: 'Unknown command: bluesky' };
yield { type: 'tool_call', toolCallId: 'tc-3', toolName: 'Bash', toolInput: { command: 'lettabot bluesky post --text "hi" --agent Bot' } };
yield { type: 'tool_result', toolCallId: 'tc-3', isError: true, content: 'Unknown command: bluesky' };
},
}));
const msg: InboundMessage = {
channel: 'discord',
chatId: 'chat-1',
userId: 'user-1',
text: 'hello',
timestamp: new Date(),
};
await (bot as any).processMessage(msg, adapter);
expect(abort).toHaveBeenCalled();
const sentTexts = adapter.sendMessage.mock.calls.map(([payload]) => payload.text as string);
expect(sentTexts.some(text => text.includes('repeated CLI command failures'))).toBe(true);
});
it('does not deliver reasoning text from error results as the response', async () => {
const bot = new LettaBot({
workingDir: workDir,

View File

@@ -20,6 +20,13 @@ import { createLogger } from '../logger.js';
const log = createLogger('Session');
function toConcreteConversationId(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed || trimmed === 'default') return null;
return trimmed;
}
export class SessionManager {
private readonly store: Store;
private readonly config: BotConfig;
@@ -230,11 +237,22 @@ export class SessionManager {
// In disabled mode, always resume the agent's built-in default conversation.
// Skip store lookup entirely -- no conversation ID is persisted.
const convId = key === 'default'
const rawConvId = key === 'default'
? null
: key === 'shared'
? this.store.conversationId
: this.store.getConversationId(key);
const convId = toConcreteConversationId(rawConvId);
// Cleanup legacy persisted alias values from older versions.
if (rawConvId === 'default') {
if (key === 'shared') {
this.store.conversationId = null;
} else {
this.store.clearConversation(key);
}
log.info(`Cleared legacy default conversation alias (key=${key})`);
}
// Propagate per-agent cron store path to CLI subprocesses (lettabot-schedule)
if (this.config.cronStorePath) {
@@ -528,9 +546,11 @@ export class SessionManager {
let session = await this.ensureSessionForKey(convKey);
// Resolve the conversation ID for this key (for error recovery)
const convId = convKey === 'shared'
? this.store.conversationId
: this.store.getConversationId(convKey);
const convId = toConcreteConversationId(
convKey === 'shared'
? this.store.conversationId
: this.store.getConversationId(convKey)
);
// Send message with fallback chain
try {

View File

@@ -43,7 +43,15 @@ export interface TriggerContext {
// Original Types
// =============================================================================
export type ChannelId = 'telegram' | 'telegram-mtproto' | 'slack' | 'whatsapp' | 'signal' | 'discord' | 'mock';
export type ChannelId = 'telegram' | 'telegram-mtproto' | 'slack' | 'whatsapp' | 'signal' | 'discord' | 'bluesky' | 'mock';
/**
* Message type indicating the context of the message.
* - 'dm': Direct message (private 1:1 conversation)
* - 'group': Group chat (multiple participants)
* - 'public': Public post (e.g., Bluesky feed, visible to anyone)
*/
export type MessageType = 'dm' | 'group' | 'public';
export interface InboundAttachment {
id?: string;
@@ -61,6 +69,26 @@ export interface InboundReaction {
action?: 'added' | 'removed';
}
/**
* Formatter hints provided by channel adapters
*/
export interface FormatterHints {
/** Custom format hint (overrides default channel format) */
formatHint?: string;
/** Whether this channel supports emoji reactions */
supportsReactions?: boolean;
/** Whether this channel supports file/image sending */
supportsFiles?: boolean;
/** Custom action hints replacing the standard Response Directives section */
actionsSection?: string[];
/** Whether to skip the standard Response Directives section entirely */
skipDirectives?: boolean;
}
/**
* Inbound message from any channel
*/
@@ -74,7 +102,8 @@ export interface InboundMessage {
text: string;
timestamp: Date;
threadId?: string; // Slack thread_ts
isGroup?: boolean; // Is this from a group chat?
messageType?: MessageType; // 'dm', 'group', or 'public' (defaults to 'dm')
isGroup?: boolean; // True if group chat (convenience alias for messageType === 'group')
groupName?: string; // Group/channel name if applicable
serverId?: string; // Server/guild ID (Discord only)
wasMentioned?: boolean; // Was bot explicitly mentioned? (groups only)
@@ -86,15 +115,7 @@ export interface InboundMessage {
isListeningMode?: boolean; // Listening mode: agent processes for memory but response is suppressed
forcePerChat?: boolean; // Force per-chat conversation routing (e.g., Discord thread-only mode)
formatterHints?: FormatterHints; // Channel capabilities for directive rendering
}
/**
* Channel capability hints for per-message directive rendering
*/
export interface FormatterHints {
supportsReactions?: boolean;
supportsFiles?: boolean;
formatHint?: string;
extraContext?: Record<string, string>; // Channel-specific key/value metadata shown in Chat Context
}
/**
@@ -128,6 +149,7 @@ export interface OutboundFile {
export interface SkillsConfig {
cronEnabled?: boolean;
googleEnabled?: boolean;
blueskyEnabled?: boolean;
ttsEnabled?: boolean;
additionalSkills?: string[];
}

View File

@@ -352,6 +352,7 @@ async function main() {
skills: {
cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled,
googleEnabled: !!agentConfig.integrations?.google?.enabled || !!agentConfig.polling?.gmail?.enabled,
blueskyEnabled: !!agentConfig.channels?.bluesky?.enabled,
ttsEnabled: voiceMemoEnabled,
},
});

View File

@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
@@ -58,6 +58,25 @@ describe('skills loader', () => {
expect(FEATURE_SKILLS.tts).toBeDefined();
expect(FEATURE_SKILLS.tts).toContain('voice-memo');
});
it('has bluesky feature with bluesky skill', () => {
expect(FEATURE_SKILLS.bluesky).toBeDefined();
expect(FEATURE_SKILLS.bluesky).toContain('bluesky');
});
it('bundled bluesky skill ships an executable helper shim', () => {
const shimPath = join(process.cwd(), 'skills', 'bluesky', 'lettabot-bluesky');
expect(existsSync(shimPath)).toBe(true);
expect(statSync(shimPath).mode & 0o111).not.toBe(0);
});
it('bundled bluesky shim prefers local CLI entrypoints', () => {
const shimPath = join(process.cwd(), 'skills', 'bluesky', 'lettabot-bluesky');
const shim = readFileSync(shimPath, 'utf-8');
expect(shim).toContain('node "./dist/cli.js" bluesky');
expect(shim).toContain('npx tsx "./src/cli.ts" bluesky');
expect(shim).toContain('exec lettabot bluesky "$@"');
});
});
describe('isVoiceMemoConfigured', () => {

View File

@@ -305,6 +305,7 @@ function installSkillsFromDir(sourceDir: string, targetDir: string): string[] {
export const FEATURE_SKILLS: Record<string, string[]> = {
cron: ['scheduling'], // Scheduling handles both one-off reminders and recurring cron jobs
google: ['gog', 'google'], // Installed when Google/Gmail is configured
bluesky: ['bluesky'], // Installed when Bluesky is configured
tts: ['voice-memo'], // Voice memo replies via lettabot-tts helper
};
@@ -340,6 +341,7 @@ function installSpecificSkills(
export interface SkillsInstallConfig {
cronEnabled?: boolean;
googleEnabled?: boolean; // Gmail polling or Google integration
blueskyEnabled?: boolean; // Bluesky integration
ttsEnabled?: boolean; // Voice memo replies via TTS providers
additionalSkills?: string[]; // Explicitly enabled skills
}
@@ -376,14 +378,19 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst
if (config.ttsEnabled) {
requestedSkills.push(...FEATURE_SKILLS.tts);
}
// Bluesky skills (if Bluesky is configured)
if (config.blueskyEnabled) {
requestedSkills.push(...FEATURE_SKILLS.bluesky);
}
// Additional explicitly enabled skills
if (config.additionalSkills?.length) {
requestedSkills.push(...config.additionalSkills);
}
const skillsToInstall = Array.from(new Set(requestedSkills));
if (skillsToInstall.length === 0) {
log.info('No feature-gated skills to install');
return;
@@ -432,14 +439,19 @@ export function installSkillsToAgent(agentId: string, config: SkillsInstallConfi
if (config.ttsEnabled) {
requestedSkills.push(...FEATURE_SKILLS.tts);
}
// Bluesky skills (if Bluesky is configured)
if (config.blueskyEnabled) {
requestedSkills.push(...FEATURE_SKILLS.bluesky);
}
// Additional explicitly enabled skills
if (config.additionalSkills?.length) {
requestedSkills.push(...config.additionalSkills);
}
const skillsToInstall = Array.from(new Set(requestedSkills));
if (skillsToInstall.length === 0) {
return; // No skills to install - silent return
}

View File

@@ -57,7 +57,11 @@ export class MockChannelAdapter implements ChannelAdapter {
}
getFormatterHints() {
return { supportsReactions: false, supportsFiles: false };
return {
supportsReactions: false,
supportsFiles: false,
formatHint: 'Plain text only',
};
}
/**