{"id":46044776,"url":"https://github.com/sudodaksh/cygnet","last_synced_at":"2026-03-03T21:03:55.386Z","repository":{"id":341220639,"uuid":"1169364918","full_name":"sudodaksh/cygnet","owner":"sudodaksh","description":"a modern framework for building signal bots","archived":false,"fork":false,"pushed_at":"2026-03-01T05:35:20.000Z","size":876,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-01T08:27:12.611Z","etag":null,"topics":["bot-framework","bun","nodejs","signal","signal-bot","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sudodaksh.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-28T15:23:22.000Z","updated_at":"2026-03-01T05:27:09.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sudodaksh/cygnet","commit_stats":null,"previous_names":["sudodaksh/cygnet"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/sudodaksh/cygnet","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sudodaksh%2Fcygnet","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sudodaksh%2Fcygnet/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sudodaksh%2Fcygnet/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sudodaksh%2Fcygnet/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sudodaksh","download_url":"https://codeload.github.com/sudodaksh/cygnet/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sudodaksh%2Fcygnet/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30060712,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-03T18:21:05.932Z","status":"ssl_error","status_checked_at":"2026-03-03T18:20:59.341Z","response_time":61,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bot-framework","bun","nodejs","signal","signal-bot","typescript"],"created_at":"2026-03-01T07:11:02.875Z","updated_at":"2026-03-03T21:03:55.373Z","avatar_url":"https://github.com/sudodaksh.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\n\u003cdiv align=\"center\"\u003e\u003cimg src=\"./images/header.png\"\u003e\u003c/div\u003e\n\u003cdiv align=\"center\"\u003e\n\n# a modern framework for building [signal](https://signal.org) bots\n\n![NPM Version](https://img.shields.io/npm/v/cygnet)\n![NPM Downloads](https://img.shields.io/npm/dm/cygnet)\n![NPM License](https://img.shields.io/npm/l/cygnet)\n\n\u003c/div\u003e\n\n## Install\n\n```bash\nnpm install cygnet\n```\n\n## Prerequisites\n\n- [Bun](https://bun.sh) (or Node.js with ESM)\n- 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))\n\n## Quick start\n\n```typescript\nimport { Bot } from \"cygnet\";\n\nconst bot = new Bot({\n  signalService: \"localhost:8080\",\n  phoneNumber: \"+491234567890\",\n});\n\nbot.command(\"start\", (ctx) =\u003e ctx.reply(\"Hello!\"));\nbot.on(\"message:text\", (ctx) =\u003e ctx.reply(`You said: ${ctx.text}`));\n\nbot.start();\n```\n\n```bash\nbun run examples/hello-world.ts\n```\n\n---\n\n## Examples\n\nRunnable examples live in [examples/README.md](./examples/README.md):\n\n- [hello-world](./examples/hello-world.ts): minimal bot setup and basic text replies\n- [commands](./examples/commands.ts): command parsing and `ctx.match`\n- [reactions](./examples/reactions.ts): react to messages, handle incoming reactions\n- [quotes-and-replies](./examples/quotes-and-replies.ts): quote messages, handle incoming quotes\n- [typing-and-receipts](./examples/typing-and-receipts.ts): typing indicators, delivery/read receipts\n- [edit-and-delete](./examples/edit-and-delete.ts): edit and delete sent messages, handle edits/deletes\n- [group-updates](./examples/group-updates.ts): best-effort `group_update` handling with persisted state\n- [audio-files](./examples/audio-files.ts): handling incoming audio attachments\n- [wizard-register](./examples/wizard-register.ts): a session-backed multi-step registration flow\n\n---\n\n## Table of contents\n\n- [Examples](#examples)\n- [Bot setup](#bot-setup)\n- [Handling updates](#handling-updates)\n  - [Commands](#commands)\n  - [Text matching](#text-matching)\n  - [Filter queries](#filter-queries)\n  - [Arbitrary filters](#arbitrary-filters)\n- [Context](#context)\n  - [Sending messages](#sending-messages)\n  - [Reactions](#reactions)\n  - [Other actions](#other-actions)\n- [Middleware](#middleware)\n- [Session](#session)\n- [Scenes](#scenes)\n  - [Scene state](#scene-state)\n  - [WizardScene](#wizardscene)\n- [Error handling](#error-handling)\n- [Context flavoring](#context-flavoring)\n- [API reference](#api-reference)\n- [Setting up signal-cli-rest-api](#setting-up-signal-cli-rest-api)\n\n---\n\n## Bot setup\n\n```typescript\nimport { Bot, FileStorage } from \"cygnet\";\n\nconst bot = new Bot({\n  signalService: \"localhost:8080\", // signal-cli-rest-api URL (scheme optional)\n  phoneNumber: \"+491234567890\",    // the bot's registered number\n  groupStateStorage: new FileStorage(\".cygnet-group-state.json\"), // optional\n});\n\nbot.start(); // connects via WebSocket, auto-reconnects on disconnect\nbot.stop();  // graceful shutdown\n```\n\n`bot.start()` calls `GET /v1/health` on startup and then opens a WebSocket to `ws://{signalService}/v1/receive/{phoneNumber}`. Updates are processed sequentially.\n\n---\n\n## Handling updates\n\n### Commands\n\nMatches `/command` (and `/command@anything`) at the start of a message:\n\n```typescript\nbot.command(\"start\", (ctx) =\u003e ctx.reply(\"Welcome!\"));\nbot.command(\"help\",  (ctx) =\u003e ctx.reply(\"Help text here.\"));\nbot.command([\"yes\", \"no\"], (ctx) =\u003e ctx.reply(\"Got a yes/no\"));\n```\n\n### Text matching\n\n```typescript\n// Exact string\nbot.hears(\"ping\", (ctx) =\u003e ctx.reply(\"pong\"));\n\n// Regular expression — ctx.match holds the RegExpExecArray\nbot.hears(/order (\\d+)/i, (ctx) =\u003e {\n  const orderId = ctx.match![1];\n  ctx.reply(`Looking up order ${orderId}…`);\n});\n\n// Array of strings and/or regexes\nbot.hears([\"hi\", \"hello\", /hey+/i], (ctx) =\u003e ctx.reply(\"Hey there!\"));\n```\n\n### Filter queries\n\nFilter 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`).\n\n| Query | Matches |\n|---|---|\n| `\"message\"` | Any regular message (not a reaction) |\n| `\"message:text\"` | Message with non-empty text |\n| `\"message:attachments\"` | Message with one or more attachments |\n| `\"message:quote\"` | Message that quotes another |\n| `\"message:reaction\"` | A reaction to a message |\n| `\"message:group\"` | Message sent in a group |\n| `\"message:private\"` | Message sent in a 1-on-1 DM |\n| `\"message:sticker\"` | Message with a sticker |\n| `\"group_update\"` | Group metadata or membership change |\n| `\"edit_message\"` | An edited message |\n| `\"delete_message\"` | A deleted message |\n| `\"receipt\"` | Read or delivery receipt |\n| `\"typing\"` | Typing indicator |\n| `\"call\"` | Incoming call |\n| `\"sync_message\"` | Sync from a linked device |\n\n```typescript\nbot.on(\"message:text\", (ctx) =\u003e {\n  ctx.text; // string — guaranteed by the filter\n});\n\nbot.on(\"message:reaction\", (ctx) =\u003e {\n  ctx.reaction?.emoji; // the emoji that was reacted with\n});\n\nbot.on(\"message:group\", (ctx) =\u003e {\n  ctx.update.envelope.dataMessage?.groupInfo?.groupId;\n});\n\n// Multiple filters — matches any of them\nbot.on([\"message:text\", \"edit_message\"], (ctx) =\u003e { /* ... */ });\n```\n\n### Arbitrary filters\n\n```typescript\n// Predicate function\nbot.filter(\n  (ctx) =\u003e ctx.from === \"+491234567890\",\n  (ctx) =\u003e ctx.reply(\"Hello, boss!\"),\n);\n\n// Type guard — narrows the context type\nbot.filter(\n  (ctx): ctx is typeof ctx \u0026 { text: string } =\u003e ctx.text !== undefined,\n  (ctx) =\u003e { /* ctx.text is string here */ },\n);\n\n// Drop updates that match (don't process further)\nbot.drop((ctx) =\u003e ctx.isGroup); // ignore all group messages\n```\n\n---\n\n## Context\n\nEvery handler receives a `Context` object. It wraps the raw update and provides shortcuts for the most common operations.\n\n### Update getters\n\n```typescript\nctx.update          // RawUpdate — the full raw envelope from signal-cli-rest-api\nctx.me              // string   — bot's own phone number\n\nctx.dataMessage     // DataMessage | undefined — any data message (incl. reactions)\nctx.message         // DataMessage | undefined — regular message (not reaction, not group_update)\nctx.groupUpdate     // DataMessage | undefined — group metadata/membership update\nctx.reaction        // Reaction   | undefined — the reaction, if this is a reaction update\nctx.editMessage     // EditMessage   | undefined\nctx.deleteMessage   // DeleteMessage | undefined\nctx.receipt         // ReceiptMessage | undefined\nctx.typingMessage   // TypingMessage  | undefined\nctx.callMessage     // CallMessage    | undefined\nctx.syncMessage     // SyncMessage    | undefined\n\nctx.from            // string | undefined — sender phone number\nctx.fromName        // string | undefined — sender display name\nctx.fromUuid        // string | undefined — sender UUID\nctx.chat            // string — group ID (for groups) or sender phone (for DMs)\nctx.isGroup         // boolean\nctx.text            // string — message text or edited text (\"\" if none)\nctx.msgTimestamp    // number | undefined — Unix ms\nctx.match           // string | RegExpExecArray | undefined — set by bot.hears()/bot.command()\n```\n\n### Group state changes\n\n`signal-cli-rest-api` collapses group membership and metadata changes into the\nsame `group_update` payload shape (`groupInfo.type === \"UPDATE\"`). Treat it as\nan eventually consistent state signal, not a perfect event log.\n\ncygnet keeps a per-bot group state cache (name, membership, last revision) and\n`ctx.inspectGroupUpdate()` uses that cache to classify updates as best-effort\n`joined`, `left`, `renamed`, `updated`, `stale`, or `unknown`.\n\n```typescript\nbot.on(\"group_update\", async (ctx) =\u003e {\n  const details = await ctx.inspectGroupUpdate();\n  console.log(details);\n});\n```\n\nNotes:\n- `groupInfo.revision` is treated as the ordering key. Older or duplicate\n  revisions are returned as `stale`.\n- If a revision jump is detected, cygnet logs a gap warning and reconciles\n  against `getGroups()`.\n- `groupStateStorage` accepts a stricter direct-storage type. Built-in\n  `MemoryStorage` and `FileStorage` work, but wrappers like `enhanceStorage()`\n  intentionally do not.\n- This keeps group state on the same adapter family as sessions, while\n  preventing TTL wrappers from being used for revision tracking.\n- Even with the cache, missed upstream events mean cygnet can recover current\n  state, but not reconstruct the exact history of what happened while offline.\n\n### Sending messages\n\n```typescript\n// Send to the same chat (group or DM) the update came from\nawait ctx.reply(\"Hello!\");\n\n// With options\nawait ctx.reply(\"Hello!\", {\n  base64Attachments: [\"...\"],\n  mentions: [{ number: \"+49...\", start: 0, length: 5 }],\n  textMode: \"styled\",\n  viewOnce: true,\n});\n\n// Quote the current message\nawait ctx.quote(\"Good point!\");\n\n// Edit a previously sent message\nawait ctx.api.editMessage(ctx.chat, previousTimestamp, \"corrected text\");\n```\n\n### Reactions\n\n```typescript\n// React to the current message\nawait ctx.react(\"👍\");\n\n// Remove a reaction\nawait ctx.unreact(\"👍\");\n```\n\n### Other actions\n\n```typescript\n// Typing indicator\nawait ctx.typing();        // \"started typing\"\nawait ctx.typing(true);    // \"stopped typing\"\n\n// Delete the current message\nawait ctx.deleteMsg();\n\n// Delete a specific message by timestamp\nawait ctx.deleteMsg(timestamp);\n```\n\n### Direct API access\n\nFor anything not covered by context shortcuts:\n\n```typescript\nawait ctx.api.send(recipient, text, options);\nawait ctx.api.react(recipient, { reaction: \"❤️\", targetAuthor: \"+49...\", targetTimestamp: ts });\nawait ctx.api.getGroups();\nawait ctx.api.checkHealth();\n```\n\n---\n\n## Middleware\n\ncygnet uses Koa-style `(ctx, next)` middleware.\n\n```typescript\n// Runs for every update\nbot.use(async (ctx, next) =\u003e {\n  console.log(\"Update from:\", ctx.from);\n  await next(); // pass to the next handler\n});\n\n// Branching\nbot.branch(\n  (ctx) =\u003e ctx.isGroup,\n  (ctx) =\u003e ctx.reply(\"Hi group!\"),\n  (ctx) =\u003e ctx.reply(\"Hi DM!\"),\n);\n\n// Background — runs concurrently, does not block the chain\nbot.fork(async (ctx) =\u003e {\n  await logToDatabase(ctx.update);\n});\n\n// Lazy — select middleware at runtime\nbot.lazy((ctx) =\u003e {\n  return ctx.isGroup ? groupMiddleware : dmMiddleware;\n});\n\n// Isolated error handling\nbot.errorBoundary(\n  (err, ctx) =\u003e console.error(\"caught:\", err),\n  riskyMiddleware,\n);\n```\n\n`Composer` instances can be used as sub-routers:\n\n```typescript\nimport { Composer } from \"cygnet\";\n\nconst admin = new Composer\u003cMyContext\u003e();\nadmin.command(\"ban\", (ctx) =\u003e { /* ... */ });\nadmin.command(\"kick\", (ctx) =\u003e { /* ... */ });\n\nbot.filter((ctx) =\u003e admins.includes(ctx.from!), admin);\n```\n\n---\n\n## Session\n\nStore per-chat data across messages. Requires a `SessionFlavor` on your context type.\n\n```typescript\nimport { Bot, Context, session, MemoryStorage } from \"cygnet\";\nimport type { SessionFlavor } from \"cygnet\";\n\ninterface MySession {\n  count: number;\n  lastSeen?: number;\n}\n\ntype MyContext = Context \u0026 SessionFlavor\u003cMySession\u003e;\n\nconst bot = new Bot\u003cMyContext\u003e({ signalService: \"...\", phoneNumber: \"...\" });\n\nbot.use(session\u003cMySession, MyContext\u003e({\n  initial: () =\u003e ({ count: 0 }),  // called when no session exists yet\n}));\n\nbot.on(\"message:text\", (ctx) =\u003e {\n  ctx.session.count++;                          // read/write\n  ctx.reply(`Message #${ctx.session.count}`);\n});\n```\n\n### Custom storage\n\nImplement `StorageAdapter\u003cT\u003e` to plug in any backend:\n\n```typescript\nimport type { StorageAdapter } from \"cygnet\";\n\nclass RedisStorage\u003cT\u003e implements StorageAdapter\u003cT\u003e {\n  async read(key: string): Promise\u003cT | undefined\u003e { /* ... */ }\n  async write(key: string, value: T): Promise\u003cvoid\u003e { /* ... */ }\n  async delete(key: string): Promise\u003cvoid\u003e { /* ... */ }\n}\n\nbot.use(session({ storage: new RedisStorage(), initial: () =\u003e ({ count: 0 }) }));\n```\n\nThe 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:\n\n```typescript\nimport { FileStorage } from \"cygnet\";\n\nbot.use(session({\n  storage: new FileStorage(\".cygnet-session.json\"),\n  initial: () =\u003e ({ count: 0 }),\n}));\n```\n\n### Session key\n\nBy default the key is `ctx.chat` (group ID or phone number). Override it:\n\n```typescript\nbot.use(session({\n  getSessionKey: (ctx) =\u003e ctx.fromUuid, // per-user instead of per-chat\n  initial: () =\u003e ({}),\n}));\n```\n\n---\n\n## Scenes\n\nScenes let you build stateful multi-step conversations. They require `session` middleware.\n\n```typescript\nimport { Bot, Context, Stage, BaseScene, session } from \"cygnet\";\nimport type { SessionFlavor, SceneContextFlavor, SceneSessionData } from \"cygnet\";\n\ninterface MySession extends SceneSessionData { /* your fields */ }\ntype MyContext = Context \u0026 SessionFlavor\u003cMySession\u003e \u0026 SceneContextFlavor;\n\nconst greetScene = new BaseScene\u003cMyContext\u003e(\"greet\");\n\ngreetScene.enter((ctx) =\u003e ctx.reply(\"You entered the greet scene!\"));\ngreetScene.on(\"message:text\", async (ctx) =\u003e {\n  await ctx.reply(`Hello, ${ctx.text}!`);\n  await ctx.scene.leave(); // exit the scene\n});\ngreetScene.leave((ctx) =\u003e ctx.reply(\"Bye!\"));\n\nconst stage = new Stage\u003cMyContext\u003e([greetScene]);\n\nconst bot = new Bot\u003cMyContext\u003e({ signalService: \"...\", phoneNumber: \"...\" });\nbot.use(session\u003cMySession, MyContext\u003e({ initial: () =\u003e ({}) }));\n\nbot.command(\"greet\", stage.enter(\"greet\"));    // enter scene\nbot.command(\"cancel\", stage.leave());          // leave scene\nbot.command(\"restart\", stage.reenter());       // re-enter (reset state)\n\nbot.use(stage);\n```\n\nWhile inside a scene, only that scene's handlers run. Updates do not reach bot-level handlers.\n\n### Scene state\n\n```typescript\ngreetScene.enter((ctx) =\u003e {\n  ctx.scene.state.attempts = 0;\n});\n\ngreetScene.on(\"message:text\", async (ctx) =\u003e {\n  ctx.scene.state.attempts = (ctx.scene.state.attempts as number ?? 0) + 1;\n});\n```\n\nState is persisted in the session automatically.\n\n### WizardScene\n\nA `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.\n\n```typescript\nimport { WizardScene } from \"cygnet\";\nimport type { WizardContext, WizardContextFlavor, SceneSessionData } from \"cygnet\";\n\ninterface MySession extends SceneSessionData {}\ntype MyContext = Context \u0026 SessionFlavor\u003cMySession\u003e \u0026 WizardContextFlavor;\n\nconst registerWizard = new WizardScene\u003cMyContext\u003e(\n  \"register\",\n\n  // Step 0\n  async (ctx) =\u003e {\n    await ctx.reply(\"What's your name?\");\n    await ctx.wizard.advance(); // advance to step 1\n  },\n\n  // Step 1\n  async (ctx) =\u003e {\n    await ctx.reply(`Nice to meet you, ${ctx.text}! How old are you?`);\n    await ctx.wizard.advance();\n  },\n\n  // Step 2\n  async (ctx) =\u003e {\n    await ctx.reply(`Got it! Registration complete.`);\n    await ctx.scene.leave();\n  },\n);\n```\n\nWizard controller:\n\n```typescript\nctx.wizard.advance()           // advance one step\nctx.wizard.retreat()           // go back one step\nctx.wizard.selectStep(n)       // jump to step n (0-based)\nctx.wizard.cursor              // current step index\nctx.wizard.state               // per-wizard state object (persisted)\n```\n\n---\n\n## Error handling\n\n```typescript\nbot.catch((err) =\u003e {\n  console.error(\"Unhandled error:\", err.error);\n  console.error(\"Update that caused it:\", err.ctx.update);\n});\n```\n\n`BotError` wraps the original error with the context that was being processed when it was thrown. The default handler logs and rethrows.\n\nFor localised error handling, use `errorBoundary`:\n\n```typescript\nbot.errorBoundary(\n  (err, ctx) =\u003e ctx.reply(\"Something went wrong, sorry!\"),\n  dangerousHandler,\n);\n```\n\n---\n\n## Context flavoring\n\nPlugins extend the context type using TypeScript intersection types — no subclassing required.\n\n```typescript\n// Define your flavor\ninterface TimingFlavor {\n  startedAt: number;\n}\n\n// Intersect with Context\ntype MyContext = Context \u0026 TimingFlavor \u0026 SessionFlavor\u003cMySession\u003e;\n\n// Build the bot with your context type\nconst bot = new Bot\u003cMyContext\u003e({ signalService: \"...\", phoneNumber: \"...\" });\n\n// Add middleware that sets the flavor property\nbot.use((ctx, next) =\u003e {\n  ctx.startedAt = Date.now();\n  return next();\n});\n\n// ctx.startedAt is now typed as number everywhere\nbot.on(\"message:text\", (ctx) =\u003e {\n  console.log(\"Handled in\", Date.now() - ctx.startedAt, \"ms\");\n});\n```\n\nPlugins like `session`, `Stage`, and `WizardScene` all use this pattern via their `*Flavor` types.\n\n---\n\n## API reference\n\n### `Bot\u003cC\u003e`\n\n| Member | Description |\n|---|---|\n| `new Bot(config)` | `config.signalService`, `config.phoneNumber`, optional `config.ContextConstructor`, `config.transport`, `config.pollingInterval`, `config.groupStateStorage`, `config.groupStateKey` |\n| `bot.start()` | Start WebSocket polling. Resolves only after `bot.stop()` is called. |\n| `bot.stop()` | Gracefully stop the bot. |\n| `bot.handleUpdate(update)` | Process a single `RawUpdate` manually (useful for custom transports). |\n| `bot.catch(handler)` | Override the top-level error handler. |\n| `bot.api` | The `SignalAPI` instance. |\n\n### `Composer\u003cC\u003e` methods\n\n| Method | Description |\n|---|---|\n| `use(...mw)` | Register middleware for all updates |\n| `on(filter, ...mw)` | Filter by `FilterQuery` string(s), type-narrows context |\n| `hears(trigger, ...mw)` | Match text by string or RegExp |\n| `command(cmd, ...mw)` | Match `/command` |\n| `filter(pred, ...mw)` | Arbitrary predicate filter |\n| `drop(pred)` | Stop chain if predicate matches |\n| `branch(pred, t, f)` | if/else routing |\n| `fork(...mw)` | Run middleware in background |\n| `lazy(factory)` | Select middleware at runtime |\n| `errorBoundary(handler, ...mw)` | Isolated error scope |\n\n### `SignalAPI`\n\n| Method | Description |\n|---|---|\n| `send(to, text, options?)` | Send a message |\n| `react(to, payload)` | Send or remove a reaction |\n| `typing(to, stop?)` | Send typing indicator |\n| `editMessage(to, timestamp, text)` | Edit a sent message |\n| `deleteMessage(to, timestamp)` | Delete a sent message |\n| `getGroups()` | List groups the bot is in |\n| `checkHealth()` | Returns `true` if signal-cli-rest-api is reachable |\n\n### Filter queries\n\nSee the [filter queries table](#filter-queries) above.\n\n---\n\n## Setting up signal-cli-rest-api\n\n### 1. Start the Docker container\n\n```bash\ndocker run -d --name signal-api --restart=always \\\n  -p 8080:8080 \\\n  -v $HOME/.local/share/signal-api:/home/.local/share/signal-cli \\\n  -e 'MODE=json-rpc' \\\n  bbernhard/signal-cli-rest-api\n```\n\n`MODE=json-rpc` is required for WebSocket support. Other modes (`native`, `normal`) only support REST polling.\n\n### 2. Register your phone number\n\n```bash\ncurl -X POST 'http://localhost:8080/v1/register/+1234567890'\n```\n\nIf you get a captcha error:\n\n1. Open https://signalcaptchas.org/registration/generate.html\n2. Open your browser's developer console (F12)\n3. Complete the captcha\n4. In the console, find the line: `Prevented navigation to \"signalcaptcha://signal-hcaptcha-short.xxxxx...\"`\n5. Copy the value after `signalcaptcha://` and pass it:\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"captcha\":\"signal-hcaptcha-short.xxxxx...\"}' \\\n  'http://localhost:8080/v1/register/+1234567890'\n```\n\n### 3. Verify with the SMS code\n\n```bash\ncurl -X POST 'http://localhost:8080/v1/register/+1234567890/verify/123456'\n```\n\n### 4. Confirm it's working\n\n```bash\ncurl http://localhost:8080/v1/health\n```\n\nYou're now ready to run a bot.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsudodaksh%2Fcygnet","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsudodaksh%2Fcygnet","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsudodaksh%2Fcygnet/lists"}