diff --git a/.changeset/add-signal-adapter.md b/.changeset/add-signal-adapter.md new file mode 100644 index 00000000..8ec3c6f0 --- /dev/null +++ b/.changeset/add-signal-adapter.md @@ -0,0 +1,33 @@ +--- +"@chat-adapter/signal": minor +"chat": minor +--- + +Add a new `@chat-adapter/signal` package for Signal bots powered by `signal-cli-rest-api`. + +**Adapter features:** + +- Incoming updates via webhook (including JSON-RPC receive payloads), REST polling (`pollOnce`/`startPolling`/`stopPolling`), and WebSocket (json-rpc mode) support +- Message send/edit/delete via `/v2/send` and `/v1/remote-delete` +- Reactions (add/remove) via `/v1/reactions` +- Typing indicators via `/v1/typing-indicator` +- File attachments (incoming metadata + lazy download, outgoing base64 data URIs) +- DM and group thread handling with `group.` prefix convention +- Cached message fetch APIs (`fetchMessages`/`fetchMessage`/`fetchChannelMessages`) matching Telegram adapter's in-memory cache style +- Message length truncation (4096 characters, matching Telegram) +- `text_mode` support (`normal`/`styled`) for Signal's markdown formatting + +**Reliability & correctness:** + +- Fail-fast initialization: health check (`/v1/health`) and account verification (`/v1/accounts`) during `initialize()` +- Incoming edit messages dispatched through `chat.processMessage` with stable message IDs across identity alias evolution +- Sync sent messages from linked devices routed through `chat.processMessage` +- Remote delete events remove messages from cache +- Identity canonicalization: phone number/UUID/source aliases tracked and canonicalized, preferring phone format +- Deterministic group ID normalization (inbound binary→base64, outbound validation) +- Full error mapping: 401→`AuthenticationError`, 403→`PermissionError`, 404→`ResourceNotFoundError`, 429→`AdapterRateLimitError`, 400→`ValidationError`, 5xx→`NetworkError` + +**Chat SDK core changes:** + +- Signal-aware user ID inference in `chat.openDM()` for `signal:...` prefixed IDs and E.164 phone numbers +- Message deduplication key includes edit revision suffix (`editedAt` timestamp) so edited messages are not swallowed as duplicates diff --git a/README.md b/README.md index 5e9faa3d..bbfb376a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![npm downloads](https://img.shields.io/npm/dm/chat)](https://www.npmjs.com/package/chat) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -A unified TypeScript SDK for building chat bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, and Linear. Write your bot logic once, deploy everywhere. +A unified TypeScript SDK for building chat bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, Signal, GitHub, and Linear. Write your bot logic once, deploy everywhere. ## Installation @@ -15,7 +15,7 @@ npm install chat Install adapters for your platforms: ```bash -npm install @chat-adapter/slack @chat-adapter/teams @chat-adapter/gchat @chat-adapter/discord @chat-adapter/telegram +npm install @chat-adapter/slack @chat-adapter/teams @chat-adapter/gchat @chat-adapter/discord @chat-adapter/telegram @chat-adapter/signal ``` ## Usage @@ -54,6 +54,7 @@ See the [Getting Started guide](https://chat-sdk.dev/docs/getting-started) for a | Google Chat | `@chat-adapter/gchat` | Yes | Yes | Yes | No | Post+Edit | Yes | | Discord | `@chat-adapter/discord` | Yes | Yes | Yes | No | Post+Edit | Yes | | Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Post+Edit | Yes | +| Signal | `@chat-adapter/signal` | Yes | Yes | Fallback text | No | Post+Edit | Yes | | GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | | Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | @@ -80,6 +81,7 @@ See the [Getting Started guide](https://chat-sdk.dev/docs/getting-started) for a | `@chat-adapter/gchat` | [Google Chat adapter](https://chat-sdk.dev/docs/adapters/gchat) | | `@chat-adapter/discord` | [Discord adapter](https://chat-sdk.dev/docs/adapters/discord) | | `@chat-adapter/telegram` | [Telegram adapter](https://chat-sdk.dev/docs/adapters/telegram) | +| `@chat-adapter/signal` | [Signal adapter](https://chat-sdk.dev/docs/adapters/signal) | | `@chat-adapter/github` | [GitHub adapter](https://chat-sdk.dev/docs/adapters/github) | | `@chat-adapter/linear` | [Linear adapter](https://chat-sdk.dev/docs/adapters/linear) | | `@chat-adapter/state-redis` | [Redis state adapter](https://chat-sdk.dev/docs/state/redis) (production) | diff --git a/apps/docs/app/[lang]/sitemap.md/route.ts b/apps/docs/app/[lang]/sitemap.md/route.ts index ca5a9409..5da238d1 100644 --- a/apps/docs/app/[lang]/sitemap.md/route.ts +++ b/apps/docs/app/[lang]/sitemap.md/route.ts @@ -13,17 +13,17 @@ export const generateStaticParams = async () => { const DOCS_PREFIX_PATTERN = /^\/docs\/?/; const WHITESPACE_PATTERN = /\s+/; -type PageNode = { - title: string; +interface PageNode { + children: PageNode[]; description: string; - url: string; - type?: string; - summary?: string; + lastmod?: string; prerequisites?: string[]; product?: string; - lastmod?: string; - children: PageNode[]; -}; + summary?: string; + title: string; + type?: string; + url: string; +} function buildTree( pages: Array<{ diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index dcd0531e..63db3bd7 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -15,6 +15,7 @@ "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", "@chat-adapter/linear": "workspace:*", + "@chat-adapter/signal": "workspace:*", "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/state-redis": "workspace:*", diff --git a/examples/nextjs-chat/src/app/page.tsx b/examples/nextjs-chat/src/app/page.tsx index dc732444..bd3101d2 100644 --- a/examples/nextjs-chat/src/app/page.tsx +++ b/examples/nextjs-chat/src/app/page.tsx @@ -21,6 +21,9 @@ export default function Home() {
  • /api/webhooks/telegram - Telegram bot updates
  • +
  • + /api/webhooks/signal - Signal bot updates +
  • /api/webhooks/github - GitHub PR comment events
  • @@ -78,6 +81,15 @@ DISCORD_APPLICATION_ID=...`} TELEGRAM_WEBHOOK_SECRET_TOKEN=...`} +

    Signal

    +
    +        {`SIGNAL_PHONE_NUMBER=+1234567890
    +SIGNAL_SERVICE_URL=http://localhost:8080
    +
    +# Configure signal-cli-rest-api to forward updates:
    +RECEIVE_WEBHOOK_URL=https://your-app.com/api/webhooks/signal`}
    +      
    +

    GitHub

             {`# PAT auth (simple)
    diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts
    index 11c362b8..827b711e 100644
    --- a/examples/nextjs-chat/src/lib/adapters.ts
    +++ b/examples/nextjs-chat/src/lib/adapters.ts
    @@ -8,6 +8,7 @@ import {
     } from "@chat-adapter/gchat";
     import { createGitHubAdapter, type GitHubAdapter } from "@chat-adapter/github";
     import { createLinearAdapter, type LinearAdapter } from "@chat-adapter/linear";
    +import { createSignalAdapter, type SignalAdapter } from "@chat-adapter/signal";
     import { createSlackAdapter, type SlackAdapter } from "@chat-adapter/slack";
     import { createTeamsAdapter, type TeamsAdapter } from "@chat-adapter/teams";
     import {
    @@ -25,6 +26,7 @@ export interface Adapters {
       gchat?: GoogleChatAdapter;
       github?: GitHubAdapter;
       linear?: LinearAdapter;
    +  signal?: SignalAdapter;
       slack?: SlackAdapter;
       teams?: TeamsAdapter;
       telegram?: TelegramAdapter;
    @@ -86,6 +88,16 @@ const LINEAR_METHODS = [
       "addReaction",
       "fetchMessages",
     ];
    +const SIGNAL_METHODS = [
    +  "postMessage",
    +  "editMessage",
    +  "deleteMessage",
    +  "addReaction",
    +  "removeReaction",
    +  "startTyping",
    +  "openDM",
    +  "fetchMessages",
    +];
     const TELEGRAM_METHODS = [
       "postMessage",
       "editMessage",
    @@ -204,6 +216,19 @@ export function buildAdapters(): Adapters {
         }
       }
     
    +  // Signal adapter (optional) - env vars: SIGNAL_PHONE_NUMBER (+ optional SIGNAL_SERVICE_URL)
    +  if (process.env.SIGNAL_PHONE_NUMBER) {
    +    adapters.signal = withRecording(
    +      createSignalAdapter({
    +        phoneNumber: process.env.SIGNAL_PHONE_NUMBER,
    +        baseUrl: process.env.SIGNAL_SERVICE_URL ?? process.env.SIGNAL_SERVICE,
    +        logger: logger.child("signal"),
    +      }),
    +      "signal",
    +      SIGNAL_METHODS
    +    );
    +  }
    +
       // Telegram adapter (optional) - env vars: TELEGRAM_BOT_TOKEN
       if (process.env.TELEGRAM_BOT_TOKEN) {
         adapters.telegram = withRecording(
    diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx
    index 3ed8b258..2adbfcdd 100644
    --- a/examples/nextjs-chat/src/lib/bot.tsx
    +++ b/examples/nextjs-chat/src/lib/bot.tsx
    @@ -638,7 +638,13 @@ bot.onNewMessage(/help/i, async (thread, message) => {
     
     // Handle messages in subscribed threads
     bot.onSubscribedMessage(async (thread, message) => {
    -  if (!(thread.adapter.name === "telegram" || message.isMention)) {
    +  if (
    +    !(
    +      thread.adapter.name === "telegram" ||
    +      thread.adapter.name === "signal" ||
    +      message.isMention
    +    )
    +  ) {
         return;
       }
       // Get thread state to check AI mode
    diff --git a/packages/adapter-signal/CHANGELOG.md b/packages/adapter-signal/CHANGELOG.md
    new file mode 100644
    index 00000000..54118574
    --- /dev/null
    +++ b/packages/adapter-signal/CHANGELOG.md
    @@ -0,0 +1,12 @@
    +# @chat-adapter/signal
    +
    +## 4.15.0
    +
    +### Minor Changes
    +
    +- Add a new Signal adapter package with webhook handling, message send/edit/delete, reactions, typing indicators, DM support, and cached fetch APIs.
    +
    +### Patch Changes
    +
    +- chat@4.15.0
    +- @chat-adapter/shared@4.15.0
    diff --git a/packages/adapter-signal/README.md b/packages/adapter-signal/README.md
    new file mode 100644
    index 00000000..f8288d23
    --- /dev/null
    +++ b/packages/adapter-signal/README.md
    @@ -0,0 +1,199 @@
    +# @chat-adapter/signal
    +
    +[![npm version](https://img.shields.io/npm/v/@chat-adapter/signal)](https://www.npmjs.com/package/@chat-adapter/signal)
    +[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/signal)](https://www.npmjs.com/package/@chat-adapter/signal)
    +
    +Signal adapter for [Chat SDK](https://chat-sdk.dev/docs).
    +
    +## Installation
    +
    +```bash
    +npm install chat @chat-adapter/signal
    +```
    +
    +## Setup
    +
    +This adapter requires [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) as the bridge between your bot and the Signal network.
    +
    +### 1. Start signal-cli-rest-api
    +
    +**With webhook delivery** (recommended for production):
    +
    +```bash
    +docker run -d --name signal-api \
    +  -p 8080:8080 \
    +  -v ~/.local/share/signal-cli:/home/.local/share/signal-cli \
    +  -e MODE=json-rpc \
    +  -e RECEIVE_WEBHOOK_URL=http://host.docker.internal:3000/api/webhooks/signal \
    +  bbernhard/signal-cli-rest-api
    +```
    +
    +**Without webhook** (use WebSocket or polling to receive):
    +
    +```bash
    +docker run -d --name signal-api \
    +  -p 8080:8080 \
    +  -v ~/.local/share/signal-cli:/home/.local/share/signal-cli \
    +  -e MODE=json-rpc \
    +  bbernhard/signal-cli-rest-api
    +```
    +
    +> **Note:** `host.docker.internal` resolves to your host machine from inside Docker. On Linux, use your machine's LAN IP instead (e.g. `http://192.168.1.x:3000/api/webhooks/signal`).
    +
    +### 2. Register or link a phone number
    +
    +You need a phone number registered with signal-cli-rest-api. There are two approaches:
    +
    +#### Option A: Link as a secondary device (recommended)
    +
    +This lets you keep using Signal on your phone alongside the bot.
    +
    +1. Open the QR code link page:
    +
    +   ```bash
    +   # Open in your browser:
    +   http://localhost:8080/v1/qrcodelink?device_name=signal-bot
    +   ```
    +
    +2. On your phone, go to **Signal → Settings → Linked Devices → Link New Device** and scan the QR code.
    +
    +3. Verify the link worked:
    +
    +   ```bash
    +   curl http://localhost:8080/v1/accounts
    +   # Should return: ["+1234567890"]
    +   ```
    +
    +#### Option B: Register a new number
    +
    +This registers the number exclusively with signal-cli (your phone will be disconnected from Signal).
    +
    +1. Get a captcha token from [signalcaptchas.org/registration/generate.html](https://signalcaptchas.org/registration/generate.html). After completing the captcha, right-click the "Open Signal" link and copy the URL.
    +
    +2. Register:
    +
    +   ```bash
    +   curl -X POST 'http://localhost:8080/v1/register/+1234567890' \
    +     -H 'Content-Type: application/json' \
    +     -d '{"captcha": "signalcaptcha://signal-recaptcha-v2.yourtoken..."}'
    +   ```
    +
    +3. Verify with the SMS code you receive:
    +
    +   ```bash
    +   curl -X POST 'http://localhost:8080/v1/register/+1234567890/verify/123456'
    +   ```
    +
    +#### Handling captchas during operation
    +
    +Signal may occasionally require a captcha challenge when sending messages (error 429 with `challenge_tokens`). To resolve:
    +
    +1. Get a captcha token from [signalcaptchas.org/challenge/generate.html](https://signalcaptchas.org/challenge/generate.html).
    +
    +2. Submit it along with the challenge token from the error:
    +
    +   ```bash
    +   curl -X POST 'http://localhost:8080/v1/accounts/+1234567890/rate-limit-challenge' \
    +     -H 'Content-Type: application/json' \
    +     -d '{
    +       "challenge_token": "",
    +       "captcha": "signalcaptcha://signal-recaptcha-v2.yourtoken..."
    +     }'
    +   ```
    +
    +### 3. Configure the adapter
    +
    +Set environment variables:
    +
    +```bash
    +SIGNAL_PHONE_NUMBER=+1234567890
    +SIGNAL_SERVICE_URL=http://localhost:8080  # optional, this is the default
    +```
    +
    +## Usage
    +
    +```typescript
    +import { Chat } from "chat";
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +
    +const signal = createSignalAdapter({
    +  phoneNumber: process.env.SIGNAL_PHONE_NUMBER!,
    +  baseUrl: process.env.SIGNAL_SERVICE_URL,
    +});
    +
    +const bot = new Chat({
    +  userName: "mybot",
    +  adapters: {
    +    signal,
    +  },
    +});
    +```
    +
    +During initialization, the adapter performs a fail-fast health check against `signal-cli-rest-api` (`/v1/health`) and verifies that the configured `phoneNumber` is present in `/v1/accounts`. Initialization fails early if either check does not pass.
    +
    +### Receiving updates
    +
    +#### Webhook mode (recommended for hosted deployments)
    +
    +Set `RECEIVE_WEBHOOK_URL` in signal-cli-rest-api (see Docker setup above) to POST incoming updates to your app's Signal webhook endpoint.
    +
    +#### WebSocket mode (recommended for local/self-hosted)
    +
    +In json-rpc mode, connect to the WebSocket endpoint at `ws://localhost:8080/v1/receive/{number}` and feed messages through `handleWebhook()`. See the [local testing examples](../../examples/signal-local/) for a ready-made helper.
    +
    +#### Polling mode
    +
    +The adapter also exposes polling helpers for `GET /v1/receive/{number}` (only works in normal mode, not json-rpc):
    +
    +```typescript
    +signal.startPolling({ intervalMs: 1000 });
    +
    +// later (shutdown)
    +await signal.stopPolling();
    +```
    +
    +Or run a single polling cycle:
    +
    +```typescript
    +const count = await signal.pollOnce();
    +console.log(`Processed ${count} updates`);
    +```
    +
    +## Examples
    +
    +See [`examples/signal-local/`](../../examples/signal-local/) for standalone scripts you can run against a local signal-cli-rest-api instance:
    +
    +- **Health check** — verify connectivity and account registration
    +- **Send / edit / delete** — message lifecycle
    +- **Reactions** — add, replace, remove (Signal allows one reaction per user per message)
    +- **Typing indicators** — show typing, then send
    +- **Group messaging** — post to groups, fetch group metadata
    +- **WebSocket receive** — listen for incoming messages via WebSocket
    +- **Webhook server** — HTTP server for webhook-based receive
    +- **Echo bot** — full echo bot with DM/group support and reaction mirroring
    +
    +## Known Limitations
    +
    +### No delivery guarantees on receive
    +
    +The receive paths exposed by `signal-cli-rest-api` lack formal delivery guarantees — there is no acknowledgment protocol, offset tracking, or retry mechanism at the transport layer. If your process crashes after receiving messages but before fully processing them, those messages may be lost. This is a limitation of `signal-cli-rest-api` itself, not this adapter.
    +
    +- **REST polling** (`GET /v1/receive`): messages are consumed the moment the HTTP response is sent — the riskiest path.
    +- **WebSocket** (`ws://.../v1/receive/{number}`): messages are pushed and immediately considered delivered.
    +- **Webhook** (`RECEIVE_WEBHOOK_URL`): signal-cli-rest-api POSTs synchronously and waits for your response, so messages aren't discarded while your server is processing. However, there is no retry on non-2xx responses. This makes webhooks the most reliable of the three options.
    +
    +### `fetchMessages` and `fetchMessage` are cache-backed
    +
    +Like the Telegram adapter, Signal message fetch APIs are backed by an in-memory cache of messages seen by the current process (incoming webhooks/polls and adapter sends/edits). They are best-effort convenience APIs, not an authoritative server-side history.
    +
    +### One reaction per user per message
    +
    +Signal only allows a single reaction per user per message. Adding a new reaction replaces the previous one.
    +
    +## Documentation
    +
    +Full setup instructions, configuration reference, and features at [chat-sdk.dev/docs/adapters/signal](https://chat-sdk.dev/docs/adapters/signal).
    +
    +## License
    +
    +MIT
    diff --git a/packages/adapter-signal/package.json b/packages/adapter-signal/package.json
    new file mode 100644
    index 00000000..09a17162
    --- /dev/null
    +++ b/packages/adapter-signal/package.json
    @@ -0,0 +1,55 @@
    +{
    +  "name": "@chat-adapter/signal",
    +  "version": "4.15.0",
    +  "description": "Signal adapter for chat",
    +  "type": "module",
    +  "main": "./dist/index.js",
    +  "module": "./dist/index.js",
    +  "types": "./dist/index.d.ts",
    +  "exports": {
    +    ".": {
    +      "types": "./dist/index.d.ts",
    +      "import": "./dist/index.js"
    +    }
    +  },
    +  "files": [
    +    "dist"
    +  ],
    +  "scripts": {
    +    "build": "tsup",
    +    "dev": "tsup --watch",
    +    "test": "vitest run --coverage",
    +    "test:watch": "vitest",
    +    "typecheck": "tsc --noEmit",
    +    "clean": "rm -rf dist"
    +  },
    +  "dependencies": {
    +    "@chat-adapter/shared": "workspace:*",
    +    "chat": "workspace:*"
    +  },
    +  "devDependencies": {
    +    "@types/node": "^25.3.2",
    +    "tsup": "^8.3.5",
    +    "typescript": "^5.7.2",
    +    "vitest": "^4.0.18"
    +  },
    +  "repository": {
    +    "type": "git",
    +    "url": "git+https://github.com/vercel/chat.git",
    +    "directory": "packages/adapter-signal"
    +  },
    +  "homepage": "https://github.com/vercel/chat#readme",
    +  "bugs": {
    +    "url": "https://github.com/vercel/chat/issues"
    +  },
    +  "publishConfig": {
    +    "access": "public"
    +  },
    +  "keywords": [
    +    "chat",
    +    "signal",
    +    "bot",
    +    "adapter"
    +  ],
    +  "license": "MIT"
    +}
    diff --git a/packages/adapter-signal/src/cache.test.ts b/packages/adapter-signal/src/cache.test.ts
    new file mode 100644
    index 00000000..a8e0d66b
    --- /dev/null
    +++ b/packages/adapter-signal/src/cache.test.ts
    @@ -0,0 +1,257 @@
    +import { Message } from "chat";
    +import { describe, expect, it } from "vitest";
    +import { messageTimestamp, SignalMessageCache } from "./cache";
    +import type { SignalRawMessage } from "./types";
    +
    +function createTestMessage(
    +  id: string,
    +  text: string,
    +  threadId: string,
    +  dateSent = new Date()
    +): Message {
    +  return new Message({
    +    id,
    +    text,
    +    threadId,
    +    formatted: { type: "root", children: [] },
    +    raw: {
    +      kind: "outgoing" as const,
    +      author: "+10000000000",
    +      recipient: "+15551234567",
    +      text,
    +      timestamp: Date.now(),
    +    },
    +    author: {
    +      userId: "signal:+10000000000",
    +      userName: "bot",
    +      fullName: "bot",
    +      isBot: true,
    +      isMe: true,
    +    },
    +    metadata: {
    +      dateSent,
    +      edited: false,
    +    },
    +    attachments: [],
    +    isMention: false,
    +  });
    +}
    +
    +describe("messageTimestamp", () => {
    +  it("extracts timestamp from author|timestamp format", () => {
    +    expect(messageTimestamp("+15551234567|1735689600000")).toBe(1735689600000);
    +  });
    +
    +  it("parses plain numeric message IDs", () => {
    +    expect(messageTimestamp("1735689600000")).toBe(1735689600000);
    +  });
    +
    +  it("returns 0 for non-numeric message IDs", () => {
    +    expect(messageTimestamp("not-a-number")).toBe(0);
    +  });
    +
    +  it("handles UUID author in message ID", () => {
    +    expect(messageTimestamp("d77d6cbf-4a80-4f7e-a8ad-c53fdbf36f4d|42")).toBe(
    +      42
    +    );
    +  });
    +});
    +
    +describe("SignalMessageCache", () => {
    +  const THREAD = "signal:+15551234567";
    +
    +  describe("cache", () => {
    +    it("stores and retrieves messages", () => {
    +      const cache = new SignalMessageCache();
    +      const msg = createTestMessage("+1|100", "hello", THREAD);
    +      cache.cache(msg);
    +
    +      expect(cache.findById(THREAD, "+1|100")).toBe(msg);
    +    });
    +
    +    it("updates existing messages with the same ID", () => {
    +      const cache = new SignalMessageCache();
    +      const msg1 = createTestMessage("+1|100", "hello", THREAD);
    +      const msg2 = createTestMessage("+1|100", "updated", THREAD);
    +
    +      cache.cache(msg1);
    +      cache.cache(msg2);
    +
    +      const result = cache.findById(THREAD, "+1|100");
    +      expect(result?.text).toBe("updated");
    +      expect(cache.getThread(THREAD)).toHaveLength(1);
    +    });
    +
    +    it("sorts messages by timestamp", () => {
    +      const cache = new SignalMessageCache();
    +      cache.cache(createTestMessage("+1|300", "third", THREAD, new Date(3000)));
    +      cache.cache(createTestMessage("+1|100", "first", THREAD, new Date(1000)));
    +      cache.cache(
    +        createTestMessage("+1|200", "second", THREAD, new Date(2000))
    +      );
    +
    +      const thread = cache.getThread(THREAD);
    +      expect(thread.map((m) => m.text)).toEqual(["first", "second", "third"]);
    +    });
    +  });
    +
    +  describe("findByTimestamp", () => {
    +    it("finds message by timestamp", () => {
    +      const cache = new SignalMessageCache();
    +      const msg = createTestMessage("+1|42", "hello", THREAD);
    +      cache.cache(msg);
    +
    +      expect(cache.findByTimestamp(THREAD, 42)).toBe(msg);
    +    });
    +
    +    it("returns undefined for non-existent timestamp", () => {
    +      const cache = new SignalMessageCache();
    +      expect(cache.findByTimestamp(THREAD, 42)).toBeUndefined();
    +    });
    +  });
    +
    +  describe("findByTimestampAcrossThreads", () => {
    +    it("finds messages across different threads", () => {
    +      const cache = new SignalMessageCache();
    +      const thread2 = "signal:+15559876543";
    +      const msg = createTestMessage("+1|42", "hello", thread2);
    +      cache.cache(msg);
    +
    +      expect(cache.findByTimestampAcrossThreads(42)).toBe(msg);
    +    });
    +
    +    it("returns undefined when no match exists", () => {
    +      const cache = new SignalMessageCache();
    +      expect(cache.findByTimestampAcrossThreads(99999)).toBeUndefined();
    +    });
    +  });
    +
    +  describe("deleteById", () => {
    +    it("removes a message by ID", () => {
    +      const cache = new SignalMessageCache();
    +      cache.cache(createTestMessage("+1|100", "hello", THREAD));
    +      cache.cache(createTestMessage("+1|200", "world", THREAD));
    +
    +      cache.deleteById("+1|100");
    +
    +      expect(cache.findById(THREAD, "+1|100")).toBeUndefined();
    +      expect(cache.findById(THREAD, "+1|200")).toBeDefined();
    +    });
    +
    +    it("removes thread entry when last message is deleted", () => {
    +      const cache = new SignalMessageCache();
    +      cache.cache(createTestMessage("+1|100", "hello", THREAD));
    +
    +      cache.deleteById("+1|100");
    +
    +      expect(cache.getThread(THREAD)).toEqual([]);
    +    });
    +  });
    +
    +  describe("deleteByTimestamp", () => {
    +    it("removes messages matching a timestamp", () => {
    +      const cache = new SignalMessageCache();
    +      cache.cache(createTestMessage("+1|100", "hello", THREAD, new Date(1000)));
    +      cache.cache(createTestMessage("+1|200", "world", THREAD, new Date(2000)));
    +
    +      cache.deleteByTimestamp(THREAD, 100);
    +
    +      expect(cache.findByTimestamp(THREAD, 100)).toBeUndefined();
    +      expect(cache.findByTimestamp(THREAD, 200)).toBeDefined();
    +    });
    +
    +    it("does nothing for non-existent thread", () => {
    +      const cache = new SignalMessageCache();
    +      cache.deleteByTimestamp("signal:nonexistent", 100);
    +    });
    +  });
    +
    +  describe("paginate", () => {
    +    function buildSortedMessages(count: number): Message[] {
    +      return Array.from({ length: count }, (_, i) =>
    +        createTestMessage(
    +          `+1|${i + 1}`,
    +          `m${i + 1}`,
    +          THREAD,
    +          new Date((i + 1) * 1000)
    +        )
    +      );
    +    }
    +
    +    it("returns empty result for empty messages", () => {
    +      const cache = new SignalMessageCache();
    +      const result = cache.paginate([], {});
    +      expect(result.messages).toEqual([]);
    +    });
    +
    +    it("paginates backward from end", () => {
    +      const cache = new SignalMessageCache();
    +      const messages = buildSortedMessages(5);
    +
    +      const result = cache.paginate(messages, {
    +        limit: 2,
    +        direction: "backward",
    +      });
    +      expect(result.messages.map((m) => m.text)).toEqual(["m4", "m5"]);
    +      expect(result.nextCursor).toBe("+1|4");
    +    });
    +
    +    it("paginates forward from start", () => {
    +      const cache = new SignalMessageCache();
    +      const messages = buildSortedMessages(5);
    +
    +      const result = cache.paginate(messages, {
    +        limit: 2,
    +        direction: "forward",
    +      });
    +      expect(result.messages.map((m) => m.text)).toEqual(["m1", "m2"]);
    +      expect(result.nextCursor).toBe("+1|2");
    +    });
    +
    +    it("uses cursor for backward pagination", () => {
    +      const cache = new SignalMessageCache();
    +      const messages = buildSortedMessages(5);
    +
    +      const result = cache.paginate(messages, {
    +        limit: 2,
    +        direction: "backward",
    +        cursor: "+1|4",
    +      });
    +      expect(result.messages.map((m) => m.text)).toEqual(["m2", "m3"]);
    +    });
    +
    +    it("uses cursor for forward pagination", () => {
    +      const cache = new SignalMessageCache();
    +      const messages = buildSortedMessages(5);
    +
    +      const result = cache.paginate(messages, {
    +        limit: 2,
    +        direction: "forward",
    +        cursor: "+1|2",
    +      });
    +      expect(result.messages.map((m) => m.text)).toEqual(["m3", "m4"]);
    +    });
    +
    +    it("clamps limit between 1 and 100", () => {
    +      const cache = new SignalMessageCache();
    +      const messages = buildSortedMessages(3);
    +
    +      const result = cache.paginate(messages, { limit: 0 });
    +      expect(result.messages).toHaveLength(1);
    +
    +      const result2 = cache.paginate(messages, { limit: 200 });
    +      expect(result2.messages).toHaveLength(3);
    +    });
    +
    +    it("returns no nextCursor when all messages fit", () => {
    +      const cache = new SignalMessageCache();
    +      const messages = buildSortedMessages(2);
    +
    +      const result = cache.paginate(messages, {
    +        limit: 10,
    +        direction: "forward",
    +      });
    +      expect(result.nextCursor).toBeUndefined();
    +    });
    +  });
    +});
    diff --git a/packages/adapter-signal/src/cache.ts b/packages/adapter-signal/src/cache.ts
    new file mode 100644
    index 00000000..12236be4
    --- /dev/null
    +++ b/packages/adapter-signal/src/cache.ts
    @@ -0,0 +1,161 @@
    +import type { FetchOptions, FetchResult, Message } from "chat";
    +import type { SignalRawMessage } from "./types";
    +
    +const MESSAGE_ID_PATTERN = /^(.*)\|(\d+)$/;
    +
    +export class SignalMessageCache {
    +  private readonly threads = new Map[]>();
    +
    +  cache(message: Message): void {
    +    const existing = this.threads.get(message.threadId) ?? [];
    +    const index = existing.findIndex((item) => item.id === message.id);
    +
    +    if (index >= 0) {
    +      existing[index] = message;
    +    } else {
    +      existing.push(message);
    +    }
    +
    +    existing.sort((a, b) => this.compareMessages(a, b));
    +    this.threads.set(message.threadId, existing);
    +  }
    +
    +  findByTimestamp(
    +    threadId: string,
    +    timestamp: number
    +  ): Message | undefined {
    +    const messages = this.threads.get(threadId) ?? [];
    +    return messages.find(
    +      (message) => messageTimestamp(message.id) === timestamp
    +    );
    +  }
    +
    +  findByTimestampAcrossThreads(
    +    timestamp: number
    +  ): Message | undefined {
    +    for (const messages of this.threads.values()) {
    +      const matched = messages.find(
    +        (message) => messageTimestamp(message.id) === timestamp
    +      );
    +      if (matched) {
    +        return matched;
    +      }
    +    }
    +
    +    return undefined;
    +  }
    +
    +  findById(
    +    threadId: string,
    +    messageId: string
    +  ): Message | undefined {
    +    const messages = this.threads.get(threadId) ?? [];
    +    return messages.find((message) => message.id === messageId);
    +  }
    +
    +  getThread(threadId: string): Message[] {
    +    return [...(this.threads.get(threadId) ?? [])].sort((a, b) =>
    +      this.compareMessages(a, b)
    +    );
    +  }
    +
    +  deleteById(messageId: string): void {
    +    for (const [threadId, messages] of this.threads.entries()) {
    +      const filtered = messages.filter((message) => message.id !== messageId);
    +      if (filtered.length === 0) {
    +        this.threads.delete(threadId);
    +      } else if (filtered.length !== messages.length) {
    +        this.threads.set(threadId, filtered);
    +      }
    +    }
    +  }
    +
    +  deleteByTimestamp(threadId: string, timestamp: number): void {
    +    const messages = this.threads.get(threadId);
    +    if (!messages) {
    +      return;
    +    }
    +
    +    const filtered = messages.filter(
    +      (message) => messageTimestamp(message.id) !== timestamp
    +    );
    +
    +    if (filtered.length === 0) {
    +      this.threads.delete(threadId);
    +      return;
    +    }
    +
    +    if (filtered.length !== messages.length) {
    +      this.threads.set(threadId, filtered);
    +    }
    +  }
    +
    +  paginate(
    +    messages: Message[],
    +    options: FetchOptions
    +  ): FetchResult {
    +    const limit = Math.max(1, Math.min(options.limit ?? 50, 100));
    +    const direction = options.direction ?? "backward";
    +
    +    if (messages.length === 0) {
    +      return { messages: [] };
    +    }
    +
    +    const indexById = new Map(
    +      messages.map((message, index) => [message.id, index])
    +    );
    +
    +    if (direction === "backward") {
    +      const end =
    +        options.cursor && indexById.has(options.cursor)
    +          ? (indexById.get(options.cursor) ?? messages.length)
    +          : messages.length;
    +      const start = Math.max(0, end - limit);
    +      const page = messages.slice(start, end);
    +
    +      return {
    +        messages: page,
    +        nextCursor: start > 0 ? page[0]?.id : undefined,
    +      };
    +    }
    +
    +    const start =
    +      options.cursor && indexById.has(options.cursor)
    +        ? (indexById.get(options.cursor) ?? -1) + 1
    +        : 0;
    +
    +    const end = Math.min(messages.length, start + limit);
    +    const page = messages.slice(start, end);
    +
    +    return {
    +      messages: page,
    +      nextCursor: end < messages.length ? page.at(-1)?.id : undefined,
    +    };
    +  }
    +
    +  private compareMessages(
    +    a: Message,
    +    b: Message
    +  ): number {
    +    const timestampDifference =
    +      a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime();
    +    if (timestampDifference !== 0) {
    +      return timestampDifference;
    +    }
    +
    +    return messageTimestamp(a.id) - messageTimestamp(b.id);
    +  }
    +}
    +
    +export function messageTimestamp(messageId: string): number {
    +  const matched = messageId.match(MESSAGE_ID_PATTERN);
    +  if (matched) {
    +    const timestamp = Number.parseInt(matched[2], 10);
    +    if (Number.isFinite(timestamp)) {
    +      return timestamp;
    +    }
    +  }
    +
    +  const fallback = Number.parseInt(messageId, 10);
    +  return Number.isFinite(fallback) ? fallback : 0;
    +}
    diff --git a/packages/adapter-signal/src/identity.test.ts b/packages/adapter-signal/src/identity.test.ts
    new file mode 100644
    index 00000000..7ba09252
    --- /dev/null
    +++ b/packages/adapter-signal/src/identity.test.ts
    @@ -0,0 +1,143 @@
    +import { describe, expect, it } from "vitest";
    +import { isPhoneNumber, SignalIdentityMap } from "./identity";
    +
    +describe("isPhoneNumber", () => {
    +  it("accepts valid E.164 phone numbers", () => {
    +    expect(isPhoneNumber("+15551234567")).toBe(true);
    +    expect(isPhoneNumber("+491234567890")).toBe(true);
    +    expect(isPhoneNumber("+8612345678901")).toBe(true);
    +  });
    +
    +  it("rejects invalid formats", () => {
    +    expect(isPhoneNumber("15551234567")).toBe(false);
    +    expect(isPhoneNumber("+0123456789")).toBe(false);
    +    expect(isPhoneNumber("+1")).toBe(false);
    +    expect(isPhoneNumber("")).toBe(false);
    +    expect(isPhoneNumber("uuid-string")).toBe(false);
    +    expect(isPhoneNumber("+abc")).toBe(false);
    +  });
    +});
    +
    +describe("SignalIdentityMap", () => {
    +  describe("canonicalize", () => {
    +    it("returns the value itself when no aliases exist", () => {
    +      const map = new SignalIdentityMap();
    +      expect(map.canonicalize("+15551234567")).toBe("+15551234567");
    +    });
    +
    +    it("returns empty string for empty input", () => {
    +      const map = new SignalIdentityMap();
    +      expect(map.canonicalize("")).toBe("");
    +    });
    +
    +    it("trims whitespace", () => {
    +      const map = new SignalIdentityMap();
    +      expect(map.canonicalize("  +15551234567  ")).toBe("+15551234567");
    +    });
    +
    +    it("resolves a UUID to a phone number after alias registration", () => {
    +      const map = new SignalIdentityMap();
    +      map.registerAliases("+15551234567", "uuid-abc-123");
    +
    +      expect(map.canonicalize("uuid-abc-123")).toBe("+15551234567");
    +      expect(map.canonicalize("+15551234567")).toBe("+15551234567");
    +    });
    +
    +    it("follows alias chains", () => {
    +      const map = new SignalIdentityMap();
    +      map.registerAliases("+15551234567", "uuid-1");
    +      map.registerAliases("+15551234567", "uuid-2");
    +
    +      expect(map.canonicalize("uuid-1")).toBe("+15551234567");
    +      expect(map.canonicalize("uuid-2")).toBe("+15551234567");
    +    });
    +
    +    it("detects cycles and returns the cycle entry point", () => {
    +      const map = new SignalIdentityMap();
    +
    +      // Manually create a cycle scenario:
    +      // Register A -> B
    +      map.registerAliases("A", "B");
    +      // Register B -> A (creates cycle)
    +      map.registerAliases("B", "A");
    +
    +      // Should not infinite loop — returns the value it lands on when cycle detected
    +      const result = map.canonicalize("A");
    +      expect(["A", "B"]).toContain(result);
    +    });
    +
    +    it("handles self-referencing aliases", () => {
    +      const map = new SignalIdentityMap();
    +      map.registerAliases("+15551234567");
    +
    +      expect(map.canonicalize("+15551234567")).toBe("+15551234567");
    +    });
    +  });
    +
    +  describe("registerAliases", () => {
    +    it("returns undefined when no identifiers are provided", () => {
    +      const map = new SignalIdentityMap();
    +      expect(map.registerAliases()).toBeUndefined();
    +    });
    +
    +    it("returns undefined when all identifiers are null or undefined", () => {
    +      const map = new SignalIdentityMap();
    +      expect(map.registerAliases(null, undefined, "")).toBeUndefined();
    +    });
    +
    +    it("returns the canonical identifier", () => {
    +      const map = new SignalIdentityMap();
    +      const result = map.registerAliases("+15551234567", "uuid-abc");
    +
    +      expect(result).toBe("+15551234567");
    +    });
    +
    +    it("prefers phone numbers as canonical", () => {
    +      const map = new SignalIdentityMap();
    +      const result = map.registerAliases("uuid-abc", "+15551234567");
    +
    +      expect(result).toBe("+15551234567");
    +    });
    +
    +    it("uses the first identifier when no phone number is present", () => {
    +      const map = new SignalIdentityMap();
    +      const result = map.registerAliases("uuid-abc", "uuid-def");
    +
    +      expect(result).toBe("uuid-abc");
    +    });
    +
    +    it("updates aliases when phone number becomes available", () => {
    +      const map = new SignalIdentityMap();
    +
    +      // First seen with UUID only
    +      map.registerAliases("uuid-abc");
    +      expect(map.canonicalize("uuid-abc")).toBe("uuid-abc");
    +
    +      // Later seen with phone number
    +      map.registerAliases("+15551234567", "uuid-abc");
    +      expect(map.canonicalize("uuid-abc")).toBe("+15551234567");
    +    });
    +
    +    it("skips null and undefined identifiers", () => {
    +      const map = new SignalIdentityMap();
    +      const result = map.registerAliases(
    +        null,
    +        "+15551234567",
    +        undefined,
    +        "uuid-abc"
    +      );
    +
    +      expect(result).toBe("+15551234567");
    +      expect(map.canonicalize("uuid-abc")).toBe("+15551234567");
    +    });
    +
    +    it("handles three-way alias registration", () => {
    +      const map = new SignalIdentityMap();
    +      map.registerAliases("+15551234567", "uuid-abc", "username.01");
    +
    +      expect(map.canonicalize("uuid-abc")).toBe("+15551234567");
    +      expect(map.canonicalize("username.01")).toBe("+15551234567");
    +      expect(map.canonicalize("+15551234567")).toBe("+15551234567");
    +    });
    +  });
    +});
    diff --git a/packages/adapter-signal/src/identity.ts b/packages/adapter-signal/src/identity.ts
    new file mode 100644
    index 00000000..bdd41ffc
    --- /dev/null
    +++ b/packages/adapter-signal/src/identity.ts
    @@ -0,0 +1,57 @@
    +const SIGNAL_PHONE_NUMBER_PATTERN = /^\+[1-9]\d{6,14}$/;
    +
    +export class SignalIdentityMap {
    +  private readonly aliases = new Map();
    +
    +  canonicalize(value: string): string {
    +    const normalized = value.trim();
    +    if (!normalized) {
    +      return normalized;
    +    }
    +
    +    const visited = new Set();
    +    let current = normalized;
    +
    +    while (!visited.has(current)) {
    +      visited.add(current);
    +      const aliased = this.aliases.get(current);
    +      if (!aliased || aliased === current) {
    +        return current;
    +      }
    +      current = aliased;
    +    }
    +
    +    return current;
    +  }
    +
    +  registerAliases(
    +    ...identifiers: Array
    +  ): string | undefined {
    +    const normalized = identifiers
    +      .map((id) => (id ? id.trim() : undefined))
    +      .filter((id): id is string => Boolean(id));
    +
    +    if (normalized.length === 0) {
    +      return undefined;
    +    }
    +
    +    const canonicalCandidate =
    +      normalized.find((id) => isPhoneNumber(id)) ?? normalized[0];
    +
    +    if (!canonicalCandidate) {
    +      return undefined;
    +    }
    +
    +    const canonical = this.canonicalize(canonicalCandidate);
    +
    +    for (const id of normalized) {
    +      this.aliases.set(id, canonical);
    +    }
    +
    +    return canonical;
    +  }
    +}
    +
    +export function isPhoneNumber(value: string): boolean {
    +  return SIGNAL_PHONE_NUMBER_PATTERN.test(value);
    +}
    diff --git a/packages/adapter-signal/src/index.test.ts b/packages/adapter-signal/src/index.test.ts
    new file mode 100644
    index 00000000..2c5bb70e
    --- /dev/null
    +++ b/packages/adapter-signal/src/index.test.ts
    @@ -0,0 +1,1436 @@
    +import {
    +  AdapterRateLimitError,
    +  AuthenticationError,
    +  NetworkError,
    +  PermissionError,
    +  ResourceNotFoundError,
    +  ValidationError,
    +} from "@chat-adapter/shared";
    +import type { ChatInstance, Logger, StateAdapter } from "chat";
    +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
    +import {
    +  createSignalAdapter,
    +  SignalAdapter,
    +  type SignalEnvelope,
    +  type SignalUpdate,
    +} from "./index";
    +
    +const mockLogger: Logger = {
    +  debug: vi.fn(),
    +  info: vi.fn(),
    +  warn: vi.fn(),
    +  error: vi.fn(),
    +  child: vi.fn().mockReturnThis(),
    +};
    +
    +const mockFetch = vi.fn();
    +
    +beforeEach(() => {
    +  mockFetch.mockReset();
    +  vi.stubGlobal("fetch", mockFetch);
    +});
    +
    +afterEach(() => {
    +  vi.unstubAllGlobals();
    +});
    +
    +function signalOk(result: unknown, status = 200): Response {
    +  return new Response(JSON.stringify(result), {
    +    status,
    +    headers: { "content-type": "application/json" },
    +  });
    +}
    +
    +function signalEmpty(status = 204): Response {
    +  return new Response(null, { status });
    +}
    +
    +function signalError(status: number, error: string): Response {
    +  return new Response(JSON.stringify({ error }), {
    +    status,
    +    headers: { "content-type": "application/json" },
    +  });
    +}
    +
    +function createMockState(): StateAdapter {
    +  const cache = new Map();
    +
    +  return {
    +    connect: vi.fn().mockResolvedValue(undefined),
    +    disconnect: vi.fn().mockResolvedValue(undefined),
    +    subscribe: vi.fn().mockResolvedValue(undefined),
    +    unsubscribe: vi.fn().mockResolvedValue(undefined),
    +    isSubscribed: vi.fn().mockResolvedValue(false),
    +    acquireLock: vi.fn().mockResolvedValue(null),
    +    releaseLock: vi.fn().mockResolvedValue(undefined),
    +    extendLock: vi.fn().mockResolvedValue(false),
    +    get: vi.fn(async (key: string) => (cache.get(key) ?? null) as unknown),
    +    set: vi.fn(async (key: string, value: unknown) => {
    +      cache.set(key, value);
    +    }),
    +    delete: vi.fn(async (key: string) => {
    +      cache.delete(key);
    +    }),
    +  };
    +}
    +
    +function createMockChat(state: StateAdapter = createMockState()): ChatInstance {
    +  return {
    +    getLogger: vi.fn().mockReturnValue(mockLogger),
    +    getState: vi.fn().mockReturnValue(state),
    +    getUserName: vi.fn().mockReturnValue("mybot"),
    +    handleIncomingMessage: vi.fn().mockResolvedValue(undefined),
    +    processMessage: vi.fn(),
    +    processReaction: vi.fn(),
    +    processAction: vi.fn(),
    +    processModalClose: vi.fn(),
    +    processModalSubmit: vi.fn().mockResolvedValue(undefined),
    +    processSlashCommand: vi.fn(),
    +    processAssistantThreadStarted: vi.fn(),
    +    processAssistantContextChanged: vi.fn(),
    +    processAppHomeOpened: vi.fn(),
    +  } as unknown as ChatInstance;
    +}
    +
    +function queueSuccessfulHealthCheck(phoneNumber: string): void {
    +  mockFetch
    +    .mockResolvedValueOnce(signalOk({ status: "ok" }))
    +    .mockResolvedValueOnce(signalOk([phoneNumber]));
    +}
    +
    +async function initializeAdapter(
    +  adapter: SignalAdapter,
    +  chat: ChatInstance,
    +  phoneNumber = "+10000000000"
    +): Promise {
    +  queueSuccessfulHealthCheck(phoneNumber);
    +  await adapter.initialize(chat);
    +}
    +
    +function buildUpdate(envelopeOverrides: Partial): SignalUpdate {
    +  return {
    +    account: "+10000000000",
    +    envelope: {
    +      source: "d77d6cbf-4a80-4f7e-a8ad-c53fdbf36f4d",
    +      sourceNumber: "+15551234567",
    +      sourceUuid: "d77d6cbf-4a80-4f7e-a8ad-c53fdbf36f4d",
    +      sourceName: "Alice",
    +      timestamp: 1_735_689_600_000,
    +      ...envelopeOverrides,
    +    },
    +  };
    +}
    +
    +describe("createSignalAdapter", () => {
    +  it("throws when phone number is missing", () => {
    +    process.env.SIGNAL_PHONE_NUMBER = "";
    +
    +    expect(() => createSignalAdapter({ logger: mockLogger })).toThrow(
    +      ValidationError
    +    );
    +  });
    +
    +  it("uses env var config when explicit config is omitted", () => {
    +    process.env.SIGNAL_PHONE_NUMBER = "+19998887777";
    +
    +    const adapter = createSignalAdapter({ logger: mockLogger });
    +
    +    expect(adapter).toBeInstanceOf(SignalAdapter);
    +    expect(adapter.name).toBe("signal");
    +  });
    +});
    +
    +describe("SignalAdapter", () => {
    +  it("encodes and decodes thread IDs", () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    expect(adapter.encodeThreadId({ chatId: "+15551234567" })).toBe(
    +      "signal:+15551234567"
    +    );
    +    expect(adapter.encodeThreadId({ chatId: "group.c29tZS1ncm91cA==" })).toBe(
    +      "signal:group.c29tZS1ncm91cA=="
    +    );
    +
    +    expect(adapter.decodeThreadId("signal:+15551234567")).toEqual({
    +      chatId: "+15551234567",
    +    });
    +  });
    +
    +  it("fails initialization when Signal service health check fails", async () => {
    +    mockFetch.mockResolvedValueOnce(signalError(503, "Service unavailable"));
    +
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await expect(adapter.initialize(createMockChat())).rejects.toBeInstanceOf(
    +      NetworkError
    +    );
    +  });
    +
    +  it("fails initialization when configured account is not linked", async () => {
    +    mockFetch
    +      .mockResolvedValueOnce(signalOk({ status: "ok" }))
    +      .mockResolvedValueOnce(signalOk(["+19998887777"]));
    +
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await expect(adapter.initialize(createMockChat())).rejects.toBeInstanceOf(
    +      ValidationError
    +    );
    +  });
    +
    +  it("rejects webhook requests with an invalid secret", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      webhookSecret: "expected",
    +      logger: mockLogger,
    +    });
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +        "x-signal-webhook-secret": "wrong",
    +      },
    +      body: JSON.stringify(buildUpdate({})),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(401);
    +  });
    +
    +  it("processes incoming data messages and marks mentions", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +      userName: "mybot",
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_100,
    +            message: "Hello there",
    +            mentions: [
    +              {
    +                author: "+10000000000",
    +                start: 0,
    +                length: 5,
    +              },
    +            ],
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(1);
    +
    +    const [, threadId, parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { text: string; isMention?: boolean },
    +    ];
    +
    +    expect(threadId).toBe("signal:+15551234567");
    +    expect(parsedMessage.text).toBe("Hello there");
    +    expect(parsedMessage.isMention).toBe(true);
    +  });
    +
    +  it("normalizes incoming group IDs without base64 guessing heuristics", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_150,
    +            message: "Group hello",
    +            groupInfo: {
    +              groupId: "test",
    +              type: "DELIVER",
    +            },
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(1);
    +
    +    const [, threadId] = processMessage.mock.calls[0] as [unknown, string];
    +    expect(threadId).toBe("signal:group.dGVzdA==");
    +  });
    +
    +  it("processes incoming edit messages through processMessage", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          editMessage: {
    +            targetSentTimestamp: 1_735_689_600_100,
    +            dataMessage: {
    +              timestamp: 1_735_689_600_300,
    +              message: "Edited text",
    +            },
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(1);
    +
    +    const [, , parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { text: string; metadata: { edited: boolean } },
    +    ];
    +
    +    expect(parsedMessage.text).toBe("Edited text");
    +    expect(parsedMessage.metadata.edited).toBe(true);
    +  });
    +
    +  it("keeps edit message IDs stable when identifier aliases change", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const originalMessageRequest = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          source: "uuid-user-1",
    +          sourceNumber: null,
    +          sourceUuid: "uuid-user-1",
    +          dataMessage: {
    +            timestamp: 1_735_689_600_500,
    +            message: "Original",
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(originalMessageRequest);
    +
    +    const editRequest = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          source: "uuid-user-1",
    +          sourceNumber: "+15550001111",
    +          sourceUuid: "uuid-user-1",
    +          editMessage: {
    +            targetSentTimestamp: 1_735_689_600_500,
    +            dataMessage: {
    +              timestamp: 1_735_689_600_700,
    +              message: "Edited",
    +            },
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(editRequest);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(2);
    +
    +    const [, , originalMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { id: string },
    +    ];
    +
    +    const [, , editedMessage] = processMessage.mock.calls[1] as [
    +      unknown,
    +      string,
    +      { id: string; metadata: { edited: boolean } },
    +    ];
    +
    +    expect(editedMessage.id).toBe(originalMessage.id);
    +    expect(editedMessage.metadata.edited).toBe(true);
    +  });
    +
    +  it("processes JSON-RPC receive wrapper payloads and emits reactions", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify({
    +        jsonrpc: "2.0",
    +        method: "receive",
    +        params: buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_200,
    +            reaction: {
    +              emoji: "🔥",
    +              targetAuthor: "+19995551212",
    +              targetSentTimestamp: 42,
    +              isRemove: false,
    +            },
    +          },
    +        }),
    +      }),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processReaction = chat.processReaction as ReturnType;
    +    expect(processReaction).toHaveBeenCalledTimes(1);
    +
    +    const [event] = processReaction.mock.calls[0] as [
    +      { messageId: string; rawEmoji: string; added: boolean },
    +    ];
    +
    +    expect(event.messageId).toBe("+19995551212|42");
    +    expect(event.rawEmoji).toBe("🔥");
    +    expect(event.added).toBe(true);
    +  });
    +
    +  it("processes sync sent messages from linked devices through processMessage", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: {
    +        "content-type": "application/json",
    +      },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          source: "bot-uuid",
    +          sourceNumber: "+10000000000",
    +          sourceUuid: "bot-uuid",
    +          sourceName: "Bot",
    +          sourceDevice: 2,
    +          syncMessage: {
    +            sentMessage: {
    +              timestamp: 1_735_689_600_800,
    +              destination: "+15551234567",
    +              message: "sent from linked device",
    +            },
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(1);
    +
    +    const [, threadId, parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { text: string; author: { isMe: boolean } },
    +    ];
    +
    +    expect(threadId).toBe("signal:+15551234567");
    +    expect(parsedMessage.text).toBe("sent from linked device");
    +    expect(parsedMessage.author.isMe).toBe(false);
    +  });
    +
    +  it("polls /v1/receive and dispatches incoming updates", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(
    +      signalOk([
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_400,
    +            message: "polled update",
    +          },
    +        }),
    +      ])
    +    );
    +
    +    const processed = await adapter.pollOnce();
    +
    +    expect(processed).toBe(1);
    +    expect(String(mockFetch.mock.calls[0]?.[0])).toContain(
    +      "/v1/receive/%2B10000000000"
    +    );
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(1);
    +  });
    +
    +  it("posts, edits, deletes, reacts, and sends typing indicators", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch
    +      .mockResolvedValueOnce(signalOk({ timestamp: "1001" }, 201))
    +      .mockResolvedValueOnce(signalOk({ timestamp: "1002" }, 201))
    +      .mockResolvedValueOnce(signalOk({ timestamp: "1001" }, 201))
    +      .mockResolvedValueOnce(signalEmpty(204))
    +      .mockResolvedValueOnce(signalEmpty(204))
    +      .mockResolvedValueOnce(signalEmpty(204));
    +
    +    const posted = await adapter.postMessage("signal:+15551234567", "hello");
    +    expect(posted.id).toBe("+10000000000|1001");
    +
    +    await adapter.editMessage("signal:+15551234567", posted.id, "updated");
    +    await adapter.deleteMessage("signal:+15551234567", posted.id);
    +    await adapter.addReaction("signal:+15551234567", posted.id, "thumbs_up");
    +    await adapter.removeReaction("signal:+15551234567", posted.id, "thumbs_up");
    +    await adapter.startTyping("signal:+15551234567");
    +
    +    const postUrl = String(mockFetch.mock.calls[0]?.[0]);
    +    const editUrl = String(mockFetch.mock.calls[1]?.[0]);
    +    const deleteUrl = String(mockFetch.mock.calls[2]?.[0]);
    +    const addReactionUrl = String(mockFetch.mock.calls[3]?.[0]);
    +    const removeReactionUrl = String(mockFetch.mock.calls[4]?.[0]);
    +    const typingUrl = String(mockFetch.mock.calls[5]?.[0]);
    +
    +    expect(postUrl).toContain("/v2/send");
    +    expect(editUrl).toContain("/v2/send");
    +    expect(deleteUrl).toContain("/v1/remote-delete/%2B10000000000");
    +    expect(addReactionUrl).toContain("/v1/reactions/%2B10000000000");
    +    expect(removeReactionUrl).toContain("/v1/reactions/%2B10000000000");
    +    expect(typingUrl).toContain("/v1/typing-indicator/%2B10000000000");
    +
    +    const postBody = JSON.parse(
    +      String((mockFetch.mock.calls[0]?.[1] as RequestInit).body)
    +    ) as {
    +      message: string;
    +      number: string;
    +      recipients: string[];
    +    };
    +
    +    const editBody = JSON.parse(
    +      String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
    +    ) as {
    +      edit_timestamp: number;
    +      message: string;
    +    };
    +
    +    const reactionBody = JSON.parse(
    +      String((mockFetch.mock.calls[3]?.[1] as RequestInit).body)
    +    ) as {
    +      reaction: string;
    +      target_author: string;
    +      timestamp: number;
    +    };
    +
    +    expect(postBody.number).toBe("+10000000000");
    +    expect(postBody.recipients).toEqual(["+15551234567"]);
    +    expect(postBody.message).toBe("hello");
    +
    +    expect(editBody.edit_timestamp).toBe(1001);
    +    expect(editBody.message).toBe("updated");
    +
    +    expect(reactionBody.target_author).toBe("+10000000000");
    +    expect(reactionBody.timestamp).toBe(1001);
    +    expect(reactionBody.reaction).toBe("👍");
    +  });
    +
    +  it("paginates cached messages", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    adapter.parseMessage(
    +      buildUpdate({
    +        dataMessage: {
    +          timestamp: 1,
    +          message: "m1",
    +        },
    +      })
    +    );
    +
    +    adapter.parseMessage(
    +      buildUpdate({
    +        dataMessage: {
    +          timestamp: 2,
    +          message: "m2",
    +        },
    +      })
    +    );
    +
    +    adapter.parseMessage(
    +      buildUpdate({
    +        dataMessage: {
    +          timestamp: 3,
    +          message: "m3",
    +        },
    +      })
    +    );
    +
    +    const backward = await adapter.fetchMessages("signal:+15551234567", {
    +      limit: 2,
    +      direction: "backward",
    +    });
    +
    +    const forward = await adapter.fetchMessages("signal:+15551234567", {
    +      limit: 2,
    +      direction: "forward",
    +    });
    +
    +    expect(backward.messages.map((message) => message.text)).toEqual([
    +      "m2",
    +      "m3",
    +    ]);
    +    expect(forward.messages.map((message) => message.text)).toEqual([
    +      "m1",
    +      "m2",
    +    ]);
    +  });
    +
    +  it("keeps message history in-memory per adapter instance", async () => {
    +    const adapterA = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapterA, createMockChat());
    +
    +    adapterA.parseMessage(
    +      buildUpdate({
    +        dataMessage: {
    +          timestamp: 1,
    +          message: "m1",
    +        },
    +      })
    +    );
    +
    +    const adapterB = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapterB, createMockChat());
    +
    +    const restored = await adapterB.fetchMessages("signal:+15551234567", {
    +      direction: "forward",
    +      limit: 10,
    +    });
    +
    +    expect(restored.messages).toEqual([]);
    +  });
    +
    +  it("fetches group channel metadata", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(
    +      signalOk({
    +        id: "group.c29tZS1ncm91cA==",
    +        name: "General",
    +        members: ["+1", "+2", "+3"],
    +      })
    +    );
    +
    +    const info = await adapter.fetchChannelInfo("group.c29tZS1ncm91cA==");
    +
    +    expect(info.id).toBe("group.c29tZS1ncm91cA==");
    +    expect(info.name).toBe("General");
    +    expect(info.memberCount).toBe(3);
    +    expect(info.isDM).toBe(false);
    +  });
    +
    +  it("maps HTTP errors to adapter-specific error types", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    mockFetch.mockResolvedValueOnce(signalError(401, "Unauthorized"));
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(AuthenticationError);
    +
    +    mockFetch.mockResolvedValueOnce(signalError(429, "Rate limited"));
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(AdapterRateLimitError);
    +
    +    mockFetch.mockResolvedValueOnce(signalError(403, "Forbidden"));
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(PermissionError);
    +
    +    mockFetch.mockResolvedValueOnce(signalError(404, "Not found"));
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(ResourceNotFoundError);
    +
    +    mockFetch.mockResolvedValueOnce(signalError(400, "Bad request"));
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(ValidationError);
    +
    +    mockFetch.mockResolvedValueOnce(signalError(500, "Internal server error"));
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(NetworkError);
    +  });
    +
    +  it("handles incoming messages with attachments", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_500,
    +            message: "Check this out",
    +            attachments: [
    +              {
    +                id: "att-1",
    +                contentType: "image/png",
    +                filename: "photo.png",
    +                size: 1024,
    +                width: 800,
    +                height: 600,
    +              },
    +              {
    +                id: "att-2",
    +                contentType: "application/pdf",
    +                filename: "doc.pdf",
    +                size: 2048,
    +              },
    +            ],
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(1);
    +
    +    const [, , parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { attachments: Array<{ type: string; name?: string; size?: number }> },
    +    ];
    +
    +    expect(parsedMessage.attachments).toHaveLength(2);
    +    expect(parsedMessage.attachments[0].type).toBe("image");
    +    expect(parsedMessage.attachments[0].name).toBe("photo.png");
    +    expect(parsedMessage.attachments[1].type).toBe("file");
    +    expect(parsedMessage.attachments[1].name).toBe("doc.pdf");
    +  });
    +
    +  it("downloads attachment data via signalFetch", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_600,
    +            message: "",
    +            attachments: [{ id: "att-download", contentType: "image/jpeg" }],
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(request);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    const [, , parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { attachments: Array<{ fetchData: () => Promise }> },
    +    ];
    +
    +    mockFetch.mockClear();
    +    const binaryData = Buffer.from("fake-image-data");
    +    mockFetch.mockResolvedValueOnce(new Response(binaryData, { status: 200 }));
    +
    +    const data = await parsedMessage.attachments[0].fetchData();
    +    expect(data).toBeInstanceOf(Buffer);
    +    expect(String(mockFetch.mock.calls[0]?.[0])).toContain(
    +      "/v1/attachments/att-download"
    +    );
    +  });
    +
    +  it("maps audio and video attachment types", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_700,
    +            message: "",
    +            attachments: [
    +              { id: "v1", contentType: "video/mp4" },
    +              { id: "a1", contentType: "audio/ogg" },
    +            ],
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(request);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    const [, , parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { attachments: Array<{ type: string }> },
    +    ];
    +
    +    expect(parsedMessage.attachments[0].type).toBe("video");
    +    expect(parsedMessage.attachments[1].type).toBe("audio");
    +  });
    +
    +  it("ignores webhook payloads when chat is not initialized", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_100,
    +            message: "Hello",
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +  });
    +
    +  it("returns 400 for invalid JSON webhooks", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: "not-json",
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(400);
    +  });
    +
    +  it("handles webhook payloads with no updates gracefully", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify({ unexpected: "payload" }),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).not.toHaveBeenCalled();
    +  });
    +
    +  it("handles array payloads with multiple updates", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify([
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_100,
    +            message: "first",
    +          },
    +        }),
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_200,
    +            message: "second",
    +          },
    +        }),
    +      ]),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    expect(processMessage).toHaveBeenCalledTimes(2);
    +  });
    +
    +  it("processes remote delete messages", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    // First send a message to cache it
    +    adapter.parseMessage(
    +      buildUpdate({
    +        dataMessage: {
    +          timestamp: 1_735_689_600_100,
    +          message: "to-be-deleted",
    +        },
    +      })
    +    );
    +
    +    // Then send remote delete
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_200,
    +            remoteDelete: {
    +              timestamp: 1_735_689_600_100,
    +            },
    +          },
    +        })
    +      ),
    +    });
    +
    +    const response = await adapter.handleWebhook(request);
    +    expect(response.status).toBe(200);
    +
    +    const fetched = await adapter.fetchMessages("signal:+15551234567", {
    +      limit: 10,
    +      direction: "forward",
    +    });
    +
    +    expect(
    +      fetched.messages.find((m) => m.text === "to-be-deleted")
    +    ).toBeUndefined();
    +  });
    +
    +  it("detects bot mention via text pattern when not in mentions array", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +      userName: "mybot",
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    const request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          dataMessage: {
    +            timestamp: 1_735_689_600_100,
    +            message: "Hey @mybot can you help?",
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(request);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    const [, , parsedMessage] = processMessage.mock.calls[0] as [
    +      unknown,
    +      string,
    +      { isMention: boolean },
    +    ];
    +
    +    expect(parsedMessage.isMention).toBe(true);
    +  });
    +
    +  it("identifies DMs vs group chats", () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    expect(adapter.isDM("signal:+15551234567")).toBe(true);
    +    expect(adapter.isDM("signal:group.dGVzdA==")).toBe(false);
    +  });
    +
    +  it("opens DMs for user identifiers", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const threadId = await adapter.openDM("+15551234567");
    +    expect(threadId).toBe("signal:+15551234567");
    +  });
    +
    +  it("rejects openDM for group identifiers", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await expect(adapter.openDM("group.dGVzdA==")).rejects.toBeInstanceOf(
    +      ValidationError
    +    );
    +  });
    +
    +  it("fetches DM channel info without API call", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const info = await adapter.fetchChannelInfo("+15551234567");
    +    expect(info.isDM).toBe(true);
    +    expect(info.name).toBe("+15551234567");
    +  });
    +
    +  it("extracts channelId from threadId", () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    expect(adapter.channelIdFromThreadId("signal:+15551234567")).toBe(
    +      "+15551234567"
    +    );
    +  });
    +
    +  it("fetches thread info for DMs", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const threadInfo = await adapter.fetchThread("signal:+15551234567");
    +    expect(threadInfo.isDM).toBe(true);
    +    expect(threadInfo.channelId).toBe("+15551234567");
    +  });
    +
    +  it("posts messages to groups", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(signalOk({ timestamp: "2001" }, 201));
    +
    +    const posted = await adapter.postChannelMessage(
    +      "group.dGVzdA==",
    +      "hello group"
    +    );
    +
    +    expect(posted.threadId).toBe("signal:group.dGVzdA==");
    +  });
    +
    +  it("rejects empty messages", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    await expect(
    +      adapter.postMessage("signal:+15551234567", "   ")
    +    ).rejects.toBeInstanceOf(ValidationError);
    +  });
    +
    +  it("truncates messages exceeding the limit", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(signalOk({ timestamp: "3001" }, 201));
    +
    +    const longText = "x".repeat(5000);
    +    await adapter.postMessage("signal:+15551234567", longText);
    +
    +    const body = JSON.parse(
    +      String((mockFetch.mock.calls[0]?.[1] as RequestInit).body)
    +    ) as { message: string };
    +
    +    expect(body.message.length).toBeLessThanOrEqual(4096);
    +    expect(body.message.endsWith("...")).toBe(true);
    +  });
    +
    +  it("uses styled text mode for markdown messages", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(signalOk({ timestamp: "4001" }, 201));
    +
    +    await adapter.postMessage("signal:+15551234567", {
    +      markdown: "**bold text**",
    +    });
    +
    +    const body = JSON.parse(
    +      String((mockFetch.mock.calls[0]?.[1] as RequestInit).body)
    +    ) as { text_mode?: string };
    +
    +    expect(body.text_mode).toBe("styled");
    +  });
    +
    +  it("does not set text mode for plain string messages", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(signalOk({ timestamp: "5001" }, 201));
    +
    +    await adapter.postMessage("signal:+15551234567", "plain text");
    +
    +    const body = JSON.parse(
    +      String((mockFetch.mock.calls[0]?.[1] as RequestInit).body)
    +    ) as { text_mode?: string };
    +
    +    expect(body.text_mode).toBeUndefined();
    +  });
    +
    +  it("uses configured text mode override", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      textMode: "normal",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    mockFetch.mockClear();
    +    mockFetch.mockResolvedValueOnce(signalOk({ timestamp: "6001" }, 201));
    +
    +    await adapter.postMessage("signal:+15551234567", {
    +      markdown: "**bold**",
    +    });
    +
    +    const body = JSON.parse(
    +      String((mockFetch.mock.calls[0]?.[1] as RequestInit).body)
    +    ) as { text_mode?: string };
    +
    +    expect(body.text_mode).toBe("normal");
    +  });
    +
    +  it("fetches a single message by ID", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    const parsed = adapter.parseMessage(
    +      buildUpdate({
    +        dataMessage: {
    +          timestamp: 42,
    +          message: "find-me",
    +        },
    +      })
    +    );
    +
    +    const found = await adapter.fetchMessage("signal:+15551234567", parsed.id);
    +    expect(found?.text).toBe("find-me");
    +  });
    +
    +  it("returns null for non-existent message", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await initializeAdapter(adapter, createMockChat());
    +
    +    const found = await adapter.fetchMessage("signal:+15551234567", "+1|99999");
    +    expect(found).toBeNull();
    +  });
    +
    +  it("validates SIGNAL_TEXT_MODE env var", () => {
    +    process.env.SIGNAL_PHONE_NUMBER = "+10000000000";
    +    process.env.SIGNAL_TEXT_MODE = "invalid";
    +
    +    expect(() => createSignalAdapter({ logger: mockLogger })).toThrow(
    +      ValidationError
    +    );
    +
    +    process.env.SIGNAL_TEXT_MODE = "";
    +  });
    +
    +  it("accepts valid SIGNAL_TEXT_MODE env var values", () => {
    +    process.env.SIGNAL_PHONE_NUMBER = "+10000000000";
    +    process.env.SIGNAL_TEXT_MODE = "styled";
    +
    +    const adapter = createSignalAdapter({ logger: mockLogger });
    +    expect(adapter).toBeInstanceOf(SignalAdapter);
    +
    +    process.env.SIGNAL_TEXT_MODE = "";
    +  });
    +
    +  it("handles network errors during fetch", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    mockFetch.mockRejectedValueOnce(new Error("Connection refused"));
    +
    +    await expect(
    +      adapter.startTyping("signal:+15551234567")
    +    ).rejects.toBeInstanceOf(NetworkError);
    +  });
    +
    +  it("identity aliases stabilize message IDs across UUID-to-phone transitions", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    // First message with UUID only
    +    const msg1Request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          source: "uuid-user-1",
    +          sourceNumber: null,
    +          sourceUuid: "uuid-user-1",
    +          dataMessage: {
    +            timestamp: 1_000_000,
    +            message: "first",
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(msg1Request);
    +
    +    // Second message reveals phone number
    +    const msg2Request = new Request("https://example.com/webhook", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body: JSON.stringify(
    +        buildUpdate({
    +          source: "uuid-user-1",
    +          sourceNumber: "+15559999999",
    +          sourceUuid: "uuid-user-1",
    +          dataMessage: {
    +            timestamp: 2_000_000,
    +            message: "second",
    +          },
    +        })
    +      ),
    +    });
    +
    +    await adapter.handleWebhook(msg2Request);
    +
    +    const processMessage = chat.processMessage as ReturnType;
    +    const [, _threadId1] = processMessage.mock.calls[0] as [unknown, string];
    +    const [, threadId2] = processMessage.mock.calls[1] as [unknown, string];
    +
    +    // Both messages should route to the same thread once the phone number is known
    +    expect(threadId2).toBe("signal:+15559999999");
    +  });
    +
    +  it("handles stopPolling when polling is not active", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    await expect(adapter.stopPolling()).resolves.toBeUndefined();
    +  });
    +
    +  it("ignores duplicate startPolling calls", async () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const chat = createMockChat();
    +    await initializeAdapter(adapter, chat);
    +
    +    mockFetch.mockClear();
    +    // Make the first poll hang
    +    mockFetch.mockImplementation(
    +      () =>
    +        new Promise((resolve) => setTimeout(() => resolve(signalOk([])), 50))
    +    );
    +
    +    adapter.startPolling({ intervalMs: 10_000 });
    +    adapter.startPolling({ intervalMs: 10_000 });
    +
    +    // Let the first poll complete
    +    await new Promise((resolve) => setTimeout(resolve, 100));
    +    await adapter.stopPolling();
    +  });
    +
    +  it("renderFormatted converts AST to string", () => {
    +    const adapter = createSignalAdapter({
    +      phoneNumber: "+10000000000",
    +      logger: mockLogger,
    +    });
    +
    +    const result = adapter.renderFormatted({
    +      type: "root",
    +      children: [
    +        {
    +          type: "paragraph",
    +          children: [{ type: "text", value: "hello world" }],
    +        },
    +      ],
    +    });
    +
    +    expect(result).toContain("hello world");
    +  });
    +});
    diff --git a/packages/adapter-signal/src/index.ts b/packages/adapter-signal/src/index.ts
    new file mode 100644
    index 00000000..48f91e78
    --- /dev/null
    +++ b/packages/adapter-signal/src/index.ts
    @@ -0,0 +1,2011 @@
    +import {
    +  AdapterRateLimitError,
    +  AuthenticationError,
    +  cardToFallbackText,
    +  extractCard,
    +  extractFiles,
    +  NetworkError,
    +  PermissionError,
    +  ResourceNotFoundError,
    +  ValidationError,
    +} from "@chat-adapter/shared";
    +import type {
    +  Adapter,
    +  AdapterPostableMessage,
    +  Attachment,
    +  ChannelInfo,
    +  ChatInstance,
    +  EmojiValue,
    +  FetchOptions,
    +  FetchResult,
    +  FormattedContent,
    +  Logger,
    +  RawMessage,
    +  ThreadInfo,
    +  WebhookOptions,
    +} from "chat";
    +import {
    +  ConsoleLogger,
    +  convertEmojiPlaceholders,
    +  defaultEmojiResolver,
    +  Message,
    +} from "chat";
    +import { SignalMessageCache } from "./cache";
    +import { SignalIdentityMap } from "./identity";
    +import { SignalFormatConverter } from "./markdown";
    +import type {
    +  SignalAdapterConfig,
    +  SignalApiErrorResponse,
    +  SignalDataMessage,
    +  SignalEnvelope,
    +  SignalGroup,
    +  SignalJsonRpcReceivePayload,
    +  SignalOutgoingRawMessage,
    +  SignalRawMessage,
    +  SignalReaction,
    +  SignalReactionRequest,
    +  SignalRemoteDeleteRequest,
    +  SignalSendMessageRequest,
    +  SignalSendMessageResponse,
    +  SignalSyncSentMessage,
    +  SignalTextMode,
    +  SignalThreadId,
    +  SignalTypingIndicatorRequest,
    +  SignalUpdate,
    +} from "./types";
    +
    +const SIGNAL_ADAPTER_NAME = "signal";
    +const DEFAULT_SIGNAL_API_BASE_URL = "http://localhost:8080";
    +const DEFAULT_SIGNAL_WEBHOOK_SECRET_HEADER = "x-signal-webhook-secret";
    +const SIGNAL_THREAD_PREFIX = "signal:";
    +const SIGNAL_GROUP_PREFIX = "group.";
    +const DEFAULT_POLLING_INTERVAL_MS = 1000;
    +const MIN_POLLING_INTERVAL_MS = 100;
    +const TRAILING_SLASHES_REGEX = /\/+$/;
    +const MESSAGE_ID_PATTERN = /^(.*)\|(\d+)$/;
    +const LEADING_AT_PATTERN = /^@+/;
    +const EMOJI_PLACEHOLDER_PATTERN = /^\{\{emoji:([a-z0-9_]+)\}\}$/i;
    +const EMOJI_NAME_PATTERN = /^[a-z0-9_+-]+$/i;
    +const SIGNAL_MESSAGE_LIMIT = 4096;
    +const BASE64_OR_BASE64URL_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/;
    +const TRAILING_BASE64_PADDING_PATTERN = /=+$/;
    +
    +interface SignalMessageAuthor {
    +  fullName: string;
    +  isBot: boolean | "unknown";
    +  isMe: boolean;
    +  userId: string;
    +  userName: string;
    +}
    +
    +interface SignalParsedMessageOptions {
    +  edited?: boolean;
    +  editedAtTimestamp?: number;
    +  messageIdTimestamp?: number;
    +}
    +
    +interface SignalPollingOptions {
    +  intervalMs?: number;
    +  timeoutSeconds?: number;
    +  webhookOptions?: WebhookOptions;
    +}
    +
    +export class SignalAdapter
    +  implements Adapter
    +{
    +  readonly name = SIGNAL_ADAPTER_NAME;
    +
    +  private readonly botPhoneNumber: string;
    +  private readonly apiBaseUrl: string;
    +  private readonly webhookSecret?: string;
    +  private readonly webhookSecretHeader: string;
    +  private readonly configuredTextMode?: SignalTextMode;
    +  private readonly logger: Logger;
    +  private readonly formatConverter = new SignalFormatConverter();
    +  private readonly messageCache = new SignalMessageCache();
    +  private readonly identityMap = new SignalIdentityMap();
    +
    +  private chat: ChatInstance | null = null;
    +  private pollingTask: Promise | null = null;
    +  private pollingAbortController: AbortController | null = null;
    +  private _userName: string;
    +  private readonly hasExplicitUserName: boolean;
    +
    +  get botUserId(): string {
    +    return this.botPhoneNumber;
    +  }
    +
    +  get userName(): string {
    +    return this._userName;
    +  }
    +
    +  constructor(
    +    config: SignalAdapterConfig & {
    +      logger: Logger;
    +      userName?: string;
    +    }
    +  ) {
    +    this.botPhoneNumber = this.normalizeSignalIdentifier(config.phoneNumber);
    +    this.apiBaseUrl = this.normalizeBaseUrl(
    +      config.baseUrl ?? DEFAULT_SIGNAL_API_BASE_URL
    +    );
    +    this.webhookSecret = config.webhookSecret;
    +    this.webhookSecretHeader =
    +      config.webhookSecretHeader?.toLowerCase() ??
    +      DEFAULT_SIGNAL_WEBHOOK_SECRET_HEADER;
    +    this.configuredTextMode = config.textMode;
    +    this.logger = config.logger;
    +    this._userName = this.normalizeUserName(config.userName ?? "bot");
    +    this.hasExplicitUserName = Boolean(config.userName);
    +  }
    +
    +  async initialize(chat: ChatInstance): Promise {
    +    this.chat = chat;
    +
    +    if (!this.hasExplicitUserName) {
    +      this._userName = this.normalizeUserName(chat.getUserName());
    +    }
    +
    +    await this.assertSignalServiceHealth();
    +
    +    this.logger.info("Signal adapter initialized", {
    +      botPhoneNumber: this.botPhoneNumber,
    +      apiBaseUrl: this.apiBaseUrl,
    +      userName: this._userName,
    +    });
    +  }
    +
    +  async handleWebhook(
    +    request: Request,
    +    options?: WebhookOptions
    +  ): Promise {
    +    if (this.webhookSecret) {
    +      const headerValue = request.headers.get(this.webhookSecretHeader);
    +      if (headerValue !== this.webhookSecret) {
    +        this.logger.warn("Signal webhook rejected due to invalid secret token");
    +        return new Response("Invalid webhook secret", { status: 401 });
    +      }
    +    }
    +
    +    let payload: unknown;
    +    try {
    +      payload = await request.json();
    +    } catch {
    +      return new Response("Invalid JSON", { status: 400 });
    +    }
    +
    +    if (!this.chat) {
    +      this.logger.warn(
    +        "Chat instance not initialized, ignoring Signal webhook"
    +      );
    +      return new Response("OK", { status: 200 });
    +    }
    +
    +    const updates = this.extractUpdatesFromPayload(payload);
    +    if (updates.length === 0) {
    +      this.logger.debug("Signal webhook payload contained no receive updates");
    +      return new Response("OK", { status: 200 });
    +    }
    +
    +    for (const update of updates) {
    +      this.handleIncomingUpdate(update, options);
    +    }
    +
    +    return new Response("OK", { status: 200 });
    +  }
    +
    +  startPolling(options: SignalPollingOptions = {}): void {
    +    if (this.pollingTask) {
    +      this.logger.debug("Signal polling already running");
    +      return;
    +    }
    +
    +    const intervalMs = Math.max(
    +      MIN_POLLING_INTERVAL_MS,
    +      this.normalizePositiveInteger(
    +        options.intervalMs,
    +        DEFAULT_POLLING_INTERVAL_MS
    +      )
    +    );
    +
    +    const timeoutSeconds =
    +      typeof options.timeoutSeconds === "number"
    +        ? Math.max(0, Math.trunc(options.timeoutSeconds))
    +        : undefined;
    +
    +    const abortController = new AbortController();
    +    this.pollingAbortController = abortController;
    +
    +    this.pollingTask = this.runPollingLoop(
    +      {
    +        intervalMs,
    +        timeoutSeconds,
    +        webhookOptions: options.webhookOptions,
    +      },
    +      abortController.signal
    +    )
    +      .catch((error) => {
    +        if (!abortController.signal.aborted) {
    +          this.logger.error("Signal polling loop failed", {
    +            error: String(error),
    +          });
    +        }
    +      })
    +      .finally(() => {
    +        if (this.pollingAbortController === abortController) {
    +          this.pollingAbortController = null;
    +        }
    +        this.pollingTask = null;
    +      });
    +  }
    +
    +  async stopPolling(): Promise {
    +    this.pollingAbortController?.abort();
    +    await this.pollingTask;
    +  }
    +
    +  async pollOnce(options: SignalPollingOptions = {}): Promise {
    +    const timeoutQuery =
    +      typeof options.timeoutSeconds === "number"
    +        ? `?timeout=${Math.max(0, Math.trunc(options.timeoutSeconds))}`
    +        : "";
    +
    +    const payload = await this.signalFetch(
    +      `/v1/receive/${encodeURIComponent(this.botPhoneNumber)}${timeoutQuery}`,
    +      {
    +        method: "GET",
    +      },
    +      "receive"
    +    );
    +
    +    const updates = this.extractUpdatesFromPayload(payload);
    +    if (updates.length === 0) {
    +      return 0;
    +    }
    +
    +    for (const update of updates) {
    +      this.handleIncomingUpdate(update, options.webhookOptions);
    +    }
    +
    +    return updates.length;
    +  }
    +
    +  async postMessage(
    +    threadId: string,
    +    message: AdapterPostableMessage
    +  ): Promise> {
    +    const parsedThread = this.resolveThreadId(threadId);
    +
    +    const card = extractCard(message);
    +    const renderedText = this.truncateMessage(
    +      this.renderOutgoingText(message, card)
    +    );
    +    const files = extractFiles(message);
    +
    +    if (!(renderedText.trim() || files.length > 0)) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal message cannot be empty"
    +      );
    +    }
    +
    +    const payload: SignalSendMessageRequest = {
    +      number: this.botPhoneNumber,
    +      recipients: [parsedThread.chatId],
    +      message: renderedText,
    +    };
    +
    +    if (files.length > 0) {
    +      payload.base64_attachments = await this.toSignalAttachmentPayload(files);
    +    }
    +
    +    const textMode = this.resolveOutgoingTextMode(message, card !== null);
    +    if (textMode) {
    +      payload.text_mode = textMode;
    +    }
    +
    +    const response = await this.signalFetch(
    +      "/v2/send",
    +      {
    +        method: "POST",
    +        body: payload,
    +      },
    +      "sendMessage"
    +    );
    +
    +    const sentTimestamp = this.parseSignalTimestamp(response.timestamp, "send");
    +    const resultingThreadId = this.encodeThreadId(parsedThread);
    +
    +    const outgoingMessage = this.createOutgoingMessage({
    +      chatId: parsedThread.chatId,
    +      edited: false,
    +      text: renderedText,
    +      threadId: resultingThreadId,
    +      timestamp: sentTimestamp,
    +    });
    +
    +    this.messageCache.cache(outgoingMessage);
    +
    +    return {
    +      id: outgoingMessage.id,
    +      threadId: outgoingMessage.threadId,
    +      raw: outgoingMessage.raw,
    +    };
    +  }
    +
    +  async postChannelMessage(
    +    channelId: string,
    +    message: AdapterPostableMessage
    +  ): Promise> {
    +    const threadId = this.encodeThreadId({ chatId: channelId });
    +    return this.postMessage(threadId, message);
    +  }
    +
    +  async editMessage(
    +    threadId: string,
    +    messageId: string,
    +    message: AdapterPostableMessage
    +  ): Promise> {
    +    const parsedThread = this.resolveThreadId(threadId);
    +    const resultingThreadId = this.encodeThreadId(parsedThread);
    +    const decodedMessageId = this.decodeMessageId(messageId);
    +
    +    const card = extractCard(message);
    +    const renderedText = this.truncateMessage(
    +      this.renderOutgoingText(message, card)
    +    );
    +    const files = extractFiles(message);
    +
    +    if (!(renderedText.trim() || files.length > 0)) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal message cannot be empty"
    +      );
    +    }
    +
    +    const payload: SignalSendMessageRequest = {
    +      number: this.botPhoneNumber,
    +      recipients: [parsedThread.chatId],
    +      message: renderedText,
    +      edit_timestamp: decodedMessageId.timestamp,
    +    };
    +
    +    if (files.length > 0) {
    +      payload.base64_attachments = await this.toSignalAttachmentPayload(files);
    +    }
    +
    +    const textMode = this.resolveOutgoingTextMode(message, card !== null);
    +    if (textMode) {
    +      payload.text_mode = textMode;
    +    }
    +
    +    const response = await this.signalFetch(
    +      "/v2/send",
    +      {
    +        method: "POST",
    +        body: payload,
    +      },
    +      "editMessage"
    +    );
    +
    +    const editedAtTimestamp = this.parseSignalTimestamp(
    +      response.timestamp,
    +      "edit"
    +    );
    +
    +    const existing =
    +      this.messageCache.findById(resultingThreadId, messageId) ??
    +      this.messageCache.findByTimestamp(
    +        resultingThreadId,
    +        decodedMessageId.timestamp
    +      );
    +
    +    const updatedMessage = existing
    +      ? new Message({
    +          ...existing,
    +          text: renderedText,
    +          formatted: this.formatConverter.toAst(renderedText),
    +          metadata: {
    +            ...existing.metadata,
    +            edited: true,
    +            editedAt: this.signalTimestampToDate(editedAtTimestamp),
    +          },
    +        })
    +      : this.createOutgoingMessage({
    +          author: decodedMessageId.author,
    +          chatId: parsedThread.chatId,
    +          edited: true,
    +          editedAtTimestamp,
    +          text: renderedText,
    +          threadId: resultingThreadId,
    +          timestamp: decodedMessageId.timestamp,
    +        });
    +
    +    this.messageCache.cache(updatedMessage);
    +
    +    return {
    +      id: updatedMessage.id,
    +      threadId: updatedMessage.threadId,
    +      raw: updatedMessage.raw,
    +    };
    +  }
    +
    +  async deleteMessage(threadId: string, messageId: string): Promise {
    +    const parsedThread = this.resolveThreadId(threadId);
    +    const decodedMessageId = this.decodeMessageId(messageId);
    +
    +    const payload: SignalRemoteDeleteRequest = {
    +      recipient: parsedThread.chatId,
    +      timestamp: decodedMessageId.timestamp,
    +    };
    +
    +    await this.signalFetch(
    +      `/v1/remote-delete/${encodeURIComponent(this.botPhoneNumber)}`,
    +      {
    +        method: "DELETE",
    +        body: payload,
    +      },
    +      "deleteMessage"
    +    );
    +
    +    this.messageCache.deleteById(messageId);
    +    this.messageCache.deleteByTimestamp(
    +      this.encodeThreadId(parsedThread),
    +      decodedMessageId.timestamp
    +    );
    +  }
    +
    +  async addReaction(
    +    threadId: string,
    +    messageId: string,
    +    emoji: EmojiValue | string
    +  ): Promise {
    +    const parsedThread = this.resolveThreadId(threadId);
    +    const decodedMessageId = this.decodeMessageIdForReaction(
    +      this.encodeThreadId(parsedThread),
    +      messageId
    +    );
    +
    +    const payload: SignalReactionRequest = {
    +      recipient: parsedThread.chatId,
    +      reaction: this.toSignalReactionEmoji(emoji),
    +      target_author: decodedMessageId.author,
    +      timestamp: decodedMessageId.timestamp,
    +    };
    +
    +    await this.signalFetch(
    +      `/v1/reactions/${encodeURIComponent(this.botPhoneNumber)}`,
    +      {
    +        method: "POST",
    +        body: payload,
    +      },
    +      "addReaction"
    +    );
    +  }
    +
    +  async removeReaction(
    +    threadId: string,
    +    messageId: string,
    +    emoji: EmojiValue | string
    +  ): Promise {
    +    const parsedThread = this.resolveThreadId(threadId);
    +    const decodedMessageId = this.decodeMessageIdForReaction(
    +      this.encodeThreadId(parsedThread),
    +      messageId
    +    );
    +
    +    const payload: SignalReactionRequest = {
    +      recipient: parsedThread.chatId,
    +      reaction: this.toSignalReactionEmoji(emoji),
    +      target_author: decodedMessageId.author,
    +      timestamp: decodedMessageId.timestamp,
    +    };
    +
    +    await this.signalFetch(
    +      `/v1/reactions/${encodeURIComponent(this.botPhoneNumber)}`,
    +      {
    +        method: "DELETE",
    +        body: payload,
    +      },
    +      "removeReaction"
    +    );
    +  }
    +
    +  async startTyping(threadId: string): Promise {
    +    const parsedThread = this.resolveThreadId(threadId);
    +
    +    const payload: SignalTypingIndicatorRequest = {
    +      recipient: parsedThread.chatId,
    +    };
    +
    +    await this.signalFetch(
    +      `/v1/typing-indicator/${encodeURIComponent(this.botPhoneNumber)}`,
    +      {
    +        method: "PUT",
    +        body: payload,
    +      },
    +      "startTyping"
    +    );
    +  }
    +
    +  async fetchMessages(
    +    threadId: string,
    +    options: FetchOptions = {}
    +  ): Promise> {
    +    const resolvedThreadId = this.encodeThreadId(
    +      this.resolveThreadId(threadId)
    +    );
    +
    +    const messages = this.messageCache.getThread(resolvedThreadId);
    +    return this.messageCache.paginate(messages, options);
    +  }
    +
    +  async fetchChannelMessages(
    +    channelId: string,
    +    options: FetchOptions = {}
    +  ): Promise> {
    +    const threadId = this.encodeThreadId({ chatId: channelId });
    +    return this.fetchMessages(threadId, options);
    +  }
    +
    +  async fetchMessage(
    +    threadId: string,
    +    messageId: string
    +  ): Promise | null> {
    +    const normalizedThreadId = this.encodeThreadId(
    +      this.resolveThreadId(threadId)
    +    );
    +
    +    return (
    +      this.messageCache.findById(normalizedThreadId, messageId) ??
    +      this.messageCache.findByTimestamp(
    +        normalizedThreadId,
    +        this.decodeMessageId(messageId).timestamp
    +      ) ??
    +      null
    +    );
    +  }
    +
    +  async fetchThread(threadId: string): Promise {
    +    const parsedThread = this.resolveThreadId(threadId);
    +    const isGroup = this.isGroupChatId(parsedThread.chatId);
    +
    +    let channelName: string | undefined;
    +    let metadata: Record = {};
    +
    +    if (isGroup) {
    +      const group = await this.fetchGroup(parsedThread.chatId).catch(
    +        () => null
    +      );
    +      if (group) {
    +        channelName = group.name;
    +        metadata = { group };
    +      }
    +    }
    +
    +    return {
    +      id: this.encodeThreadId(parsedThread),
    +      channelId: parsedThread.chatId,
    +      channelName,
    +      isDM: !isGroup,
    +      metadata,
    +    };
    +  }
    +
    +  async fetchChannelInfo(channelId: string): Promise {
    +    const isGroup = this.isGroupChatId(channelId);
    +
    +    if (!isGroup) {
    +      return {
    +        id: channelId,
    +        isDM: true,
    +        name: channelId,
    +        metadata: {},
    +      };
    +    }
    +
    +    const group = await this.fetchGroup(channelId);
    +
    +    const memberCount = Array.isArray(group.members)
    +      ? group.members.length
    +      : undefined;
    +
    +    return {
    +      id: channelId,
    +      isDM: false,
    +      name: group.name,
    +      memberCount,
    +      metadata: { group },
    +    };
    +  }
    +
    +  channelIdFromThreadId(threadId: string): string {
    +    return this.resolveThreadId(threadId).chatId;
    +  }
    +
    +  async openDM(userId: string): Promise {
    +    const normalizedUserId = this.fromSignalUserId(userId);
    +
    +    if (this.isGroupChatId(normalizedUserId)) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "openDM expects a user identifier, not a group identifier"
    +      );
    +    }
    +
    +    return this.encodeThreadId({ chatId: normalizedUserId });
    +  }
    +
    +  isDM(threadId: string): boolean {
    +    return !this.isGroupChatId(this.resolveThreadId(threadId).chatId);
    +  }
    +
    +  encodeThreadId(platformData: SignalThreadId): string {
    +    if (!platformData.chatId) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal thread chatId cannot be empty"
    +      );
    +    }
    +
    +    const normalizedChatId = this.normalizeSignalIdentifier(
    +      platformData.chatId
    +    );
    +    const chatId = this.isGroupChatId(normalizedChatId)
    +      ? this.normalizeGroupId(normalizedChatId)
    +      : this.identityMap.canonicalize(normalizedChatId);
    +
    +    return `${SIGNAL_THREAD_PREFIX}${chatId}`;
    +  }
    +
    +  decodeThreadId(threadId: string): SignalThreadId {
    +    if (!threadId.startsWith(SIGNAL_THREAD_PREFIX)) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Invalid Signal thread ID: ${threadId}`
    +      );
    +    }
    +
    +    const chatId = threadId.slice(SIGNAL_THREAD_PREFIX.length);
    +    if (!chatId) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Invalid Signal thread ID: ${threadId}`
    +      );
    +    }
    +
    +    const normalizedChatId = this.normalizeSignalIdentifier(chatId);
    +
    +    return {
    +      chatId: this.isGroupChatId(normalizedChatId)
    +        ? this.normalizeGroupId(normalizedChatId)
    +        : this.identityMap.canonicalize(normalizedChatId),
    +    };
    +  }
    +
    +  parseMessage(raw: SignalRawMessage): Message {
    +    const message = this.messageFromRaw(raw);
    +    this.messageCache.cache(message);
    +    return message;
    +  }
    +
    +  renderFormatted(content: FormattedContent): string {
    +    return this.formatConverter.fromAst(content);
    +  }
    +
    +  private messageFromRaw(
    +    raw: SignalRawMessage,
    +    options: { skipSyncMessages?: boolean } = {}
    +  ): Message {
    +    if (this.isOutgoingRawMessage(raw)) {
    +      return this.createOutgoingMessage({
    +        author: raw.author,
    +        chatId: raw.recipient,
    +        edited: raw.edited ?? false,
    +        text: raw.text,
    +        threadId: this.encodeThreadId({ chatId: raw.recipient }),
    +        timestamp: raw.timestamp,
    +      });
    +    }
    +
    +    const update = this.unwrapToSignalUpdate(raw);
    +    if (!update) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Cannot parse Signal raw message payload"
    +      );
    +    }
    +
    +    const dataMessage = update.envelope.dataMessage;
    +    if (dataMessage && !dataMessage.reaction && !dataMessage.remoteDelete) {
    +      const threadId = this.threadIdFromEnvelope(update.envelope, dataMessage);
    +      return this.createMessageFromDataMessage(update, dataMessage, threadId, {
    +        edited: false,
    +      });
    +    }
    +
    +    const editMessage = update.envelope.editMessage;
    +    if (editMessage?.dataMessage) {
    +      const threadId = this.threadIdFromEnvelope(
    +        update.envelope,
    +        editMessage.dataMessage
    +      );
    +      return this.createMessageFromDataMessage(
    +        update,
    +        editMessage.dataMessage,
    +        threadId,
    +        {
    +          edited: true,
    +          messageIdTimestamp: editMessage.targetSentTimestamp,
    +          editedAtTimestamp:
    +            editMessage.dataMessage.timestamp ?? update.envelope.timestamp,
    +        }
    +      );
    +    }
    +
    +    const syncSentMessage = update.envelope.syncMessage?.sentMessage;
    +    if (syncSentMessage && !options.skipSyncMessages) {
    +      return this.createMessageFromSyncSentMessage(update, syncSentMessage);
    +    }
    +
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      "Signal raw message does not contain a supported message payload"
    +    );
    +  }
    +
    +  private handleIncomingUpdate(
    +    update: SignalUpdate,
    +    options?: WebhookOptions
    +  ): void {
    +    const dataMessage = update.envelope.dataMessage;
    +    if (dataMessage?.reaction) {
    +      this.handleIncomingReaction(
    +        update,
    +        dataMessage.reaction,
    +        dataMessage,
    +        options
    +      );
    +      return;
    +    }
    +
    +    if (dataMessage?.remoteDelete) {
    +      this.handleIncomingRemoteDelete(update, dataMessage);
    +      return;
    +    }
    +
    +    if (dataMessage && dataMessage.groupInfo?.type !== "UPDATE") {
    +      this.handleIncomingDataMessage(update, dataMessage, options);
    +    }
    +
    +    const editMessage = update.envelope.editMessage;
    +    if (editMessage?.dataMessage) {
    +      this.handleIncomingEditMessage(update, editMessage, options);
    +    }
    +
    +    const syncSentMessage = update.envelope.syncMessage?.sentMessage;
    +    if (syncSentMessage) {
    +      this.handleIncomingSyncSentMessage(update, syncSentMessage, options);
    +    }
    +  }
    +
    +  private handleIncomingDataMessage(
    +    update: SignalUpdate,
    +    dataMessage: SignalDataMessage,
    +    options?: WebhookOptions
    +  ): void {
    +    if (!this.chat) {
    +      return;
    +    }
    +
    +    const threadId = this.threadIdFromEnvelope(update.envelope, dataMessage);
    +    const message = this.createMessageFromDataMessage(
    +      update,
    +      dataMessage,
    +      threadId,
    +      {
    +        edited: false,
    +      }
    +    );
    +
    +    this.messageCache.cache(message);
    +    this.chat.processMessage(this, threadId, message, options);
    +  }
    +
    +  private handleIncomingEditMessage(
    +    update: SignalUpdate,
    +    editMessage: NonNullable,
    +    options?: WebhookOptions
    +  ): void {
    +    if (!this.chat) {
    +      return;
    +    }
    +
    +    const threadId = this.threadIdFromEnvelope(
    +      update.envelope,
    +      editMessage.dataMessage
    +    );
    +
    +    const message = this.createMessageFromDataMessage(
    +      update,
    +      editMessage.dataMessage,
    +      threadId,
    +      {
    +        edited: true,
    +        messageIdTimestamp: editMessage.targetSentTimestamp,
    +        editedAtTimestamp:
    +          editMessage.dataMessage.timestamp ?? update.envelope.timestamp,
    +      }
    +    );
    +
    +    this.messageCache.cache(message);
    +    this.chat.processMessage(this, threadId, message, options);
    +  }
    +
    +  private handleIncomingRemoteDelete(
    +    update: SignalUpdate,
    +    dataMessage: SignalDataMessage
    +  ): void {
    +    const remoteDelete = dataMessage.remoteDelete;
    +    if (!remoteDelete) {
    +      return;
    +    }
    +
    +    const threadId = this.threadIdFromEnvelope(update.envelope, dataMessage);
    +    this.messageCache.deleteByTimestamp(threadId, remoteDelete.timestamp);
    +  }
    +
    +  private handleIncomingSyncSentMessage(
    +    update: SignalUpdate,
    +    sentMessage: SignalSyncSentMessage,
    +    options?: WebhookOptions
    +  ): void {
    +    const message = this.createMessageFromSyncSentMessage(update, sentMessage);
    +    this.messageCache.cache(message);
    +
    +    if (!this.chat) {
    +      return;
    +    }
    +
    +    this.chat.processMessage(this, message.threadId, message, options);
    +  }
    +
    +  private handleIncomingReaction(
    +    update: SignalUpdate,
    +    reaction: SignalReaction,
    +    dataMessage: SignalDataMessage,
    +    options?: WebhookOptions
    +  ): void {
    +    if (!this.chat) {
    +      return;
    +    }
    +
    +    const threadId = this.threadIdFromEnvelope(update.envelope, dataMessage);
    +    const targetAuthor = this.resolveReactionTargetAuthor(reaction);
    +    const cachedTargetMessage = this.messageCache.findByTimestamp(
    +      threadId,
    +      reaction.targetSentTimestamp
    +    );
    +
    +    const messageId =
    +      cachedTargetMessage?.id ??
    +      (targetAuthor
    +        ? this.encodeMessageId(targetAuthor, reaction.targetSentTimestamp)
    +        : undefined);
    +
    +    if (!messageId) {
    +      this.logger.warn(
    +        "Skipping Signal reaction event with missing targetAuthor",
    +        {
    +          reaction,
    +        }
    +      );
    +      return;
    +    }
    +
    +    this.chat.processReaction(
    +      {
    +        adapter: this,
    +        threadId,
    +        messageId,
    +        emoji: defaultEmojiResolver.fromGChat(reaction.emoji),
    +        rawEmoji: reaction.emoji,
    +        added: !reaction.isRemove,
    +        user: this.toAuthor(update.envelope),
    +        raw: update,
    +      },
    +      options
    +    );
    +  }
    +
    +  private unwrapToSignalUpdate(payload: unknown): SignalUpdate | null {
    +    if (this.isSignalUpdate(payload)) {
    +      return payload;
    +    }
    +
    +    if (this.isSignalJsonRpcReceivePayload(payload)) {
    +      return payload.params ?? null;
    +    }
    +
    +    return null;
    +  }
    +
    +  private extractUpdatesFromPayload(payload: unknown): SignalUpdate[] {
    +    if (Array.isArray(payload)) {
    +      return payload
    +        .map((entry) => this.unwrapToSignalUpdate(entry))
    +        .filter((entry): entry is SignalUpdate => entry !== null);
    +    }
    +
    +    const single = this.unwrapToSignalUpdate(payload);
    +    return single ? [single] : [];
    +  }
    +
    +  private isSignalUpdate(payload: unknown): payload is SignalUpdate {
    +    if (!(payload && typeof payload === "object")) {
    +      return false;
    +    }
    +
    +    return "envelope" in payload;
    +  }
    +
    +  private isSignalJsonRpcReceivePayload(
    +    payload: unknown
    +  ): payload is SignalJsonRpcReceivePayload {
    +    if (!(payload && typeof payload === "object")) {
    +      return false;
    +    }
    +
    +    const record = payload as Record;
    +    return (
    +      record.method === "receive" &&
    +      Boolean(record.params) &&
    +      typeof record.params === "object"
    +    );
    +  }
    +
    +  private threadIdFromEnvelope(
    +    envelope: SignalEnvelope,
    +    dataMessage?: SignalDataMessage
    +  ): string {
    +    const chatId = this.chatIdFromEnvelope(envelope, dataMessage);
    +    return this.encodeThreadId({ chatId });
    +  }
    +
    +  private chatIdFromEnvelope(
    +    envelope: SignalEnvelope,
    +    dataMessage?: SignalDataMessage
    +  ): string {
    +    const groupId = dataMessage?.groupInfo?.groupId;
    +    if (groupId) {
    +      return this.normalizeIncomingGroupId(groupId);
    +    }
    +
    +    const sourceId = this.resolveEnvelopeSourceIdentifier(envelope);
    +    if (sourceId) {
    +      return sourceId;
    +    }
    +
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      "Could not determine Signal chat ID from incoming update"
    +    );
    +  }
    +
    +  private createMessageFromDataMessage(
    +    update: SignalUpdate,
    +    dataMessage: SignalDataMessage,
    +    threadId: string,
    +    options: SignalParsedMessageOptions
    +  ): Message {
    +    const authorIdentifier =
    +      this.resolveEnvelopeSourceIdentifier(update.envelope) ??
    +      this.botPhoneNumber;
    +    const messageIdTimestamp =
    +      options.messageIdTimestamp ??
    +      dataMessage.timestamp ??
    +      update.envelope.timestamp;
    +
    +    if (!messageIdTimestamp) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal message did not include a timestamp"
    +      );
    +    }
    +
    +    const existingMessage = options.edited
    +      ? (this.messageCache.findByTimestamp(threadId, messageIdTimestamp) ??
    +        this.messageCache.findByTimestampAcrossThreads(messageIdTimestamp))
    +      : undefined;
    +
    +    const existingMessageAuthor = existingMessage
    +      ? this.decodeMessageIdRaw(existingMessage.id).author
    +      : undefined;
    +
    +    const messageId = existingMessageAuthor
    +      ? this.encodeMessageIdRaw(existingMessageAuthor, messageIdTimestamp)
    +      : this.encodeMessageId(authorIdentifier, messageIdTimestamp);
    +
    +    const text = dataMessage.message ?? "";
    +
    +    const message = new Message({
    +      id: messageId,
    +      threadId,
    +      text,
    +      formatted: this.formatConverter.toAst(text),
    +      raw: {
    +        account: update.account,
    +        envelope: update.envelope,
    +      },
    +      author: this.toAuthor(update.envelope),
    +      metadata: {
    +        dateSent: this.signalTimestampToDate(
    +          dataMessage.timestamp ??
    +            update.envelope.timestamp ??
    +            messageIdTimestamp
    +        ),
    +        edited: Boolean(options.edited),
    +        editedAt: options.edited
    +          ? this.signalTimestampToDate(
    +              options.editedAtTimestamp ??
    +                dataMessage.timestamp ??
    +                update.envelope.timestamp ??
    +                messageIdTimestamp
    +            )
    +          : undefined,
    +      },
    +      attachments: this.extractIncomingAttachments(dataMessage),
    +      isMention: this.isBotMentioned(dataMessage, text),
    +    });
    +
    +    return message;
    +  }
    +
    +  private createMessageFromSyncSentMessage(
    +    update: SignalUpdate,
    +    sentMessage: SignalSyncSentMessage
    +  ): Message {
    +    const timestamp = sentMessage.timestamp ?? update.envelope.timestamp;
    +    if (!timestamp) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal sync message did not include a timestamp"
    +      );
    +    }
    +
    +    const chatId = this.chatIdFromSyncSentMessage(update, sentMessage);
    +    const threadId = this.encodeThreadId({ chatId });
    +    const authorIdentifier =
    +      this.resolveEnvelopeSourceIdentifier(update.envelope) ??
    +      this.normalizeSignalIdentifier(this.botPhoneNumber);
    +    const isLinkedDeviceSync = this.isLinkedDeviceSyncMessage(update.envelope);
    +    const authorName = update.envelope.sourceName ?? this._userName;
    +    const text = sentMessage.message ?? "";
    +
    +    return new Message({
    +      id: this.encodeMessageId(authorIdentifier, timestamp),
    +      threadId,
    +      text,
    +      formatted: this.formatConverter.toAst(text),
    +      raw: {
    +        account: update.account,
    +        envelope: update.envelope,
    +      },
    +      author: {
    +        userId: this.toSignalUserId(authorIdentifier),
    +        userName: authorName,
    +        fullName: authorName,
    +        isBot: isLinkedDeviceSync ? "unknown" : true,
    +        isMe: !isLinkedDeviceSync,
    +      },
    +      metadata: {
    +        dateSent: this.signalTimestampToDate(timestamp),
    +        edited: false,
    +      },
    +      attachments: this.extractIncomingAttachments({
    +        timestamp,
    +        message: sentMessage.message,
    +        attachments: sentMessage.attachments,
    +        mentions: sentMessage.mentions,
    +        groupInfo: sentMessage.groupInfo,
    +      }),
    +      isMention: false,
    +    });
    +  }
    +
    +  private chatIdFromSyncSentMessage(
    +    update: SignalUpdate,
    +    sentMessage: SignalSyncSentMessage
    +  ): string {
    +    const groupId = sentMessage.groupInfo?.groupId;
    +    if (groupId) {
    +      return this.normalizeIncomingGroupId(groupId);
    +    }
    +
    +    const destination = sentMessage.destination ?? sentMessage.destinationUuid;
    +    if (destination) {
    +      return this.normalizeSignalIdentifier(destination);
    +    }
    +
    +    const envelopeSource = this.resolveEnvelopeSourceIdentifier(
    +      update.envelope
    +    );
    +    if (envelopeSource) {
    +      return envelopeSource;
    +    }
    +
    +    return this.botPhoneNumber;
    +  }
    +
    +  private createOutgoingMessage(params: {
    +    author?: string;
    +    chatId: string;
    +    edited: boolean;
    +    editedAtTimestamp?: number;
    +    text: string;
    +    threadId: string;
    +    timestamp: number;
    +  }): Message {
    +    const authorIdentifier = params.author ?? this.botPhoneNumber;
    +    const sdkUserId = this.toSignalUserId(authorIdentifier);
    +    const dateSent = this.signalTimestampToDate(params.timestamp);
    +
    +    const raw: SignalOutgoingRawMessage = {
    +      kind: "outgoing",
    +      author: authorIdentifier,
    +      recipient: params.chatId,
    +      text: params.text,
    +      timestamp: params.timestamp,
    +      edited: params.edited,
    +    };
    +
    +    return new Message({
    +      id: this.encodeMessageId(authorIdentifier, params.timestamp),
    +      threadId: params.threadId,
    +      text: params.text,
    +      formatted: this.formatConverter.toAst(params.text),
    +      raw,
    +      author: {
    +        userId: sdkUserId,
    +        userName: this._userName,
    +        fullName: this._userName,
    +        isBot: true,
    +        isMe: true,
    +      },
    +      metadata: {
    +        dateSent,
    +        edited: params.edited,
    +        editedAt:
    +          params.edited && params.editedAtTimestamp
    +            ? this.signalTimestampToDate(params.editedAtTimestamp)
    +            : undefined,
    +      },
    +      attachments: [],
    +      isMention: false,
    +    });
    +  }
    +
    +  private extractIncomingAttachments(
    +    dataMessage: SignalDataMessage
    +  ): Attachment[] {
    +    if (!dataMessage.attachments?.length) {
    +      return [];
    +    }
    +
    +    return dataMessage.attachments.map((attachment) => ({
    +      type: this.mapAttachmentType(attachment.contentType),
    +      size: attachment.size,
    +      width: attachment.width,
    +      height: attachment.height,
    +      name: attachment.filename ?? undefined,
    +      mimeType: attachment.contentType,
    +      fetchData: async () => this.downloadAttachment(attachment.id),
    +    }));
    +  }
    +
    +  private mapAttachmentType(mimeType?: string): Attachment["type"] {
    +    const normalized = mimeType?.toLowerCase() ?? "";
    +
    +    if (normalized.startsWith("image/")) {
    +      return "image";
    +    }
    +    if (normalized.startsWith("video/")) {
    +      return "video";
    +    }
    +    if (normalized.startsWith("audio/")) {
    +      return "audio";
    +    }
    +
    +    return "file";
    +  }
    +
    +  private async downloadAttachment(attachmentId: string): Promise {
    +    return this.signalFetchBinary(
    +      `/v1/attachments/${encodeURIComponent(attachmentId)}`,
    +      "downloadAttachment"
    +    );
    +  }
    +
    +  private toAuthor(envelope: SignalEnvelope): SignalMessageAuthor {
    +    const sourceIdentifier =
    +      this.resolveEnvelopeSourceIdentifier(envelope) ?? "unknown";
    +
    +    const userName =
    +      envelope.sourceName ??
    +      envelope.sourceNumber ??
    +      envelope.sourceUuid ??
    +      envelope.source ??
    +      sourceIdentifier;
    +
    +    const isMe =
    +      this.normalizeSignalIdentifier(sourceIdentifier) ===
    +      this.normalizeSignalIdentifier(this.botPhoneNumber);
    +
    +    return {
    +      userId: this.toSignalUserId(sourceIdentifier),
    +      userName,
    +      fullName: envelope.sourceName ?? userName,
    +      isBot: isMe ? true : "unknown",
    +      isMe,
    +    };
    +  }
    +
    +  private isLinkedDeviceSyncMessage(envelope: SignalEnvelope): boolean {
    +    return (
    +      typeof envelope.sourceDevice === "number" && envelope.sourceDevice > 1
    +    );
    +  }
    +
    +  private resolveEnvelopeSourceIdentifier(
    +    envelope: SignalEnvelope
    +  ): string | undefined {
    +    return this.identityMap.registerAliases(
    +      envelope.sourceNumber ?? undefined,
    +      envelope.sourceUuid,
    +      envelope.source
    +    );
    +  }
    +
    +  private resolveReactionTargetAuthor(
    +    reaction: SignalReaction
    +  ): string | undefined {
    +    return this.identityMap.registerAliases(
    +      reaction.targetAuthorNumber ?? undefined,
    +      reaction.targetAuthorUuid,
    +      reaction.targetAuthor
    +    );
    +  }
    +
    +  private isBotMentioned(
    +    dataMessage: SignalDataMessage,
    +    text: string
    +  ): boolean {
    +    if (!(text || dataMessage.mentions?.length)) {
    +      return false;
    +    }
    +
    +    const mentionedBot = (dataMessage.mentions ?? []).some((mention) => {
    +      const mentionedAuthor = mention.author ?? mention.number ?? mention.uuid;
    +      if (!mentionedAuthor) {
    +        return false;
    +      }
    +
    +      return (
    +        this.normalizeSignalIdentifier(mentionedAuthor) ===
    +        this.normalizeSignalIdentifier(this.botPhoneNumber)
    +      );
    +    });
    +
    +    if (mentionedBot) {
    +      return true;
    +    }
    +
    +    if (!text) {
    +      return false;
    +    }
    +
    +    const mentionRegex = new RegExp(
    +      `@${this.escapeRegex(this._userName)}\\b`,
    +      "i"
    +    );
    +    return mentionRegex.test(text);
    +  }
    +
    +  private async toSignalAttachmentPayload(
    +    files: ReturnType
    +  ): Promise {
    +    const payload: string[] = [];
    +
    +    for (const file of files) {
    +      const buffer = await this.toBuffer(file.data);
    +      payload.push(
    +        this.toDataUri(
    +          buffer,
    +          file.mimeType ?? "application/octet-stream",
    +          file.filename
    +        )
    +      );
    +    }
    +
    +    return payload;
    +  }
    +
    +  private async toBuffer(data: Buffer | Blob | ArrayBuffer): Promise {
    +    if (Buffer.isBuffer(data)) {
    +      return data;
    +    }
    +
    +    if (data instanceof ArrayBuffer) {
    +      return Buffer.from(data);
    +    }
    +
    +    if (data instanceof Blob) {
    +      return Buffer.from(await data.arrayBuffer());
    +    }
    +
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      "Unsupported file data type"
    +    );
    +  }
    +
    +  private toDataUri(
    +    buffer: Buffer,
    +    mimeType: string,
    +    filename?: string
    +  ): string {
    +    const encodedName = filename
    +      ? `;filename=${encodeURIComponent(filename)}`
    +      : "";
    +    return `data:${mimeType}${encodedName};base64,${buffer.toString("base64")}`;
    +  }
    +
    +  private renderOutgoingText(
    +    message: AdapterPostableMessage,
    +    card: ReturnType
    +  ): string {
    +    const rendered = card
    +      ? cardToFallbackText(card, { boldFormat: "**" })
    +      : this.formatConverter.renderPostable(message);
    +
    +    return convertEmojiPlaceholders(rendered, "gchat");
    +  }
    +
    +  private resolveOutgoingTextMode(
    +    message: AdapterPostableMessage,
    +    hasCard: boolean
    +  ): SignalTextMode | undefined {
    +    if (this.configuredTextMode) {
    +      return this.configuredTextMode;
    +    }
    +
    +    if (hasCard) {
    +      return "styled";
    +    }
    +
    +    if (typeof message === "string") {
    +      return undefined;
    +    }
    +
    +    if ("raw" in message) {
    +      return undefined;
    +    }
    +
    +    if ("markdown" in message || "ast" in message) {
    +      return "styled";
    +    }
    +
    +    return undefined;
    +  }
    +
    +  private decodeMessageIdForReaction(
    +    threadId: string,
    +    messageId: string
    +  ): { author: string; timestamp: number } {
    +    const decoded = this.decodeMessageId(messageId);
    +
    +    const fromCache = this.messageCache.findById(threadId, messageId);
    +    if (fromCache) {
    +      const cachedDecoded = this.decodeMessageIdRaw(fromCache.id);
    +      if (cachedDecoded.author) {
    +        return {
    +          author: cachedDecoded.author,
    +          timestamp: cachedDecoded.timestamp,
    +        };
    +      }
    +    }
    +
    +    const fromTimestamp = this.messageCache.findByTimestamp(
    +      threadId,
    +      decoded.timestamp
    +    );
    +    if (fromTimestamp) {
    +      const timestampDecoded = this.decodeMessageIdRaw(fromTimestamp.id);
    +      if (timestampDecoded.author) {
    +        return {
    +          author: timestampDecoded.author,
    +          timestamp: timestampDecoded.timestamp,
    +        };
    +      }
    +    }
    +
    +    if (decoded.author) {
    +      return {
    +        author: decoded.author,
    +        timestamp: decoded.timestamp,
    +      };
    +    }
    +
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      `Signal reaction requires a message ID in | format, got: ${messageId}`
    +    );
    +  }
    +
    +  private encodeMessageId(author: string, timestamp: number): string {
    +    return this.encodeMessageIdRaw(
    +      this.identityMap.canonicalize(author),
    +      timestamp
    +    );
    +  }
    +
    +  private encodeMessageIdRaw(author: string, timestamp: number): string {
    +    return `${this.normalizeSignalIdentifier(author)}|${timestamp}`;
    +  }
    +
    +  private decodeMessageId(messageId: string): {
    +    author?: string;
    +    timestamp: number;
    +  } {
    +    const decoded = this.decodeMessageIdRaw(messageId);
    +    if (!decoded.author) {
    +      return decoded;
    +    }
    +
    +    return {
    +      author: this.identityMap.canonicalize(decoded.author),
    +      timestamp: decoded.timestamp,
    +    };
    +  }
    +
    +  private decodeMessageIdRaw(messageId: string): {
    +    author?: string;
    +    timestamp: number;
    +  } {
    +    const matched = messageId.match(MESSAGE_ID_PATTERN);
    +    if (matched) {
    +      const [, author, rawTimestamp] = matched;
    +      const timestamp = Number.parseInt(rawTimestamp, 10);
    +      if (Number.isFinite(timestamp)) {
    +        return {
    +          author: this.normalizeSignalIdentifier(author),
    +          timestamp,
    +        };
    +      }
    +    }
    +
    +    const fallbackTimestamp = Number.parseInt(messageId, 10);
    +    if (Number.isFinite(fallbackTimestamp)) {
    +      return {
    +        timestamp: fallbackTimestamp,
    +      };
    +    }
    +
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      `Invalid Signal message ID: ${messageId}`
    +    );
    +  }
    +
    +  private signalTimestampToDate(timestamp: number): Date {
    +    if (timestamp < 1_000_000_000_000) {
    +      return new Date(timestamp * 1000);
    +    }
    +
    +    return new Date(timestamp);
    +  }
    +
    +  private parseSignalTimestamp(
    +    timestamp: number | string,
    +    context: string
    +  ): number {
    +    const parsed =
    +      typeof timestamp === "number"
    +        ? timestamp
    +        : Number.parseInt(timestamp, 10);
    +
    +    if (!Number.isFinite(parsed)) {
    +      throw new NetworkError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Signal ${context} response contained an invalid timestamp`
    +      );
    +    }
    +
    +    return parsed;
    +  }
    +
    +  private resolveThreadId(value: string): SignalThreadId {
    +    if (value.startsWith(SIGNAL_THREAD_PREFIX)) {
    +      return this.decodeThreadId(value);
    +    }
    +
    +    const normalized = this.normalizeSignalIdentifier(value);
    +
    +    return {
    +      chatId: this.isGroupChatId(normalized)
    +        ? this.normalizeGroupId(normalized)
    +        : this.identityMap.canonicalize(normalized),
    +    };
    +  }
    +
    +  private fromSignalUserId(userId: string): string {
    +    const normalized = userId.startsWith(`${SIGNAL_THREAD_PREFIX}`)
    +      ? this.normalizeSignalIdentifier(
    +          userId.slice(SIGNAL_THREAD_PREFIX.length)
    +        )
    +      : this.normalizeSignalIdentifier(userId);
    +
    +    return this.identityMap.canonicalize(normalized);
    +  }
    +
    +  private toSignalUserId(userId: string): string {
    +    const normalized = this.identityMap.canonicalize(userId);
    +    return `${SIGNAL_THREAD_PREFIX}${normalized}`;
    +  }
    +
    +  private normalizeGroupId(groupId: string): string {
    +    const normalized = this.normalizeSignalIdentifier(groupId);
    +    const encodedGroupId = normalized.startsWith(SIGNAL_GROUP_PREFIX)
    +      ? normalized.slice(SIGNAL_GROUP_PREFIX.length)
    +      : normalized;
    +
    +    if (!encodedGroupId) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal group ID is empty"
    +      );
    +    }
    +
    +    return `${SIGNAL_GROUP_PREFIX}${this.normalizeEncodedGroupId(encodedGroupId)}`;
    +  }
    +
    +  private normalizeIncomingGroupId(groupId: string): string {
    +    const normalized = this.normalizeSignalIdentifier(groupId);
    +
    +    if (!normalized) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        "Signal group ID is empty"
    +      );
    +    }
    +
    +    if (normalized.startsWith(SIGNAL_GROUP_PREFIX)) {
    +      return this.normalizeGroupId(normalized);
    +    }
    +
    +    return `${SIGNAL_GROUP_PREFIX}${Buffer.from(normalized, "binary").toString("base64")}`;
    +  }
    +
    +  private normalizeEncodedGroupId(encodedGroupId: string): string {
    +    if (!BASE64_OR_BASE64URL_PATTERN.test(encodedGroupId)) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Invalid Signal group ID: ${encodedGroupId}`
    +      );
    +    }
    +
    +    const base64 = encodedGroupId.replace(/-/g, "+").replace(/_/g, "/");
    +    const paddingLength = (4 - (base64.length % 4)) % 4;
    +    const padded = `${base64}${"=".repeat(paddingLength)}`;
    +
    +    if (!this.isCanonicalBase64(padded)) {
    +      throw new ValidationError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Invalid Signal group ID: ${encodedGroupId}`
    +      );
    +    }
    +
    +    return padded;
    +  }
    +
    +  private isCanonicalBase64(value: string): boolean {
    +    try {
    +      const decoded = Buffer.from(value, "base64");
    +      if (decoded.length === 0) {
    +        return false;
    +      }
    +
    +      const normalizedValue = value.replace(
    +        TRAILING_BASE64_PADDING_PATTERN,
    +        ""
    +      );
    +      const roundTrip = decoded
    +        .toString("base64")
    +        .replace(TRAILING_BASE64_PADDING_PATTERN, "");
    +
    +      return normalizedValue === roundTrip;
    +    } catch {
    +      return false;
    +    }
    +  }
    +
    +  private isGroupChatId(chatId: string): boolean {
    +    return chatId.startsWith(SIGNAL_GROUP_PREFIX);
    +  }
    +
    +  private normalizeBaseUrl(baseUrl: string): string {
    +    const withScheme =
    +      baseUrl.startsWith("http://") || baseUrl.startsWith("https://")
    +        ? baseUrl
    +        : `http://${baseUrl}`;
    +
    +    return withScheme.replace(TRAILING_SLASHES_REGEX, "");
    +  }
    +
    +  private normalizeSignalIdentifier(value: string): string {
    +    return value.trim();
    +  }
    +
    +  private normalizeUserName(value: string): string {
    +    return value.replace(LEADING_AT_PATTERN, "").trim() || "bot";
    +  }
    +
    +  private escapeRegex(input: string): string {
    +    return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    +  }
    +
    +  private truncateMessage(text: string): string {
    +    if (text.length <= SIGNAL_MESSAGE_LIMIT) {
    +      return text;
    +    }
    +
    +    return `${text.slice(0, SIGNAL_MESSAGE_LIMIT - 3)}...`;
    +  }
    +
    +  private toSignalReactionEmoji(emoji: EmojiValue | string): string {
    +    if (typeof emoji !== "string") {
    +      return defaultEmojiResolver.toGChat(emoji.name);
    +    }
    +
    +    const placeholderMatch = emoji.match(EMOJI_PLACEHOLDER_PATTERN);
    +    if (placeholderMatch) {
    +      return defaultEmojiResolver.toGChat(placeholderMatch[1]);
    +    }
    +
    +    if (EMOJI_NAME_PATTERN.test(emoji)) {
    +      return defaultEmojiResolver.toGChat(emoji.toLowerCase());
    +    }
    +
    +    return emoji;
    +  }
    +
    +  private async fetchGroup(chatId: string): Promise {
    +    return this.signalFetch(
    +      `/v1/groups/${encodeURIComponent(this.botPhoneNumber)}/${encodeURIComponent(chatId)}`,
    +      {
    +        method: "GET",
    +      },
    +      "fetchGroup"
    +    );
    +  }
    +
    +  private isOutgoingRawMessage(
    +    raw: SignalRawMessage
    +  ): raw is SignalOutgoingRawMessage {
    +    return (
    +      raw && typeof raw === "object" && "kind" in raw && raw.kind === "outgoing"
    +    );
    +  }
    +
    +  private normalizePositiveInteger(
    +    value: number | undefined,
    +    fallback: number
    +  ): number {
    +    if (typeof value !== "number" || !Number.isFinite(value)) {
    +      return fallback;
    +    }
    +
    +    return Math.max(1, Math.trunc(value));
    +  }
    +
    +  private async assertSignalServiceHealth(): Promise {
    +    await this.signalFetch(
    +      "/v1/health",
    +      {
    +        method: "GET",
    +      },
    +      "healthCheck"
    +    );
    +
    +    const accountsPayload = await this.signalFetch(
    +      "/v1/accounts",
    +      {
    +        method: "GET",
    +      },
    +      "listAccounts"
    +    );
    +
    +    if (!Array.isArray(accountsPayload)) {
    +      this.logger.warn("Signal /v1/accounts response was not an array", {
    +        accountsPayloadType: typeof accountsPayload,
    +      });
    +      return;
    +    }
    +
    +    const normalizedAccounts = accountsPayload
    +      .filter((entry): entry is string => typeof entry === "string")
    +      .map((entry) => this.normalizeSignalIdentifier(entry));
    +
    +    if (normalizedAccounts.includes(this.botPhoneNumber)) {
    +      return;
    +    }
    +
    +    const knownAccounts =
    +      normalizedAccounts.length > 0 ? normalizedAccounts.join(", ") : "";
    +
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      `Configured phone number ${this.botPhoneNumber} is not registered in signal-cli-rest-api (known accounts: ${knownAccounts})`
    +    );
    +  }
    +
    +  private async runPollingLoop(
    +    options: {
    +      intervalMs: number;
    +      timeoutSeconds?: number;
    +      webhookOptions?: WebhookOptions;
    +    },
    +    signal: AbortSignal
    +  ): Promise {
    +    while (!signal.aborted) {
    +      try {
    +        await this.pollOnce({
    +          timeoutSeconds: options.timeoutSeconds,
    +          webhookOptions: options.webhookOptions,
    +        });
    +      } catch (error) {
    +        if (signal.aborted) {
    +          break;
    +        }
    +
    +        this.logger.warn("Signal polling request failed", {
    +          error: String(error),
    +        });
    +      }
    +
    +      if (signal.aborted) {
    +        break;
    +      }
    +
    +      await this.waitForPollingInterval(options.intervalMs, signal);
    +    }
    +  }
    +
    +  private async waitForPollingInterval(
    +    intervalMs: number,
    +    signal: AbortSignal
    +  ): Promise {
    +    if (intervalMs <= 0 || signal.aborted) {
    +      return;
    +    }
    +
    +    await new Promise((resolve) => {
    +      const cleanup = (): void => {
    +        signal.removeEventListener("abort", onAbort);
    +      };
    +
    +      const finish = (): void => {
    +        cleanup();
    +        resolve();
    +      };
    +
    +      const onAbort = (): void => {
    +        clearTimeout(timeout);
    +        finish();
    +      };
    +
    +      const timeout = setTimeout(finish, intervalMs);
    +      signal.addEventListener("abort", onAbort, { once: true });
    +    });
    +  }
    +
    +  private async signalFetch(
    +    path: string,
    +    init: {
    +      body?: unknown;
    +      method: "GET" | "POST" | "PUT" | "DELETE";
    +    },
    +    operation: string
    +  ): Promise {
    +    const url = `${this.apiBaseUrl}${path}`;
    +
    +    let response: Response;
    +    try {
    +      response = await fetch(url, {
    +        method: init.method,
    +        headers:
    +          init.body === undefined
    +            ? undefined
    +            : {
    +                "Content-Type": "application/json",
    +              },
    +        body: init.body === undefined ? undefined : JSON.stringify(init.body),
    +      });
    +    } catch (error) {
    +      throw new NetworkError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Network error while calling Signal ${operation}`,
    +        error instanceof Error ? error : undefined
    +      );
    +    }
    +
    +    const responseText = await response.text();
    +    const parsedBody = this.parseResponseBody(responseText);
    +
    +    if (!response.ok) {
    +      this.throwSignalApiError(operation, response.status, parsedBody);
    +    }
    +
    +    if (!responseText.trim()) {
    +      return undefined as TResult;
    +    }
    +
    +    return parsedBody as TResult;
    +  }
    +
    +  private async signalFetchBinary(
    +    path: string,
    +    operation: string
    +  ): Promise {
    +    const url = `${this.apiBaseUrl}${path}`;
    +
    +    let response: Response;
    +    try {
    +      response = await fetch(url, {
    +        method: "GET",
    +      });
    +    } catch (error) {
    +      throw new NetworkError(
    +        SIGNAL_ADAPTER_NAME,
    +        `Network error while calling Signal ${operation}`,
    +        error instanceof Error ? error : undefined
    +      );
    +    }
    +
    +    if (!response.ok) {
    +      const text = await response.text();
    +      this.throwSignalApiError(
    +        operation,
    +        response.status,
    +        this.parseResponseBody(text)
    +      );
    +    }
    +
    +    return Buffer.from(await response.arrayBuffer());
    +  }
    +
    +  private parseResponseBody(payload: string): unknown {
    +    if (!payload.trim()) {
    +      return undefined;
    +    }
    +
    +    try {
    +      return JSON.parse(payload);
    +    } catch {
    +      return payload;
    +    }
    +  }
    +
    +  private throwSignalApiError(
    +    operation: string,
    +    status: number,
    +    payload: unknown
    +  ): never {
    +    const message = this.extractErrorMessage(payload, operation);
    +
    +    if (status === 429) {
    +      throw new AdapterRateLimitError(SIGNAL_ADAPTER_NAME);
    +    }
    +
    +    if (status === 401) {
    +      throw new AuthenticationError(SIGNAL_ADAPTER_NAME, message);
    +    }
    +
    +    if (status === 403) {
    +      throw new PermissionError(SIGNAL_ADAPTER_NAME, operation);
    +    }
    +
    +    if (status === 404) {
    +      throw new ResourceNotFoundError(SIGNAL_ADAPTER_NAME, operation);
    +    }
    +
    +    if (status >= 400 && status < 500) {
    +      throw new ValidationError(SIGNAL_ADAPTER_NAME, message);
    +    }
    +
    +    throw new NetworkError(
    +      SIGNAL_ADAPTER_NAME,
    +      `${message} (status ${status})`
    +    );
    +  }
    +
    +  private extractErrorMessage(payload: unknown, operation: string): string {
    +    if (typeof payload === "string" && payload.trim()) {
    +      return payload;
    +    }
    +
    +    if (payload && typeof payload === "object") {
    +      const typed = payload as SignalApiErrorResponse;
    +      if (typed.error) {
    +        return typed.error;
    +      }
    +      if (typed.message) {
    +        return typed.message;
    +      }
    +    }
    +
    +    return `Signal API ${operation} failed`;
    +  }
    +}
    +
    +export function createSignalAdapter(
    +  config?: Partial
    +): SignalAdapter {
    +  const phoneNumber = config?.phoneNumber ?? process.env.SIGNAL_PHONE_NUMBER;
    +
    +  if (!phoneNumber) {
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      "phoneNumber is required. Set SIGNAL_PHONE_NUMBER or provide it in config."
    +    );
    +  }
    +
    +  const textModeFromEnv = process.env.SIGNAL_TEXT_MODE;
    +  const textMode =
    +    config?.textMode ??
    +    (textModeFromEnv === "normal" || textModeFromEnv === "styled"
    +      ? textModeFromEnv
    +      : undefined);
    +
    +  if (
    +    textModeFromEnv &&
    +    textModeFromEnv !== "normal" &&
    +    textModeFromEnv !== "styled"
    +  ) {
    +    throw new ValidationError(
    +      SIGNAL_ADAPTER_NAME,
    +      "SIGNAL_TEXT_MODE must be either 'normal' or 'styled'"
    +    );
    +  }
    +
    +  return new SignalAdapter({
    +    phoneNumber,
    +    baseUrl:
    +      config?.baseUrl ??
    +      process.env.SIGNAL_SERVICE_URL ??
    +      process.env.SIGNAL_SERVICE ??
    +      DEFAULT_SIGNAL_API_BASE_URL,
    +    textMode,
    +    webhookSecret: config?.webhookSecret ?? process.env.SIGNAL_WEBHOOK_SECRET,
    +    webhookSecretHeader:
    +      config?.webhookSecretHeader ?? process.env.SIGNAL_WEBHOOK_SECRET_HEADER,
    +    logger: config?.logger ?? new ConsoleLogger("info").child("signal"),
    +    userName: config?.userName ?? process.env.SIGNAL_BOT_USERNAME,
    +  });
    +}
    +
    +export { SignalFormatConverter } from "./markdown";
    +export type {
    +  SignalAdapterConfig,
    +  SignalDataMessage,
    +  SignalEnvelope,
    +  SignalGroup,
    +  SignalJsonRpcReceivePayload,
    +  SignalOutgoingRawMessage,
    +  SignalRawMessage,
    +  SignalReaction,
    +  SignalSyncMessage,
    +  SignalSyncSentMessage,
    +  SignalThreadId,
    +  SignalUpdate,
    +} from "./types";
    diff --git a/packages/adapter-signal/src/markdown.ts b/packages/adapter-signal/src/markdown.ts
    new file mode 100644
    index 00000000..1af2867e
    --- /dev/null
    +++ b/packages/adapter-signal/src/markdown.ts
    @@ -0,0 +1,40 @@
    +/**
    + * Signal format conversion.
    + *
    + * Signal supports optional styled text mode. We keep markdown as canonical
    + * and render markdown strings for outgoing messages.
    + */
    +
    +import {
    +  type AdapterPostableMessage,
    +  BaseFormatConverter,
    +  parseMarkdown,
    +  type Root,
    +  stringifyMarkdown,
    +} from "chat";
    +
    +export class SignalFormatConverter extends BaseFormatConverter {
    +  fromAst(ast: Root): string {
    +    return stringifyMarkdown(ast).trim();
    +  }
    +
    +  toAst(text: string): Root {
    +    return parseMarkdown(text);
    +  }
    +
    +  override renderPostable(message: AdapterPostableMessage): string {
    +    if (typeof message === "string") {
    +      return message;
    +    }
    +    if ("raw" in message) {
    +      return message.raw;
    +    }
    +    if ("markdown" in message) {
    +      return this.fromMarkdown(message.markdown);
    +    }
    +    if ("ast" in message) {
    +      return this.fromAst(message.ast);
    +    }
    +    return super.renderPostable(message);
    +  }
    +}
    diff --git a/packages/adapter-signal/src/types.ts b/packages/adapter-signal/src/types.ts
    new file mode 100644
    index 00000000..462ba6c8
    --- /dev/null
    +++ b/packages/adapter-signal/src/types.ts
    @@ -0,0 +1,267 @@
    +/**
    + * Signal adapter types.
    + */
    +
    +export type SignalTextMode = "normal" | "styled";
    +
    +/**
    + * Signal adapter configuration.
    + */
    +export interface SignalAdapterConfig {
    +  /**
    +   * Base URL of signal-cli-rest-api.
    +   * @default "http://localhost:8080"
    +   */
    +  baseUrl?: string;
    +
    +  /**
    +   * Signal number registered with signal-cli-rest-api.
    +   * Example: "+491234567890"
    +   */
    +  phoneNumber: string;
    +
    +  /**
    +   * Optional text mode override for outgoing messages.
    +   * If omitted, plain/raw messages use default server behavior and
    +   * markdown/ast/card messages default to `styled`.
    +   */
    +  textMode?: SignalTextMode;
    +
    +  /**
    +   * Optional secret used to validate incoming webhook requests.
    +   */
    +  webhookSecret?: string;
    +
    +  /**
    +   * Optional header name for webhook secret validation.
    +   * @default "x-signal-webhook-secret"
    +   */
    +  webhookSecretHeader?: string;
    +}
    +
    +/**
    + * Signal thread ID components.
    + */
    +export interface SignalThreadId {
    +  /**
    +   * Signal chat identifier.
    +   * - Direct messages: phone number/UUID/username
    +   * - Group messages: `group.`
    +   */
    +  chatId: string;
    +}
    +
    +export interface SignalMessageMention {
    +  author?: string;
    +  length: number;
    +  name?: string;
    +  number?: string;
    +  start: number;
    +  uuid?: string;
    +}
    +
    +export interface SignalAttachment {
    +  caption?: string;
    +  contentType?: string;
    +  filename?: string | null;
    +  height?: number;
    +  id: string;
    +  size?: number;
    +  width?: number;
    +}
    +
    +export interface SignalGroupInfo {
    +  groupId: string;
    +  groupName?: string;
    +  revision?: number;
    +  type?: string;
    +}
    +
    +export interface SignalReaction {
    +  emoji: string;
    +  isRemove?: boolean;
    +  targetAuthor?: string;
    +  targetAuthorNumber?: string | null;
    +  targetAuthorUuid?: string;
    +  targetSentTimestamp: number;
    +}
    +
    +export interface SignalDataMessage {
    +  attachments?: SignalAttachment[];
    +  expiresInSeconds?: number;
    +  groupInfo?: SignalGroupInfo;
    +  isExpirationUpdate?: boolean;
    +  mentions?: SignalMessageMention[];
    +  message?: string | null;
    +  quote?: {
    +    author?: string;
    +    id?: number;
    +    text?: string | null;
    +  };
    +  reaction?: SignalReaction;
    +  remoteDelete?: {
    +    timestamp: number;
    +  };
    +  timestamp: number;
    +  viewOnce?: boolean;
    +}
    +
    +export interface SignalSyncSentMessage {
    +  attachments?: SignalAttachment[];
    +  destination?: string | null;
    +  destinationUuid?: string | null;
    +  groupInfo?: SignalGroupInfo;
    +  mentions?: SignalMessageMention[];
    +  message?: string | null;
    +  quote?: {
    +    author?: string;
    +    id?: number;
    +    text?: string | null;
    +  };
    +  timestamp: number;
    +}
    +
    +export interface SignalSyncMessage {
    +  contacts?: Record;
    +  readMessages?: Record[];
    +  sentMessage?: SignalSyncSentMessage;
    +  viewedMessages?: Record[];
    +}
    +
    +export interface SignalEditMessage {
    +  dataMessage: SignalDataMessage;
    +  targetSentTimestamp: number;
    +}
    +
    +export interface SignalDeleteMessage {
    +  targetSentTimestamp: number;
    +}
    +
    +export interface SignalTypingMessage {
    +  action: "STARTED" | "STOPPED";
    +  groupId?: string;
    +  timestamp: number;
    +}
    +
    +export interface SignalReceiptMessage {
    +  isDelivery?: boolean;
    +  isRead?: boolean;
    +  isViewed?: boolean;
    +  timestamps?: number[];
    +  when?: number;
    +}
    +
    +export interface SignalEnvelope {
    +  callMessage?: Record;
    +  dataMessage?: SignalDataMessage;
    +  deleteMessage?: SignalDeleteMessage;
    +  editMessage?: SignalEditMessage;
    +  receiptMessage?: SignalReceiptMessage;
    +  serverDeliveredTimestamp?: number;
    +  serverReceivedTimestamp?: number;
    +  source?: string;
    +  sourceDevice?: number;
    +  sourceName?: string;
    +  sourceNumber?: string | null;
    +  sourceUuid?: string;
    +  syncMessage?: SignalSyncMessage;
    +  timestamp?: number;
    +  typingMessage?: SignalTypingMessage;
    +}
    +
    +/**
    + * Raw receive payload emitted by signal-cli-rest-api receive endpoints.
    + */
    +export interface SignalUpdate {
    +  account?: string;
    +  envelope: SignalEnvelope;
    +}
    +
    +/**
    + * JSON-RPC receive wrapper used by RECEIVE_WEBHOOK_URL.
    + */
    +export interface SignalJsonRpcReceivePayload {
    +  error?: {
    +    code: number;
    +    message: string;
    +  };
    +  jsonrpc?: string;
    +  method?: string;
    +  params?: SignalUpdate;
    +}
    +
    +/**
    + * Raw payload synthesized for outgoing adapter responses.
    + */
    +export interface SignalOutgoingRawMessage {
    +  author: string;
    +  edited?: boolean;
    +  kind: "outgoing";
    +  recipient: string;
    +  text: string;
    +  timestamp: number;
    +}
    +
    +export type SignalRawMessage = SignalUpdate | SignalOutgoingRawMessage;
    +
    +export interface SignalSendMessageRequest {
    +  base64_attachments?: string[];
    +  edit_timestamp?: number;
    +  message: string;
    +  number: string;
    +  recipients: string[];
    +  text_mode?: SignalTextMode;
    +  view_once?: boolean;
    +}
    +
    +export interface SignalSendMessageResponse {
    +  results?: Array<{
    +    networkFailure?: boolean;
    +    recipientAddress?: {
    +      number?: string;
    +      uuid?: string;
    +    };
    +    status?: string;
    +    unregisteredFailure?: boolean;
    +  }>;
    +  timestamp: number | string;
    +}
    +
    +export interface SignalReactionRequest {
    +  reaction: string;
    +  recipient: string;
    +  target_author: string;
    +  timestamp: number;
    +}
    +
    +export interface SignalRemoteDeleteRequest {
    +  recipient: string;
    +  timestamp: number;
    +}
    +
    +export interface SignalTypingIndicatorRequest {
    +  recipient: string;
    +}
    +
    +export interface SignalGroup {
    +  admins?: Array;
    +  blocked?: boolean;
    +  description?: string;
    +  id: string;
    +  internal_id?: string;
    +  invite_link?: string;
    +  isBlocked?: boolean;
    +  isMember?: boolean;
    +  members?: Array;
    +  name?: string;
    +  pending_invites?: string[];
    +  pending_requests?: string[];
    +  revision?: number;
    +}
    +
    +export interface SignalApiErrorResponse {
    +  account?: string;
    +  challenge_tokens?: string[];
    +  error?: string;
    +  message?: string;
    +}
    diff --git a/packages/adapter-signal/tsconfig.json b/packages/adapter-signal/tsconfig.json
    new file mode 100644
    index 00000000..8768f5bd
    --- /dev/null
    +++ b/packages/adapter-signal/tsconfig.json
    @@ -0,0 +1,10 @@
    +{
    +  "extends": "../../tsconfig.base.json",
    +  "compilerOptions": {
    +    "outDir": "./dist",
    +    "rootDir": "./src",
    +    "strictNullChecks": true
    +  },
    +  "include": ["src/**/*"],
    +  "exclude": ["node_modules", "dist", "**/*.test.ts"]
    +}
    diff --git a/packages/adapter-signal/tsup.config.ts b/packages/adapter-signal/tsup.config.ts
    new file mode 100644
    index 00000000..faf3167a
    --- /dev/null
    +++ b/packages/adapter-signal/tsup.config.ts
    @@ -0,0 +1,9 @@
    +import { defineConfig } from "tsup";
    +
    +export default defineConfig({
    +  entry: ["src/index.ts"],
    +  format: ["esm"],
    +  dts: true,
    +  clean: true,
    +  sourcemap: true,
    +});
    diff --git a/packages/adapter-signal/vitest.config.ts b/packages/adapter-signal/vitest.config.ts
    new file mode 100644
    index 00000000..edc2d946
    --- /dev/null
    +++ b/packages/adapter-signal/vitest.config.ts
    @@ -0,0 +1,14 @@
    +import { defineProject } from "vitest/config";
    +
    +export default defineProject({
    +  test: {
    +    globals: true,
    +    environment: "node",
    +    coverage: {
    +      provider: "v8",
    +      reporter: ["text", "json-summary"],
    +      include: ["src/**/*.ts"],
    +      exclude: ["src/**/*.test.ts"],
    +    },
    +  },
    +});
    diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts
    index bedaaf3f..040e878d 100644
    --- a/packages/chat/src/chat.test.ts
    +++ b/packages/chat/src/chat.test.ts
    @@ -181,6 +181,44 @@ describe("Chat", () => {
           expect(handler).toHaveBeenCalledTimes(1);
         });
     
    +    it("should treat edited messages as distinct dedupe events", async () => {
    +      const handler = vi.fn().mockResolvedValue(undefined);
    +      chat.onNewMention(handler);
    +
    +      const original = createTestMessage("msg-1", "Hey @slack-bot help", {
    +        metadata: {
    +          dateSent: new Date("2024-01-15T10:00:00.000Z"),
    +          edited: false,
    +        },
    +      });
    +
    +      const edited = createTestMessage("msg-1", "Hey @slack-bot help", {
    +        metadata: {
    +          dateSent: new Date("2024-01-15T10:00:00.000Z"),
    +          edited: true,
    +          editedAt: new Date("2024-01-15T10:05:00.000Z"),
    +        },
    +      });
    +
    +      await chat.handleIncomingMessage(
    +        mockAdapter,
    +        "slack:C123:1234.5678",
    +        original
    +      );
    +      await chat.handleIncomingMessage(
    +        mockAdapter,
    +        "slack:C123:1234.5678",
    +        edited
    +      );
    +
    +      expect(handler).toHaveBeenCalledTimes(2);
    +      expect(mockState.setIfNotExists).toHaveBeenCalledWith(
    +        "dedupe:slack:msg-1:edited:1705313100000",
    +        true,
    +        300_000
    +      );
    +    });
    +
         it("should use default dedupe TTL of 5 minutes", async () => {
           const handler = vi.fn().mockResolvedValue(undefined);
           chat.onNewMention(handler);
    @@ -1242,6 +1280,46 @@ describe("Chat", () => {
           expect(thread.id).toBe("slack:DU789ABC:");
         });
     
    +    it("should infer Signal adapter from signal:-prefixed userId", async () => {
    +      const signalAdapter = createMockAdapter("signal");
    +      signalAdapter.openDM = vi.fn().mockResolvedValue("signal:+15551234567");
    +
    +      const signalChat = new Chat({
    +        userName: "testbot",
    +        adapters: { signal: signalAdapter },
    +        state: createMockState(),
    +        logger: mockLogger,
    +      });
    +
    +      await signalChat.webhooks.signal(
    +        new Request("http://test.com", { method: "POST" })
    +      );
    +
    +      const thread = await signalChat.openDM("signal:+15551234567");
    +
    +      expect(signalAdapter.openDM).toHaveBeenCalledWith("signal:+15551234567");
    +      expect(thread.id).toBe("signal:+15551234567");
    +    });
    +
    +    it("should infer Signal adapter from E.164 phone number", async () => {
    +      const signalAdapter = createMockAdapter("signal");
    +      signalAdapter.openDM = vi.fn().mockResolvedValue("signal:+15551234567");
    +
    +      const signalChat = new Chat({
    +        userName: "testbot",
    +        adapters: { signal: signalAdapter },
    +        state: createMockState(),
    +        logger: mockLogger,
    +      });
    +
    +      await signalChat.webhooks.signal(
    +        new Request("http://test.com", { method: "POST" })
    +      );
    +
    +      await signalChat.openDM("+15551234567");
    +      expect(signalAdapter.openDM).toHaveBeenCalledWith("+15551234567");
    +    });
    +
         it("should throw error for unknown userId format", async () => {
           await expect(chat.openDM("invalid-user-id")).rejects.toThrow(
             'Cannot infer adapter from userId "invalid-user-id"'
    diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts
    index ecfa0da0..5ccc4732 100644
    --- a/packages/chat/src/chat.ts
    +++ b/packages/chat/src/chat.ts
    @@ -49,6 +49,8 @@ import { ChatError, ConsoleLogger, LockError } from "./types";
     const DEFAULT_LOCK_TTL_MS = 30_000; // 30 seconds
     const SLACK_USER_ID_REGEX = /^U[A-Z0-9]+$/i;
     const DISCORD_SNOWFLAKE_REGEX = /^\d{17,19}$/;
    +const SIGNAL_PREFIX_REGEX = /^signal:(.+)$/i;
    +const SIGNAL_PHONE_NUMBER_REGEX = /^\+[1-9]\d{6,14}$/;
     /** TTL for message deduplication entries */
     const DEDUPE_TTL_MS = 5 * 60 * 1000; // 5 minutes
     const MODAL_CONTEXT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
    @@ -1339,6 +1341,7 @@ export class Chat<
        * - Teams: `29:...` (e.g., "29:198PbJuw...")
        * - Google Chat: `users/...` (e.g., "users/100000000000000000001")
        * - Discord: numeric snowflake (e.g., "1033044521375764530")
    +   * - Signal: `signal:...` or E.164 phone numbers (e.g., "+15551234567")
        *
        * @param user - Platform-specific user ID string, or an Author object
        * @returns A Thread that can be used to post messages
    @@ -1456,8 +1459,24 @@ export class Chat<
           }
         }
     
    +    // Signal: signal:+15551234567 or signal:
    +    if (SIGNAL_PREFIX_REGEX.test(userId)) {
    +      const adapter = this.adapters.get("signal");
    +      if (adapter) {
    +        return adapter;
    +      }
    +    }
    +
    +    // Signal: E.164 phone number (+15551234567)
    +    if (SIGNAL_PHONE_NUMBER_REGEX.test(userId)) {
    +      const adapter = this.adapters.get("signal");
    +      if (adapter) {
    +        return adapter;
    +      }
    +    }
    +
         throw new ChatError(
    -      `Cannot infer adapter from userId "${userId}". Expected format: Slack (U...), Teams (29:...), Google Chat (users/...), or Discord (numeric snowflake).`,
    +      `Cannot infer adapter from userId "${userId}". Expected format: Slack (U...), Teams (29:...), Google Chat (users/...), Discord (numeric snowflake), or Signal (signal:... or +E.164).`,
           "UNKNOWN_USER_ID_FORMAT"
         );
       }
    @@ -1500,7 +1519,11 @@ export class Chat<
     
         // Deduplicate messages atomically - same message can arrive via multiple paths
         // (e.g., Slack message + app_mention events, GChat direct webhook + Pub/Sub)
    -    const dedupeKey = `dedupe:${adapter.name}:${message.id}`;
    +    const editDedupeSuffix =
    +      message.metadata.edited && message.metadata.editedAt
    +        ? `:edited:${message.metadata.editedAt.getTime()}`
    +        : "";
    +    const dedupeKey = `dedupe:${adapter.name}:${message.id}${editDedupeSuffix}`;
         const isFirstProcess = await this._stateAdapter.setIfNotExists(
           dedupeKey,
           true,
    diff --git a/packages/integration-tests/src/readme.test.ts b/packages/integration-tests/src/readme.test.ts
    index 3656fe41..e8e6d85b 100644
    --- a/packages/integration-tests/src/readme.test.ts
    +++ b/packages/integration-tests/src/readme.test.ts
    @@ -76,6 +76,9 @@ function createTempProject(codeBlocks: string[]): string {
             "@chat-adapter/telegram": [
               join(import.meta.dirname, "../../adapter-telegram/src/index.ts"),
             ],
    +        "@chat-adapter/signal": [
    +          join(import.meta.dirname, "../../adapter-signal/src/index.ts"),
    +        ],
             "@chat-adapter/github": [
               join(import.meta.dirname, "../../adapter-github/src/index.ts"),
             ],
    @@ -266,6 +269,7 @@ describe("Package README code examples", () => {
                   "@chat-adapter/gchat",
                   "@chat-adapter/discord",
                   "@chat-adapter/telegram",
    +              "@chat-adapter/signal",
                   "@chat-adapter/github",
                   "@chat-adapter/linear",
                   "@chat-adapter/state-redis",
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
    index 4d7aa821..c73cfa53 100644
    --- a/pnpm-lock.yaml
    +++ b/pnpm-lock.yaml
    @@ -183,6 +183,9 @@ importers:
           '@chat-adapter/linear':
             specifier: workspace:*
             version: link:../../packages/adapter-linear
    +      '@chat-adapter/signal':
    +        specifier: workspace:*
    +        version: link:../../packages/adapter-signal
           '@chat-adapter/slack':
             specifier: workspace:*
             version: link:../../packages/adapter-slack
    @@ -367,6 +370,28 @@ importers:
             specifier: ^4.0.18
             version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
     
    +  packages/adapter-signal:
    +    dependencies:
    +      '@chat-adapter/shared':
    +        specifier: workspace:*
    +        version: link:../adapter-shared
    +      chat:
    +        specifier: workspace:*
    +        version: link:../chat
    +    devDependencies:
    +      '@types/node':
    +        specifier: ^25.3.2
    +        version: 25.3.2
    +      tsup:
    +        specifier: ^8.3.5
    +        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)
    +      typescript:
    +        specifier: ^5.7.2
    +        version: 5.9.3
    +      vitest:
    +        specifier: ^4.0.18
    +        version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
    +
       packages/adapter-slack:
         dependencies:
           '@chat-adapter/shared':
    diff --git a/turbo.json b/turbo.json
    index 98bf25f6..54dae039 100644
    --- a/turbo.json
    +++ b/turbo.json
    @@ -10,6 +10,13 @@
         "GOOGLE_CHAT_CREDENTIALS",
         "GOOGLE_CHAT_PUBSUB_TOPIC",
         "GOOGLE_CHAT_IMPERSONATE_USER",
    +    "SIGNAL_PHONE_NUMBER",
    +    "SIGNAL_SERVICE_URL",
    +    "SIGNAL_SERVICE",
    +    "SIGNAL_WEBHOOK_SECRET",
    +    "SIGNAL_WEBHOOK_SECRET_HEADER",
    +    "SIGNAL_TEXT_MODE",
    +    "SIGNAL_BOT_USERNAME",
         "BOT_USERNAME",
         "REDIS_URL"
       ],