This commit is contained in:
@@ -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
19
bluesky-jetstream.json
Normal 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
184
docs/bluesky-setup.md
Normal 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.
|
||||
@@ -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
138
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
87
skills/bluesky/SKILL.md
Normal 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>
|
||||
```
|
||||
|
||||
## Auth‑Required 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 opt‑in).
|
||||
- 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
23
skills/bluesky/lettabot-bluesky
Executable 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
|
||||
397
src/channels/bluesky.test.ts
Normal file
397
src/channels/bluesky.test.ts
Normal 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
1
src/channels/bluesky.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './bluesky/index.js';
|
||||
1554
src/channels/bluesky/adapter.ts
Normal file
1554
src/channels/bluesky/adapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
878
src/channels/bluesky/cli.ts
Normal file
878
src/channels/bluesky/cli.ts
Normal 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);
|
||||
});
|
||||
15
src/channels/bluesky/constants.ts
Normal file
15
src/channels/bluesky/constants.ts
Normal 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;
|
||||
98
src/channels/bluesky/formatter.ts
Normal file
98
src/channels/bluesky/formatter.ts
Normal 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;
|
||||
}
|
||||
2
src/channels/bluesky/index.ts
Normal file
2
src/channels/bluesky/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BlueskyAdapter } from './adapter.js';
|
||||
export type { BlueskyConfig, JetstreamEvent, JetstreamCommit, DidMode } from './types.js';
|
||||
69
src/channels/bluesky/types.ts
Normal file
69
src/channels/bluesky/types.ts
Normal 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 };
|
||||
}
|
||||
172
src/channels/bluesky/utils.ts
Normal file
172
src/channels/bluesky/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
293
src/cli.ts
293
src/cli.ts
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ export class MockChannelAdapter implements ChannelAdapter {
|
||||
}
|
||||
|
||||
getFormatterHints() {
|
||||
return { supportsReactions: false, supportsFiles: false };
|
||||
return {
|
||||
supportsReactions: false,
|
||||
supportsFiles: false,
|
||||
formatHint: 'Plain text only',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user