https://github.com/sudodaksh/cygnet
a modern framework for building signal bots
https://github.com/sudodaksh/cygnet
bot-framework bun nodejs signal signal-bot typescript
Last synced: 4 months ago
JSON representation
a modern framework for building signal bots
- Host: GitHub
- URL: https://github.com/sudodaksh/cygnet
- Owner: sudodaksh
- Created: 2026-02-28T15:23:22.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-01T05:35:20.000Z (4 months ago)
- Last Synced: 2026-03-01T08:27:12.611Z (4 months ago)
- Topics: bot-framework, bun, nodejs, signal, signal-bot, typescript
- Language: TypeScript
- Homepage:
- Size: 855 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# a modern framework for building [signal](https://signal.org) bots



## Install
```bash
npm install cygnet
```
## Prerequisites
- [Bun](https://bun.sh) (or Node.js with ESM)
- A running [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) instance with your Signal number registered ([setup guide](#setting-up-signal-cli-rest-api))
## Quick start
```typescript
import { Bot } from "cygnet";
const bot = new Bot({
signalService: "localhost:8080",
phoneNumber: "+491234567890",
});
bot.command("start", (ctx) => ctx.reply("Hello!"));
bot.on("message:text", (ctx) => ctx.reply(`You said: ${ctx.text}`));
bot.start();
```
```bash
bun run examples/hello-world.ts
```
---
## Examples
Runnable examples live in [examples/README.md](./examples/README.md):
- [hello-world](./examples/hello-world.ts): minimal bot setup and basic text replies
- [commands](./examples/commands.ts): command parsing and `ctx.match`
- [reactions](./examples/reactions.ts): react to messages, handle incoming reactions
- [quotes-and-replies](./examples/quotes-and-replies.ts): quote messages, handle incoming quotes
- [typing-and-receipts](./examples/typing-and-receipts.ts): typing indicators, delivery/read receipts
- [edit-and-delete](./examples/edit-and-delete.ts): edit and delete sent messages, handle edits/deletes
- [group-updates](./examples/group-updates.ts): best-effort `group_update` handling with persisted state
- [audio-files](./examples/audio-files.ts): handling incoming audio attachments
- [wizard-register](./examples/wizard-register.ts): a session-backed multi-step registration flow
---
## Table of contents
- [Examples](#examples)
- [Bot setup](#bot-setup)
- [Handling updates](#handling-updates)
- [Commands](#commands)
- [Text matching](#text-matching)
- [Filter queries](#filter-queries)
- [Arbitrary filters](#arbitrary-filters)
- [Context](#context)
- [Sending messages](#sending-messages)
- [Reactions](#reactions)
- [Other actions](#other-actions)
- [Middleware](#middleware)
- [Session](#session)
- [Scenes](#scenes)
- [Scene state](#scene-state)
- [WizardScene](#wizardscene)
- [Error handling](#error-handling)
- [Context flavoring](#context-flavoring)
- [API reference](#api-reference)
- [Setting up signal-cli-rest-api](#setting-up-signal-cli-rest-api)
---
## Bot setup
```typescript
import { Bot, FileStorage } from "cygnet";
const bot = new Bot({
signalService: "localhost:8080", // signal-cli-rest-api URL (scheme optional)
phoneNumber: "+491234567890", // the bot's registered number
groupStateStorage: new FileStorage(".cygnet-group-state.json"), // optional
});
bot.start(); // connects via WebSocket, auto-reconnects on disconnect
bot.stop(); // graceful shutdown
```
`bot.start()` calls `GET /v1/health` on startup and then opens a WebSocket to `ws://{signalService}/v1/receive/{phoneNumber}`. Updates are processed sequentially.
---
## Handling updates
### Commands
Matches `/command` (and `/command@anything`) at the start of a message:
```typescript
bot.command("start", (ctx) => ctx.reply("Welcome!"));
bot.command("help", (ctx) => ctx.reply("Help text here."));
bot.command(["yes", "no"], (ctx) => ctx.reply("Got a yes/no"));
```
### Text matching
```typescript
// Exact string
bot.hears("ping", (ctx) => ctx.reply("pong"));
// Regular expression — ctx.match holds the RegExpExecArray
bot.hears(/order (\d+)/i, (ctx) => {
const orderId = ctx.match![1];
ctx.reply(`Looking up order ${orderId}…`);
});
// Array of strings and/or regexes
bot.hears(["hi", "hello", /hey+/i], (ctx) => ctx.reply("Hey there!"));
```
### Filter queries
Filter queries are type-safe strings that narrow the context type at compile time. After `.on("message:text")`, TypeScript knows `ctx.text` is `string` (not `string | undefined`).
| Query | Matches |
|---|---|
| `"message"` | Any regular message (not a reaction) |
| `"message:text"` | Message with non-empty text |
| `"message:attachments"` | Message with one or more attachments |
| `"message:quote"` | Message that quotes another |
| `"message:reaction"` | A reaction to a message |
| `"message:group"` | Message sent in a group |
| `"message:private"` | Message sent in a 1-on-1 DM |
| `"message:sticker"` | Message with a sticker |
| `"group_update"` | Group metadata or membership change |
| `"edit_message"` | An edited message |
| `"delete_message"` | A deleted message |
| `"receipt"` | Read or delivery receipt |
| `"typing"` | Typing indicator |
| `"call"` | Incoming call |
| `"sync_message"` | Sync from a linked device |
```typescript
bot.on("message:text", (ctx) => {
ctx.text; // string — guaranteed by the filter
});
bot.on("message:reaction", (ctx) => {
ctx.reaction?.emoji; // the emoji that was reacted with
});
bot.on("message:group", (ctx) => {
ctx.update.envelope.dataMessage?.groupInfo?.groupId;
});
// Multiple filters — matches any of them
bot.on(["message:text", "edit_message"], (ctx) => { /* ... */ });
```
### Arbitrary filters
```typescript
// Predicate function
bot.filter(
(ctx) => ctx.from === "+491234567890",
(ctx) => ctx.reply("Hello, boss!"),
);
// Type guard — narrows the context type
bot.filter(
(ctx): ctx is typeof ctx & { text: string } => ctx.text !== undefined,
(ctx) => { /* ctx.text is string here */ },
);
// Drop updates that match (don't process further)
bot.drop((ctx) => ctx.isGroup); // ignore all group messages
```
---
## Context
Every handler receives a `Context` object. It wraps the raw update and provides shortcuts for the most common operations.
### Update getters
```typescript
ctx.update // RawUpdate — the full raw envelope from signal-cli-rest-api
ctx.me // string — bot's own phone number
ctx.dataMessage // DataMessage | undefined — any data message (incl. reactions)
ctx.message // DataMessage | undefined — regular message (not reaction, not group_update)
ctx.groupUpdate // DataMessage | undefined — group metadata/membership update
ctx.reaction // Reaction | undefined — the reaction, if this is a reaction update
ctx.editMessage // EditMessage | undefined
ctx.deleteMessage // DeleteMessage | undefined
ctx.receipt // ReceiptMessage | undefined
ctx.typingMessage // TypingMessage | undefined
ctx.callMessage // CallMessage | undefined
ctx.syncMessage // SyncMessage | undefined
ctx.from // string | undefined — sender phone number
ctx.fromName // string | undefined — sender display name
ctx.fromUuid // string | undefined — sender UUID
ctx.chat // string — group ID (for groups) or sender phone (for DMs)
ctx.isGroup // boolean
ctx.text // string — message text or edited text ("" if none)
ctx.msgTimestamp // number | undefined — Unix ms
ctx.match // string | RegExpExecArray | undefined — set by bot.hears()/bot.command()
```
### Group state changes
`signal-cli-rest-api` collapses group membership and metadata changes into the
same `group_update` payload shape (`groupInfo.type === "UPDATE"`). Treat it as
an eventually consistent state signal, not a perfect event log.
cygnet keeps a per-bot group state cache (name, membership, last revision) and
`ctx.inspectGroupUpdate()` uses that cache to classify updates as best-effort
`joined`, `left`, `renamed`, `updated`, `stale`, or `unknown`.
```typescript
bot.on("group_update", async (ctx) => {
const details = await ctx.inspectGroupUpdate();
console.log(details);
});
```
Notes:
- `groupInfo.revision` is treated as the ordering key. Older or duplicate
revisions are returned as `stale`.
- If a revision jump is detected, cygnet logs a gap warning and reconciles
against `getGroups()`.
- `groupStateStorage` accepts a stricter direct-storage type. Built-in
`MemoryStorage` and `FileStorage` work, but wrappers like `enhanceStorage()`
intentionally do not.
- This keeps group state on the same adapter family as sessions, while
preventing TTL wrappers from being used for revision tracking.
- Even with the cache, missed upstream events mean cygnet can recover current
state, but not reconstruct the exact history of what happened while offline.
### Sending messages
```typescript
// Send to the same chat (group or DM) the update came from
await ctx.reply("Hello!");
// With options
await ctx.reply("Hello!", {
base64Attachments: ["..."],
mentions: [{ number: "+49...", start: 0, length: 5 }],
textMode: "styled",
viewOnce: true,
});
// Quote the current message
await ctx.quote("Good point!");
// Edit a previously sent message
await ctx.api.editMessage(ctx.chat, previousTimestamp, "corrected text");
```
### Reactions
```typescript
// React to the current message
await ctx.react("👍");
// Remove a reaction
await ctx.unreact("👍");
```
### Other actions
```typescript
// Typing indicator
await ctx.typing(); // "started typing"
await ctx.typing(true); // "stopped typing"
// Delete the current message
await ctx.deleteMsg();
// Delete a specific message by timestamp
await ctx.deleteMsg(timestamp);
```
### Direct API access
For anything not covered by context shortcuts:
```typescript
await ctx.api.send(recipient, text, options);
await ctx.api.react(recipient, { reaction: "❤️", targetAuthor: "+49...", targetTimestamp: ts });
await ctx.api.getGroups();
await ctx.api.checkHealth();
```
---
## Middleware
cygnet uses Koa-style `(ctx, next)` middleware.
```typescript
// Runs for every update
bot.use(async (ctx, next) => {
console.log("Update from:", ctx.from);
await next(); // pass to the next handler
});
// Branching
bot.branch(
(ctx) => ctx.isGroup,
(ctx) => ctx.reply("Hi group!"),
(ctx) => ctx.reply("Hi DM!"),
);
// Background — runs concurrently, does not block the chain
bot.fork(async (ctx) => {
await logToDatabase(ctx.update);
});
// Lazy — select middleware at runtime
bot.lazy((ctx) => {
return ctx.isGroup ? groupMiddleware : dmMiddleware;
});
// Isolated error handling
bot.errorBoundary(
(err, ctx) => console.error("caught:", err),
riskyMiddleware,
);
```
`Composer` instances can be used as sub-routers:
```typescript
import { Composer } from "cygnet";
const admin = new Composer();
admin.command("ban", (ctx) => { /* ... */ });
admin.command("kick", (ctx) => { /* ... */ });
bot.filter((ctx) => admins.includes(ctx.from!), admin);
```
---
## Session
Store per-chat data across messages. Requires a `SessionFlavor` on your context type.
```typescript
import { Bot, Context, session, MemoryStorage } from "cygnet";
import type { SessionFlavor } from "cygnet";
interface MySession {
count: number;
lastSeen?: number;
}
type MyContext = Context & SessionFlavor;
const bot = new Bot({ signalService: "...", phoneNumber: "..." });
bot.use(session({
initial: () => ({ count: 0 }), // called when no session exists yet
}));
bot.on("message:text", (ctx) => {
ctx.session.count++; // read/write
ctx.reply(`Message #${ctx.session.count}`);
});
```
### Custom storage
Implement `StorageAdapter` to plug in any backend:
```typescript
import type { StorageAdapter } from "cygnet";
class RedisStorage implements StorageAdapter {
async read(key: string): Promise { /* ... */ }
async write(key: string, value: T): Promise { /* ... */ }
async delete(key: string): Promise { /* ... */ }
}
bot.use(session({ storage: new RedisStorage(), initial: () => ({ count: 0 }) }));
```
The built-in `MemoryStorage` keeps data in-process and loses it on restart. Use a persistent adapter for production. `FileStorage` is a simple built-in JSON file adapter:
```typescript
import { FileStorage } from "cygnet";
bot.use(session({
storage: new FileStorage(".cygnet-session.json"),
initial: () => ({ count: 0 }),
}));
```
### Session key
By default the key is `ctx.chat` (group ID or phone number). Override it:
```typescript
bot.use(session({
getSessionKey: (ctx) => ctx.fromUuid, // per-user instead of per-chat
initial: () => ({}),
}));
```
---
## Scenes
Scenes let you build stateful multi-step conversations. They require `session` middleware.
```typescript
import { Bot, Context, Stage, BaseScene, session } from "cygnet";
import type { SessionFlavor, SceneContextFlavor, SceneSessionData } from "cygnet";
interface MySession extends SceneSessionData { /* your fields */ }
type MyContext = Context & SessionFlavor & SceneContextFlavor;
const greetScene = new BaseScene("greet");
greetScene.enter((ctx) => ctx.reply("You entered the greet scene!"));
greetScene.on("message:text", async (ctx) => {
await ctx.reply(`Hello, ${ctx.text}!`);
await ctx.scene.leave(); // exit the scene
});
greetScene.leave((ctx) => ctx.reply("Bye!"));
const stage = new Stage([greetScene]);
const bot = new Bot({ signalService: "...", phoneNumber: "..." });
bot.use(session({ initial: () => ({}) }));
bot.command("greet", stage.enter("greet")); // enter scene
bot.command("cancel", stage.leave()); // leave scene
bot.command("restart", stage.reenter()); // re-enter (reset state)
bot.use(stage);
```
While inside a scene, only that scene's handlers run. Updates do not reach bot-level handlers.
### Scene state
```typescript
greetScene.enter((ctx) => {
ctx.scene.state.attempts = 0;
});
greetScene.on("message:text", async (ctx) => {
ctx.scene.state.attempts = (ctx.scene.state.attempts as number ?? 0) + 1;
});
```
State is persisted in the session automatically.
### WizardScene
A `WizardScene` is a scene that executes a sequence of steps one at a time. Step 0 runs immediately when the scene is entered, and each later step handles exactly one incoming update.
```typescript
import { WizardScene } from "cygnet";
import type { WizardContext, WizardContextFlavor, SceneSessionData } from "cygnet";
interface MySession extends SceneSessionData {}
type MyContext = Context & SessionFlavor & WizardContextFlavor;
const registerWizard = new WizardScene(
"register",
// Step 0
async (ctx) => {
await ctx.reply("What's your name?");
await ctx.wizard.advance(); // advance to step 1
},
// Step 1
async (ctx) => {
await ctx.reply(`Nice to meet you, ${ctx.text}! How old are you?`);
await ctx.wizard.advance();
},
// Step 2
async (ctx) => {
await ctx.reply(`Got it! Registration complete.`);
await ctx.scene.leave();
},
);
```
Wizard controller:
```typescript
ctx.wizard.advance() // advance one step
ctx.wizard.retreat() // go back one step
ctx.wizard.selectStep(n) // jump to step n (0-based)
ctx.wizard.cursor // current step index
ctx.wizard.state // per-wizard state object (persisted)
```
---
## Error handling
```typescript
bot.catch((err) => {
console.error("Unhandled error:", err.error);
console.error("Update that caused it:", err.ctx.update);
});
```
`BotError` wraps the original error with the context that was being processed when it was thrown. The default handler logs and rethrows.
For localised error handling, use `errorBoundary`:
```typescript
bot.errorBoundary(
(err, ctx) => ctx.reply("Something went wrong, sorry!"),
dangerousHandler,
);
```
---
## Context flavoring
Plugins extend the context type using TypeScript intersection types — no subclassing required.
```typescript
// Define your flavor
interface TimingFlavor {
startedAt: number;
}
// Intersect with Context
type MyContext = Context & TimingFlavor & SessionFlavor;
// Build the bot with your context type
const bot = new Bot({ signalService: "...", phoneNumber: "..." });
// Add middleware that sets the flavor property
bot.use((ctx, next) => {
ctx.startedAt = Date.now();
return next();
});
// ctx.startedAt is now typed as number everywhere
bot.on("message:text", (ctx) => {
console.log("Handled in", Date.now() - ctx.startedAt, "ms");
});
```
Plugins like `session`, `Stage`, and `WizardScene` all use this pattern via their `*Flavor` types.
---
## API reference
### `Bot`
| Member | Description |
|---|---|
| `new Bot(config)` | `config.signalService`, `config.phoneNumber`, optional `config.ContextConstructor`, `config.transport`, `config.pollingInterval`, `config.groupStateStorage`, `config.groupStateKey` |
| `bot.start()` | Start WebSocket polling. Resolves only after `bot.stop()` is called. |
| `bot.stop()` | Gracefully stop the bot. |
| `bot.handleUpdate(update)` | Process a single `RawUpdate` manually (useful for custom transports). |
| `bot.catch(handler)` | Override the top-level error handler. |
| `bot.api` | The `SignalAPI` instance. |
### `Composer` methods
| Method | Description |
|---|---|
| `use(...mw)` | Register middleware for all updates |
| `on(filter, ...mw)` | Filter by `FilterQuery` string(s), type-narrows context |
| `hears(trigger, ...mw)` | Match text by string or RegExp |
| `command(cmd, ...mw)` | Match `/command` |
| `filter(pred, ...mw)` | Arbitrary predicate filter |
| `drop(pred)` | Stop chain if predicate matches |
| `branch(pred, t, f)` | if/else routing |
| `fork(...mw)` | Run middleware in background |
| `lazy(factory)` | Select middleware at runtime |
| `errorBoundary(handler, ...mw)` | Isolated error scope |
### `SignalAPI`
| Method | Description |
|---|---|
| `send(to, text, options?)` | Send a message |
| `react(to, payload)` | Send or remove a reaction |
| `typing(to, stop?)` | Send typing indicator |
| `editMessage(to, timestamp, text)` | Edit a sent message |
| `deleteMessage(to, timestamp)` | Delete a sent message |
| `getGroups()` | List groups the bot is in |
| `checkHealth()` | Returns `true` if signal-cli-rest-api is reachable |
### Filter queries
See the [filter queries table](#filter-queries) above.
---
## Setting up signal-cli-rest-api
### 1. Start the Docker container
```bash
docker run -d --name signal-api --restart=always \
-p 8080:8080 \
-v $HOME/.local/share/signal-api:/home/.local/share/signal-cli \
-e 'MODE=json-rpc' \
bbernhard/signal-cli-rest-api
```
`MODE=json-rpc` is required for WebSocket support. Other modes (`native`, `normal`) only support REST polling.
### 2. Register your phone number
```bash
curl -X POST 'http://localhost:8080/v1/register/+1234567890'
```
If you get a captcha error:
1. Open https://signalcaptchas.org/registration/generate.html
2. Open your browser's developer console (F12)
3. Complete the captcha
4. In the console, find the line: `Prevented navigation to "signalcaptcha://signal-hcaptcha-short.xxxxx..."`
5. Copy the value after `signalcaptcha://` and pass it:
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"captcha":"signal-hcaptcha-short.xxxxx..."}' \
'http://localhost:8080/v1/register/+1234567890'
```
### 3. Verify with the SMS code
```bash
curl -X POST 'http://localhost:8080/v1/register/+1234567890/verify/123456'
```
### 4. Confirm it's working
```bash
curl http://localhost:8080/v1/health
```
You're now ready to run a bot.