From 8a68ba4751a8e31ad2cac39068dc1bba495a7d8f Mon Sep 17 00:00:00 2001 From: Daksh Date: Tue, 3 Mar 2026 17:35:06 +0530 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20signal=20adapt?= =?UTF-8?q?er=20to=20the=20chat=20sdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/add-signal-adapter.md | 33 + README.md | 6 +- apps/docs/app/[lang]/sitemap.md/route.ts | 16 +- examples/nextjs-chat/package.json | 1 + examples/nextjs-chat/src/app/page.tsx | 12 + examples/nextjs-chat/src/lib/adapters.ts | 25 + examples/nextjs-chat/src/lib/bot.tsx | 8 +- examples/signal-local/.env.example | 8 + examples/signal-local/01-health-check.ts | 40 + examples/signal-local/02-send-edit-delete.ts | 81 + examples/signal-local/03-reactions.ts | 68 + examples/signal-local/04-typing.ts | 51 + examples/signal-local/05-group.ts | 70 + examples/signal-local/06-poll-receive.ts | 72 + examples/signal-local/07-webhook-server.ts | 105 + examples/signal-local/08-echo-bot.ts | 121 + examples/signal-local/README.md | 109 + examples/signal-local/env.ts | 14 + examples/signal-local/package.json | 25 + examples/signal-local/tsconfig.json | 11 + examples/signal-local/ws.ts | 54 + packages/adapter-signal/CHANGELOG.md | 12 + packages/adapter-signal/README.md | 199 ++ packages/adapter-signal/package.json | 55 + packages/adapter-signal/src/index.test.ts | 761 ++++++ packages/adapter-signal/src/index.ts | 2217 +++++++++++++++++ packages/adapter-signal/src/markdown.ts | 40 + packages/adapter-signal/src/types.ts | 267 ++ packages/adapter-signal/tsconfig.json | 10 + packages/adapter-signal/tsup.config.ts | 9 + packages/adapter-signal/vitest.config.ts | 14 + packages/chat/src/chat.test.ts | 78 + packages/chat/src/chat.ts | 27 +- packages/integration-tests/src/readme.test.ts | 4 + pnpm-lock.yaml | 50 + turbo.json | 7 + 36 files changed, 4667 insertions(+), 13 deletions(-) create mode 100644 .changeset/add-signal-adapter.md create mode 100644 examples/signal-local/.env.example create mode 100644 examples/signal-local/01-health-check.ts create mode 100644 examples/signal-local/02-send-edit-delete.ts create mode 100644 examples/signal-local/03-reactions.ts create mode 100644 examples/signal-local/04-typing.ts create mode 100644 examples/signal-local/05-group.ts create mode 100644 examples/signal-local/06-poll-receive.ts create mode 100644 examples/signal-local/07-webhook-server.ts create mode 100644 examples/signal-local/08-echo-bot.ts create mode 100644 examples/signal-local/README.md create mode 100644 examples/signal-local/env.ts create mode 100644 examples/signal-local/package.json create mode 100644 examples/signal-local/tsconfig.json create mode 100644 examples/signal-local/ws.ts create mode 100644 packages/adapter-signal/CHANGELOG.md create mode 100644 packages/adapter-signal/README.md create mode 100644 packages/adapter-signal/package.json create mode 100644 packages/adapter-signal/src/index.test.ts create mode 100644 packages/adapter-signal/src/index.ts create mode 100644 packages/adapter-signal/src/markdown.ts create mode 100644 packages/adapter-signal/src/types.ts create mode 100644 packages/adapter-signal/tsconfig.json create mode 100644 packages/adapter-signal/tsup.config.ts create mode 100644 packages/adapter-signal/vitest.config.ts 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 39e78eba..efc810c6 100644 --- a/apps/docs/app/[lang]/sitemap.md/route.ts +++ b/apps/docs/app/[lang]/sitemap.md/route.ts @@ -6,17 +6,17 @@ export const revalidate = false; 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 9fc78feb..414bfba7 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",
    @@ -202,6 +214,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 d6e657a5..5865926e 100644
    --- a/examples/nextjs-chat/src/lib/bot.tsx
    +++ b/examples/nextjs-chat/src/lib/bot.tsx
    @@ -570,7 +570,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/examples/signal-local/.env.example b/examples/signal-local/.env.example
    new file mode 100644
    index 00000000..dbf0e183
    --- /dev/null
    +++ b/examples/signal-local/.env.example
    @@ -0,0 +1,8 @@
    +# Your bot's phone number registered in signal-cli-rest-api
    +SIGNAL_PHONE_NUMBER=+14155551234
    +
    +# signal-cli-rest-api URL (default: http://localhost:8080)
    +SIGNAL_SERVICE_URL=http://localhost:8080
    +
    +# Phone number to send test messages to
    +SIGNAL_RECIPIENT=+14155559999
    diff --git a/examples/signal-local/01-health-check.ts b/examples/signal-local/01-health-check.ts
    new file mode 100644
    index 00000000..984142c2
    --- /dev/null
    +++ b/examples/signal-local/01-health-check.ts
    @@ -0,0 +1,40 @@
    +/**
    + * 01 — Health check & account verification
    + *
    + * Verifies connectivity to signal-cli-rest-api and that
    + * the configured phone number is registered.
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, SERVICE_URL } from "./env";
    +
    +async function main() {
    +  console.log(`📡 Connecting to signal-cli-rest-api at ${SERVICE_URL}`);
    +  console.log(`📱 Using phone number: ${PHONE_NUMBER}`);
    +
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  await bot.initialize();
    +
    +  console.log("\n✅ Health check passed!");
    +  console.log(`   Bot user ID: ${signal.botUserId}`);
    +  console.log(`   Bot username: ${signal.userName}`);
    +
    +  await bot.shutdown();
    +}
    +
    +main().catch((err) => {
    +  console.error("\n❌ Health check failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/02-send-edit-delete.ts b/examples/signal-local/02-send-edit-delete.ts
    new file mode 100644
    index 00000000..c6d98ac2
    --- /dev/null
    +++ b/examples/signal-local/02-send-edit-delete.ts
    @@ -0,0 +1,81 @@
    +/**
    + * 02 — Send, edit, delete messages
    + *
    + * Posts a message, edits it twice, fetches from cache, then deletes.
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, RECIPIENT, SERVICE_URL } from "./env";
    +
    +if (!RECIPIENT) {
    +  console.error("❌ SIGNAL_RECIPIENT is required for this example");
    +  process.exit(1);
    +}
    +
    +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  await bot.initialize();
    +
    +  const threadId = await signal.openDM(RECIPIENT!);
    +  console.log(`📨 Thread: ${threadId}\n`);
    +
    +  // Post
    +  console.log("1️⃣  Sending message...");
    +  const sent = await signal.postMessage(threadId, "Hello from the Signal adapter! 🚀");
    +  console.log(`   ID: ${sent.id}`);
    +
    +  await delay(2000);
    +
    +  // Edit
    +  console.log("2️⃣  Editing message...");
    +  await signal.editMessage(threadId, sent.id, "Hello from the Signal adapter! ✏️ (edited)");
    +  console.log("   Edited.");
    +
    +  await delay(2000);
    +
    +  // Edit again
    +  console.log("3️⃣  Editing again...");
    +  await signal.editMessage(threadId, sent.id, "Hello from the Signal adapter! ✏️✏️ (edited twice)");
    +  console.log("   Edited again.");
    +
    +  await delay(1000);
    +
    +  // Fetch from cache
    +  console.log("4️⃣  Fetching from cache...");
    +  const fetched = await signal.fetchMessage(threadId, sent.id);
    +  console.log(`   Cached text: "${fetched?.text}"`);
    +  console.log(`   Edited: ${fetched?.metadata.edited}`);
    +
    +  await delay(2000);
    +
    +  // Delete
    +  console.log("5️⃣  Deleting message...");
    +  await signal.deleteMessage(threadId, sent.id);
    +  console.log("   Deleted.");
    +
    +  // Verify deletion from cache
    +  const afterDelete = await signal.fetchMessage(threadId, sent.id);
    +  console.log(`   Still in cache: ${afterDelete !== null}`);
    +
    +  console.log("\n✅ Done!");
    +  await bot.shutdown();
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/03-reactions.ts b/examples/signal-local/03-reactions.ts
    new file mode 100644
    index 00000000..199f927d
    --- /dev/null
    +++ b/examples/signal-local/03-reactions.ts
    @@ -0,0 +1,68 @@
    +/**
    + * 03 — Reactions
    + *
    + * Posts a message, adds a reaction, waits, then removes it.
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, RECIPIENT, SERVICE_URL } from "./env";
    +
    +if (!RECIPIENT) {
    +  console.error("❌ SIGNAL_RECIPIENT is required for this example");
    +  process.exit(1);
    +}
    +
    +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  await bot.initialize();
    +
    +  const threadId = await signal.openDM(RECIPIENT!);
    +
    +  // Post a message to react to
    +  console.log("1️⃣  Sending message...");
    +  const sent = await signal.postMessage(threadId, "React to this! 🎯");
    +  console.log(`   ID: ${sent.id}`);
    +
    +  await delay(1500);
    +
    +  // Add thumbs up
    +  console.log("2️⃣  Adding 👍 reaction...");
    +  await signal.addReaction(threadId, sent.id, "thumbs_up");
    +  console.log("   Added.");
    +
    +  await delay(2000);
    +
    +  // Replace with fire (Signal allows only one reaction per user per message)
    +  console.log("3️⃣  Replacing with 🔥 reaction (Signal replaces previous)...");
    +  await signal.addReaction(threadId, sent.id, "fire");
    +  console.log("   Replaced 👍 → 🔥.");
    +
    +  await delay(2000);
    +
    +  // Remove fire (the current reaction)
    +  console.log("4️⃣  Removing 🔥 reaction...");
    +  await signal.removeReaction(threadId, sent.id, "fire");
    +  console.log("   Removed.");
    +
    +  console.log("\n✅ Done!");
    +  await bot.shutdown();
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/04-typing.ts b/examples/signal-local/04-typing.ts
    new file mode 100644
    index 00000000..fb0361b9
    --- /dev/null
    +++ b/examples/signal-local/04-typing.ts
    @@ -0,0 +1,51 @@
    +/**
    + * 04 — Typing indicator
    + *
    + * Shows a typing indicator, waits, then sends a message.
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, RECIPIENT, SERVICE_URL } from "./env";
    +
    +if (!RECIPIENT) {
    +  console.error("❌ SIGNAL_RECIPIENT is required for this example");
    +  process.exit(1);
    +}
    +
    +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  await bot.initialize();
    +
    +  const threadId = await signal.openDM(RECIPIENT!);
    +
    +  console.log("⌨️  Sending typing indicator...");
    +  await signal.startTyping(threadId);
    +
    +  console.log("   Waiting 3 seconds...");
    +  await delay(3000);
    +
    +  console.log("💬 Sending message...");
    +  await signal.postMessage(threadId, "I was typing for 3 seconds! ⌨️");
    +
    +  console.log("\n✅ Done!");
    +  await bot.shutdown();
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/05-group.ts b/examples/signal-local/05-group.ts
    new file mode 100644
    index 00000000..5fcbca72
    --- /dev/null
    +++ b/examples/signal-local/05-group.ts
    @@ -0,0 +1,70 @@
    +/**
    + * 05 — Group messaging
    + *
    + * Posts a message to a Signal group and fetches group metadata.
    + *
    + * Usage:
    + *   SIGNAL_GROUP_ID="group.abc123==" npx tsx 05-group.ts
    + *
    + * To find your group IDs:
    + *   curl http://localhost:8080/v1/groups/YOUR_PHONE_NUMBER
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { GROUP_ID, PHONE_NUMBER, SERVICE_URL } from "./env";
    +
    +if (!GROUP_ID) {
    +  console.error("❌ SIGNAL_GROUP_ID is required for this example");
    +  console.error('   Example: SIGNAL_GROUP_ID="group.abc123==" npx tsx 05-group.ts');
    +  console.error(`   List groups: curl ${SERVICE_URL}/v1/groups/${encodeURIComponent(PHONE_NUMBER!)}`);
    +  process.exit(1);
    +}
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  await bot.initialize();
    +
    +  // Fetch group info
    +  console.log(`📋 Fetching group info for: ${GROUP_ID}`);
    +  try {
    +    const info = await signal.fetchChannelInfo(GROUP_ID!);
    +    console.log(`   Name: ${info.name}`);
    +    console.log(`   Members: ${info.memberCount ?? "unknown"}`);
    +    console.log(`   Is DM: ${info.isDM}`);
    +  } catch (err) {
    +    console.warn(`   ⚠️  Could not fetch group info: ${(err as Error).message}`);
    +  }
    +
    +  // Post to group
    +  const threadId = signal.encodeThreadId({ chatId: GROUP_ID! });
    +  console.log(`\n📨 Posting to group thread: ${threadId}`);
    +  const sent = await signal.postMessage(threadId, "Hello group! 👋 This is a test from the Signal adapter.");
    +  console.log(`   Message ID: ${sent.id}`);
    +
    +  // Fetch messages from cache
    +  const result = await signal.fetchMessages(threadId, { limit: 5 });
    +  console.log(`\n📚 Cached messages in thread: ${result.messages.length}`);
    +  for (const msg of result.messages) {
    +    console.log(`   [${msg.author.userName}] ${msg.text}`);
    +  }
    +
    +  console.log("\n✅ Done!");
    +  await bot.shutdown();
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/06-poll-receive.ts b/examples/signal-local/06-poll-receive.ts
    new file mode 100644
    index 00000000..a0f078aa
    --- /dev/null
    +++ b/examples/signal-local/06-poll-receive.ts
    @@ -0,0 +1,72 @@
    +/**
    + * 06 — WebSocket receive loop
    + *
    + * Connects to signal-cli-rest-api via WebSocket (json-rpc mode)
    + * and prints incoming messages, reactions, and edits.
    + *
    + * Send messages from another Signal client to see them arrive.
    + * Ctrl+C to stop.
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, SERVICE_URL } from "./env";
    +import { connectSignalWebSocket } from "./ws";
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  // Log every incoming message
    +  bot.onNewMention(async (_thread, message) => {
    +    console.log(`\n📩 [mention] ${message.author.userName}: ${message.text}`);
    +  });
    +
    +  bot.onNewMessage(/./, async (thread, message) => {
    +    const label = message.metadata.edited ? "edited" : thread.isDM ? "DM" : "group";
    +    console.log(message)
    +    console.log(`\n📩 [${label}] ${message.author.userName}: ${message.text}`);
    +    if (message.attachments.length > 0) {
    +      for (const att of message.attachments) {
    +        console.log(`   📎 ${att.type}: ${att.name ?? att.mimeType ?? "unknown"} (${att.size ?? "?"} bytes)`);
    +      }
    +    }
    +  });
    +
    +  bot.onReaction(async (event) => {
    +    console.log(
    +      `\n${event.added ? "➕" : "➖"} Reaction: ${event.rawEmoji} by ${event.user.userName} on message ${event.messageId}`
    +    );
    +  });
    +
    +  await bot.initialize();
    +
    +  console.log("🔄 Listening via WebSocket (Ctrl+C to stop)...\n");
    +  const ws = connectSignalWebSocket(signal, SERVICE_URL, PHONE_NUMBER!);
    +
    +  // Wait for Ctrl+C
    +  await new Promise((resolve) => {
    +    process.on("SIGINT", () => {
    +      console.log("\n🛑 Stopping...");
    +      resolve();
    +    });
    +  });
    +
    +  ws.close();
    +  await bot.shutdown();
    +  console.log("✅ Stopped.");
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/07-webhook-server.ts b/examples/signal-local/07-webhook-server.ts
    new file mode 100644
    index 00000000..7ec9f225
    --- /dev/null
    +++ b/examples/signal-local/07-webhook-server.ts
    @@ -0,0 +1,105 @@
    +/**
    + * 07 — Webhook server (alternative to WebSocket)
    + *
    + * Starts an HTTP server on port 3000 that receives Signal webhooks.
    + * Use this if you prefer webhook mode over WebSocket.
    + *
    + * Configure signal-cli-rest-api with:
    + *   RECEIVE_WEBHOOK_URL=http://host.docker.internal:3000/webhook
    + *
    + * (Use host.docker.internal if signal-cli-rest-api runs in Docker)
    + */
    +import { createServer } from "node:http";
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, SERVICE_URL } from "./env";
    +
    +const PORT = Number(process.env.PORT ?? 3000);
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +  });
    +
    +  const bot = new Chat({
    +    userName: "test-bot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  // Log incoming messages
    +  bot.onNewMessage(/./, async (thread, message) => {
    +    console.log(
    +      `📩 [${thread.isDM ? "DM" : "group"}] ${message.author.userName}: ${message.text}`
    +    );
    +  });
    +
    +  bot.onReaction(async (event) => {
    +    console.log(
    +      `${event.added ? "➕" : "➖"} Reaction: ${event.rawEmoji} by ${event.user.userName}`
    +    );
    +  });
    +
    +  await bot.initialize();
    +
    +  // Create HTTP server
    +  const server = createServer(async (req, res) => {
    +    if (req.method === "POST" && req.url === "/webhook") {
    +      const chunks: Buffer[] = [];
    +      for await (const chunk of req) {
    +        chunks.push(chunk as Buffer);
    +      }
    +      const body = Buffer.concat(chunks).toString();
    +
    +      // Convert to a Web Request for the adapter
    +      const webRequest = new Request(`http://localhost:${PORT}/webhook`, {
    +        method: "POST",
    +        headers: Object.fromEntries(
    +          Object.entries(req.headers)
    +            .filter((entry): entry is [string, string] => typeof entry[1] === "string")
    +        ),
    +        body,
    +      });
    +
    +      const response = await signal.handleWebhook(webRequest);
    +      res.writeHead(response.status);
    +      res.end(await response.text());
    +      return;
    +    }
    +
    +    if (req.method === "GET" && req.url === "/health") {
    +      res.writeHead(200);
    +      res.end("ok");
    +      return;
    +    }
    +
    +    res.writeHead(404);
    +    res.end("Not found");
    +  });
    +
    +  server.listen(PORT, () => {
    +    console.log(`\n🌐 Webhook server listening on http://localhost:${PORT}/webhook`);
    +    console.log("   Configure signal-cli-rest-api with:");
    +    console.log(`   RECEIVE_WEBHOOK_URL=http://host.docker.internal:${PORT}/webhook`);
    +    console.log("\n   Ctrl+C to stop.\n");
    +  });
    +
    +  await new Promise((resolve) => {
    +    process.on("SIGINT", () => {
    +      console.log("\n🛑 Stopping...");
    +      server.close();
    +      resolve();
    +    });
    +  });
    +
    +  await bot.shutdown();
    +  console.log("✅ Stopped.");
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/08-echo-bot.ts b/examples/signal-local/08-echo-bot.ts
    new file mode 100644
    index 00000000..e4c1d748
    --- /dev/null
    +++ b/examples/signal-local/08-echo-bot.ts
    @@ -0,0 +1,121 @@
    +/**
    + * 08 — Echo bot (WebSocket)
    + *
    + * A simple bot using WebSocket receive that:
    + * - Echoes DM messages back
    + * - Echoes group messages when @mentioned
    + * - Reacts to incoming reactions with 🤝
    + * - Handles edits by showing the diff
    + *
    + * Ctrl+C to stop.
    + */
    +import { createSignalAdapter } from "@chat-adapter/signal";
    +import { createMemoryState } from "@chat-adapter/state-memory";
    +import { Chat } from "chat";
    +import { PHONE_NUMBER, SERVICE_URL } from "./env";
    +import { connectSignalWebSocket } from "./ws";
    +
    +async function main() {
    +  const signal = createSignalAdapter({
    +    phoneNumber: PHONE_NUMBER!,
    +    baseUrl: SERVICE_URL,
    +    userName: "echobot",
    +  });
    +
    +  const bot = new Chat({
    +    userName: "echobot",
    +    adapters: { signal },
    +    state: createMemoryState(),
    +    logger: "info",
    +  });
    +
    +  // Subscribe to threads on first mention
    +  bot.onNewMention(async (thread, message) => {
    +    await thread.subscribe();
    +    console.log(`🔔 Subscribed to thread: ${thread.id}`);
    +
    +    await thread.startTyping();
    +    await thread.post(`👋 Hi ${message.author.userName}! I'm now listening here. Send me messages and I'll echo them back.`);
    +  });
    +
    +  // Echo messages in subscribed threads (DMs + groups where mentioned)
    +  bot.onSubscribedMessage(async (thread, message) => {
    +    // Skip bot's own messages
    +    if (message.author.isMe) {
    +      return;
    +    }
    +
    +    // For non-DM threads, only respond if mentioned or it's Signal/Telegram
    +    if (!(thread.isDM || thread.adapter.name === "signal" || message.isMention)) {
    +      return;
    +    }
    +
    +    const prefix = message.metadata.edited ? "✏️ [edited]" : "🔊";
    +
    +    // Echo with typing indicator
    +    await thread.startTyping();
    +
    +    if (message.attachments.length > 0) {
    +      const attList = message.attachments
    +        .map((a) => `${a.type}: ${a.name ?? a.mimeType ?? "file"}`)
    +        .join(", ");
    +      await thread.post(`${prefix} You said: "${message.text}"\n📎 Attachments: ${attList}`);
    +    } else {
    +      await thread.post(`${prefix} You said: "${message.text}"`);
    +    }
    +
    +    // Show cached message count
    +    const cached = await signal.fetchMessages(thread.id, { limit: 100 });
    +    console.log(`   📚 ${cached.messages.length} messages cached for this thread`);
    +  });
    +
    +  // React back to reactions
    +  bot.onReaction(async (event) => {
    +    if (!event.added) {
    +      return;
    +    }
    +
    +    console.log(`${event.rawEmoji} from ${event.user.userName}`);
    +
    +    try {
    +      await event.adapter.addReaction(event.threadId, event.messageId, "🤝");
    +    } catch (err) {
    +      console.warn(`   ⚠️  Could not add reaction: ${(err as Error).message}`);
    +    }
    +  });
    +
    +  // Auto-subscribe and echo on any new message (DMs and groups)
    +  bot.onNewMessage(/./, async (thread, message) => {
    +    if (message.author.isMe) {
    +      return;
    +    }
    +
    +    await thread.subscribe();
    +    const label = thread.isDM ? "DM" : "group";
    +    await thread.post(`👋 Echo bot here (${label})! You said: "${message.text}"`);
    +  });
    +
    +  await bot.initialize();
    +
    +  console.log("🤖 Echo bot started! Listening via WebSocket...");
    +  console.log("   Send a message from your Signal app to test.");
    +  console.log("   Ctrl+C to stop.\n");
    +
    +  const ws = connectSignalWebSocket(signal, SERVICE_URL, PHONE_NUMBER!);
    +
    +  await new Promise((resolve) => {
    +    process.on("SIGINT", () => {
    +      console.log("\n🛑 Shutting down...");
    +      resolve();
    +    });
    +  });
    +
    +  ws.close();
    +  await bot.shutdown();
    +  console.log("✅ Stopped.");
    +}
    +
    +main().catch((err) => {
    +  console.error("❌ Failed:", err.message);
    +  process.exit(1);
    +});
    diff --git a/examples/signal-local/README.md b/examples/signal-local/README.md
    new file mode 100644
    index 00000000..bf7bc4b5
    --- /dev/null
    +++ b/examples/signal-local/README.md
    @@ -0,0 +1,109 @@
    +# Signal Adapter — Local Testing Examples
    +
    +Standalone scripts for testing the Signal adapter against a local `signal-cli-rest-api` instance running in **json-rpc mode** (WebSocket).
    +
    +## Prerequisites
    +
    +1. **signal-cli-rest-api** running locally in json-rpc mode (default: `http://localhost:8080`)
    +
    +   ```bash
    +   docker run -d --name signal-api \
    +     -p 8080:8080 \
    +     -v $HOME/.local/share/signal-cli:/home/.local/share/signal-cli \
    +     -e MODE=json-rpc \
    +     bbernhard/signal-cli-rest-api:latest
    +   ```
    +
    +2. A registered/linked phone number in signal-cli-rest-api.
    +
    +3. Build the monorepo:
    +
    +   ```bash
    +   pnpm install && pnpm build
    +   ```
    +
    +## Environment
    +
    +Copy `.env.example` to `.env` and fill in values:
    +
    +```bash
    +cp .env.example .env
    +```
    +
    +| Variable | Required | Description |
    +|----------|----------|-------------|
    +| `SIGNAL_PHONE_NUMBER` | Yes | Your bot's registered phone number (e.g. `+14155551234`) |
    +| `SIGNAL_SERVICE_URL` | No | signal-cli-rest-api URL (default: `http://localhost:8080`) |
    +| `SIGNAL_RECIPIENT` | Yes* | Phone number to send test messages to (*scripts 02-05) |
    +
    +## Scripts
    +
    +Run from this directory with `npx tsx` or use the `pnpm` shortcuts:
    +
    +### 1. Health check & account verification
    +
    +```bash
    +npx tsx 01-health-check.ts     # or: pnpm health
    +```
    +
    +Verifies connectivity to signal-cli-rest-api and that your phone number is registered.
    +
    +### 2. Send, edit, delete messages
    +
    +```bash
    +npx tsx 02-send-edit-delete.ts  # or: pnpm send
    +```
    +
    +Posts a message, edits it twice, fetches it from cache, then deletes it.
    +
    +### 3. Reactions
    +
    +```bash
    +npx tsx 03-reactions.ts         # or: pnpm react
    +```
    +
    +Posts a message and adds/removes reactions.
    +
    +### 4. Typing indicator
    +
    +```bash
    +npx tsx 04-typing.ts            # or: pnpm typing
    +```
    +
    +Sends a typing indicator, waits 3s, then posts a message.
    +
    +### 5. Group messaging
    +
    +```bash
    +SIGNAL_GROUP_ID="group.abc123==" npx tsx 05-group.ts  # or: pnpm group
    +```
    +
    +Posts a message to a Signal group, fetches group metadata. List your groups with:
    +
    +```bash
    +curl http://localhost:8080/v1/groups/YOUR_PHONE_NUMBER
    +```
    +
    +### 6. WebSocket receive (json-rpc mode)
    +
    +```bash
    +npx tsx 06-poll-receive.ts      # or: pnpm poll
    +```
    +
    +Connects via WebSocket to `ws://localhost:8080/v1/receive/{number}` and prints incoming messages, reactions, and edits. Send messages from your Signal app to see them arrive. Ctrl+C to stop.
    +
    +### 7. Webhook server (alternative)
    +
    +```bash
    +npx tsx 07-webhook-server.ts    # or: pnpm webhook
    +```
    +
    +Starts an HTTP server on port 3000 that receives Signal webhooks. Use this if you prefer webhook mode. Configure `RECEIVE_WEBHOOK_URL=http://host.docker.internal:3000/webhook` in signal-cli-rest-api.
    +
    +### 8. Echo bot (WebSocket)
    +
    +```bash
    +npx tsx 08-echo-bot.ts          # or: pnpm bot
    +```
    +
    +A simple echo bot using WebSocket receive. Replies to DMs, echoes messages in groups when mentioned, reacts to incoming reactions with 🤝.
    diff --git a/examples/signal-local/env.ts b/examples/signal-local/env.ts
    new file mode 100644
    index 00000000..9a88b062
    --- /dev/null
    +++ b/examples/signal-local/env.ts
    @@ -0,0 +1,14 @@
    +import { config } from "dotenv";
    +
    +config();
    +
    +export const PHONE_NUMBER = process.env.SIGNAL_PHONE_NUMBER;
    +export const SERVICE_URL =
    +  process.env.SIGNAL_SERVICE_URL ?? "http://localhost:8080";
    +export const RECIPIENT = process.env.SIGNAL_RECIPIENT;
    +export const GROUP_ID = process.env.SIGNAL_GROUP_ID;
    +
    +if (!PHONE_NUMBER) {
    +  console.error("❌ SIGNAL_PHONE_NUMBER is required. See .env.example");
    +  process.exit(1);
    +}
    diff --git a/examples/signal-local/package.json b/examples/signal-local/package.json
    new file mode 100644
    index 00000000..6d1e57e5
    --- /dev/null
    +++ b/examples/signal-local/package.json
    @@ -0,0 +1,25 @@
    +{
    +  "name": "@chat-example/signal-local",
    +  "version": "0.0.0",
    +  "private": true,
    +  "type": "module",
    +  "scripts": {
    +    "health": "tsx 01-health-check.ts",
    +    "send": "tsx 02-send-edit-delete.ts",
    +    "react": "tsx 03-reactions.ts",
    +    "typing": "tsx 04-typing.ts",
    +    "group": "tsx 05-group.ts",
    +    "poll": "tsx 06-poll-receive.ts",
    +    "webhook": "tsx 07-webhook-server.ts",
    +    "bot": "tsx 08-echo-bot.ts"
    +  },
    +  "dependencies": {
    +    "@chat-adapter/signal": "workspace:*",
    +    "@chat-adapter/state-memory": "workspace:*",
    +    "chat": "workspace:*",
    +    "dotenv": "^16.4.7"
    +  },
    +  "devDependencies": {
    +    "tsx": "^4.19.4"
    +  }
    +}
    diff --git a/examples/signal-local/tsconfig.json b/examples/signal-local/tsconfig.json
    new file mode 100644
    index 00000000..219302d7
    --- /dev/null
    +++ b/examples/signal-local/tsconfig.json
    @@ -0,0 +1,11 @@
    +{
    +  "compilerOptions": {
    +    "target": "ES2022",
    +    "module": "ESNext",
    +    "moduleResolution": "bundler",
    +    "esModuleInterop": true,
    +    "strict": true,
    +    "skipLibCheck": true
    +  },
    +  "include": ["*.ts"]
    +}
    diff --git a/examples/signal-local/ws.ts b/examples/signal-local/ws.ts
    new file mode 100644
    index 00000000..2c563c31
    --- /dev/null
    +++ b/examples/signal-local/ws.ts
    @@ -0,0 +1,54 @@
    +/**
    + * WebSocket helper for signal-cli-rest-api json-rpc mode.
    + *
    + * Connects to ws:///v1/receive/ and feeds incoming
    + * JSON-RPC messages through the adapter's handleWebhook method.
    + */
    +import type { SignalAdapter } from "@chat-adapter/signal";
    +
    +export function connectSignalWebSocket(
    +  signal: SignalAdapter,
    +  serviceUrl: string,
    +  phoneNumber: string
    +): { close: () => void } {
    +  const wsUrl = serviceUrl
    +    .replace(/^http:/, "ws:")
    +    .replace(/^https:/, "wss:")
    +    .replace(/\/+$/, "");
    +
    +  const endpoint = `${wsUrl}/v1/receive/${encodeURIComponent(phoneNumber)}`;
    +  console.log(`🔌 Connecting WebSocket: ${endpoint}`);
    +
    +  const ws = new WebSocket(endpoint);
    +
    +  ws.addEventListener("open", () => {
    +    console.log("🟢 WebSocket connected\n");
    +  });
    +
    +  ws.addEventListener("message", (event) => {
    +    const body = typeof event.data === "string" ? event.data : String(event.data);
    +
    +    // Feed the JSON-RPC message through handleWebhook as a synthetic Request
    +    const request = new Request("http://localhost/ws-receive", {
    +      method: "POST",
    +      headers: { "content-type": "application/json" },
    +      body,
    +    });
    +
    +    signal.handleWebhook(request).catch((err) => {
    +      console.error("⚠️  handleWebhook error:", (err as Error).message);
    +    });
    +  });
    +
    +  ws.addEventListener("error", (event) => {
    +    console.error("🔴 WebSocket error:", event);
    +  });
    +
    +  ws.addEventListener("close", (event) => {
    +    console.log(`🔴 WebSocket closed (code=${event.code}, reason=${event.reason})`);
    +  });
    +
    +  return {
    +    close: () => ws.close(),
    +  };
    +}
    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/index.test.ts b/packages/adapter-signal/src/index.test.ts
    new file mode 100644
    index 00000000..02540060
    --- /dev/null
    +++ b/packages/adapter-signal/src/index.test.ts
    @@ -0,0 +1,761 @@
    +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);
    +  });
    +});
    diff --git a/packages/adapter-signal/src/index.ts b/packages/adapter-signal/src/index.ts
    new file mode 100644
    index 00000000..6842aac7
    --- /dev/null
    +++ b/packages/adapter-signal/src/index.ts
    @@ -0,0 +1,2217 @@
    +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 { 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 SIGNAL_PHONE_NUMBER_PATTERN = /^\+[1-9]\d{6,14}$/;
    +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;
    +}
    +
    +export 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 Map<
    +    string,
    +    Message[]
    +  >();
    +  private readonly identifierAliases = new Map();
    +
    +  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 = this.normalizePositiveInteger(
    +      options.intervalMs,
    +      DEFAULT_POLLING_INTERVAL_MS,
    +      MIN_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.cacheMessage(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.get(resultingThreadId) ?? []).find(
    +        (cachedMessage) => cachedMessage.id === messageId
    +      ) ??
    +      this.findCachedMessageByTimestamp(
    +        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.cacheMessage(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.deleteCachedMessage(messageId);
    +    this.deleteCachedMessagesByTimestamp(
    +      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.get(resolvedThreadId) ?? [])].sort(
    +      (a, b) => this.compareMessages(a, b)
    +    );
    +
    +    return this.paginateMessages(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)
    +    );
    +
    +    const threadMessages = this.messageCache.get(normalizedThreadId) ?? [];
    +    const directMatch = threadMessages.find(
    +      (message) => message.id === messageId
    +    );
    +
    +    return (
    +      directMatch ??
    +      this.findCachedMessageByTimestamp(
    +        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.canonicalizeIdentifier(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.canonicalizeIdentifier(normalizedChatId),
    +    };
    +  }
    +
    +  parseMessage(raw: SignalRawMessage): Message {
    +    const message = this.messageFromRaw(raw);
    +    this.cacheMessage(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.cacheMessage(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.cacheMessage(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.deleteCachedMessagesByTimestamp(threadId, remoteDelete.timestamp);
    +  }
    +
    +  private handleIncomingSyncSentMessage(
    +    update: SignalUpdate,
    +    sentMessage: SignalSyncSentMessage,
    +    options?: WebhookOptions
    +  ): void {
    +    const message = this.createMessageFromSyncSentMessage(update, sentMessage);
    +    this.cacheMessage(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.findCachedMessageByTimestamp(
    +      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.findCachedMessageByTimestamp(threadId, messageIdTimestamp) ??
    +        this.findCachedMessageByTimestampAcrossThreads(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.registerIdentifierAliases(
    +      envelope.sourceNumber ?? undefined,
    +      envelope.sourceUuid,
    +      envelope.source
    +    );
    +  }
    +
    +  private resolveReactionTargetAuthor(
    +    reaction: SignalReaction
    +  ): string | undefined {
    +    return this.registerIdentifierAliases(
    +      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 threadMessages = this.messageCache.get(threadId) ?? [];
    +    const fromCache = threadMessages.find(
    +      (message) => message.id === messageId
    +    );
    +    if (fromCache) {
    +      const cachedDecoded = this.decodeMessageIdRaw(fromCache.id);
    +      if (cachedDecoded.author) {
    +        return {
    +          author: cachedDecoded.author,
    +          timestamp: cachedDecoded.timestamp,
    +        };
    +      }
    +    }
    +
    +    const fromTimestamp = this.findCachedMessageByTimestamp(
    +      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.canonicalizeIdentifier(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.canonicalizeIdentifier(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 messageTimestamp(messageId: string): number {
    +    try {
    +      return this.decodeMessageIdRaw(messageId).timestamp;
    +    } catch {
    +      return 0;
    +    }
    +  }
    +
    +  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.canonicalizeIdentifier(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.canonicalizeIdentifier(normalized);
    +  }
    +
    +  private toSignalUserId(userId: string): string {
    +    const normalized = this.canonicalizeIdentifier(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 canonicalizeIdentifier(value: string): string {
    +    const normalized = this.normalizeSignalIdentifier(value);
    +    if (!normalized) {
    +      return normalized;
    +    }
    +
    +    const visited = new Set();
    +    let current = normalized;
    +
    +    while (!visited.has(current)) {
    +      visited.add(current);
    +      const aliased = this.identifierAliases.get(current);
    +      if (!aliased || aliased === current) {
    +        return current;
    +      }
    +      current = aliased;
    +    }
    +
    +    return current;
    +  }
    +
    +  private registerIdentifierAliases(
    +    ...identifiers: Array
    +  ): string | undefined {
    +    const normalized = identifiers
    +      .map((identifier) =>
    +        identifier ? this.normalizeSignalIdentifier(identifier) : undefined
    +      )
    +      .filter((identifier): identifier is string => Boolean(identifier));
    +
    +    if (normalized.length === 0) {
    +      return undefined;
    +    }
    +
    +    const canonicalCandidate =
    +      normalized.find((identifier) => this.isPhoneNumber(identifier)) ??
    +      normalized[0];
    +
    +    if (!canonicalCandidate) {
    +      return undefined;
    +    }
    +
    +    const canonical = this.canonicalizeIdentifier(canonicalCandidate);
    +
    +    for (const identifier of normalized) {
    +      this.identifierAliases.set(identifier, canonical);
    +    }
    +
    +    return canonical;
    +  }
    +
    +  private isPhoneNumber(value: string): boolean {
    +    return SIGNAL_PHONE_NUMBER_PATTERN.test(value);
    +  }
    +
    +  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 compareMessages(
    +    a: Message,
    +    b: Message
    +  ): number {
    +    const timestampDifference =
    +      a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime();
    +    if (timestampDifference !== 0) {
    +      return timestampDifference;
    +    }
    +
    +    return this.messageTimestamp(a.id) - this.messageTimestamp(b.id);
    +  }
    +
    +  private cacheMessage(message: Message): void {
    +    const existing = this.messageCache.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.messageCache.set(message.threadId, existing);
    +  }
    +
    +  private findCachedMessageByTimestamp(
    +    threadId: string,
    +    timestamp: number
    +  ): Message | undefined {
    +    const messages = this.messageCache.get(threadId) ?? [];
    +    return messages.find(
    +      (message) => this.messageTimestamp(message.id) === timestamp
    +    );
    +  }
    +
    +  private findCachedMessageByTimestampAcrossThreads(
    +    timestamp: number
    +  ): Message | undefined {
    +    for (const messages of this.messageCache.values()) {
    +      const matchedMessage = messages.find(
    +        (message) => this.messageTimestamp(message.id) === timestamp
    +      );
    +      if (matchedMessage) {
    +        return matchedMessage;
    +      }
    +    }
    +
    +    return undefined;
    +  }
    +
    +  private deleteCachedMessage(messageId: string): void {
    +    for (const [threadId, messages] of this.messageCache.entries()) {
    +      const filtered = messages.filter((message) => message.id !== messageId);
    +      if (filtered.length === 0) {
    +        this.messageCache.delete(threadId);
    +      } else if (filtered.length !== messages.length) {
    +        this.messageCache.set(threadId, filtered);
    +      }
    +    }
    +  }
    +
    +  private deleteCachedMessagesByTimestamp(
    +    threadId: string,
    +    timestamp: number
    +  ): void {
    +    const messages = this.messageCache.get(threadId);
    +    if (!messages) {
    +      return;
    +    }
    +
    +    const filtered = messages.filter(
    +      (message) => this.messageTimestamp(message.id) !== timestamp
    +    );
    +
    +    if (filtered.length === 0) {
    +      this.messageCache.delete(threadId);
    +      return;
    +    }
    +
    +    if (filtered.length !== messages.length) {
    +      this.messageCache.set(threadId, filtered);
    +    }
    +  }
    +
    +  private paginateMessages(
    +    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 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,
    +    minimum = 1
    +  ): number {
    +    if (typeof value !== "number" || !Number.isFinite(value)) {
    +      return fallback;
    +    }
    +
    +    return Math.max(minimum, 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 c02fe9d9..8e3478be 100644
    --- a/packages/chat/src/chat.test.ts
    +++ b/packages/chat/src/chat.test.ts
    @@ -180,6 +180,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.set).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);
    @@ -1105,6 +1143,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 27b3bcec..762f68ce 100644
    --- a/packages/chat/src/chat.ts
    +++ b/packages/chat/src/chat.ts
    @@ -47,6 +47,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
    @@ -1306,6 +1308,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
    @@ -1391,6 +1394,22 @@ export class Chat<
        * Infer which adapter to use based on the userId format.
        */
       private inferAdapterFromUserId(userId: string): Adapter {
    +    // 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;
    +      }
    +    }
    +
         // Google Chat: users/123456789
         if (userId.startsWith("users/")) {
           const adapter = this.adapters.get("gchat");
    @@ -1424,7 +1443,7 @@ export class Chat<
         }
     
         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: Signal (signal:... or +E.164), Slack (U...), Teams (29:...), Google Chat (users/...), or Discord (numeric snowflake).`,
           "UNKNOWN_USER_ID_FORMAT"
         );
       }
    @@ -1467,7 +1486,11 @@ export class Chat<
     
         // Deduplicate messages - 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 alreadyProcessed = await this._stateAdapter.get(dedupeKey);
         if (alreadyProcessed) {
           this.logger.debug("Skipping duplicate message", {
    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 44b14249..b7ef48c6 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
    @@ -236,6 +239,25 @@ importers:
             specifier: ^5.7.2
             version: 5.9.3
     
    +  examples/signal-local:
    +    dependencies:
    +      '@chat-adapter/signal':
    +        specifier: workspace:*
    +        version: link:../../packages/adapter-signal
    +      '@chat-adapter/state-memory':
    +        specifier: workspace:*
    +        version: link:../../packages/state-memory
    +      chat:
    +        specifier: workspace:*
    +        version: link:../../packages/chat
    +      dotenv:
    +        specifier: ^16.4.7
    +        version: 16.6.1
    +    devDependencies:
    +      tsx:
    +        specifier: ^4.19.4
    +        version: 4.21.0
    +
       packages/adapter-discord:
         dependencies:
           '@chat-adapter/shared':
    @@ -367,6 +389,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':
    @@ -3570,6 +3614,10 @@ packages:
       domutils@3.2.2:
         resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
     
    +  dotenv@16.6.1:
    +    resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
    +    engines: {node: '>=12'}
    +
       dotenv@17.2.3:
         resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
         engines: {node: '>=12'}
    @@ -8877,6 +8925,8 @@ snapshots:
           domelementtype: 2.3.0
           domhandler: 5.0.3
     
    +  dotenv@16.6.1: {}
    +
       dotenv@17.2.3: {}
     
       dunder-proto@1.0.1:
    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"
       ],
    
    From 5696e4a40c964b08788c254e1ad0ee87c6ecea71 Mon Sep 17 00:00:00 2001
    From: Hayden Bleasel 
    Date: Fri, 6 Mar 2026 17:00:24 -0800
    Subject: [PATCH 2/7] remove examples/signal-local directory
    
    The nextjs-chat integration is sufficient for demonstrating Signal adapter
    usage. No other adapter has a dedicated example directory.
    
    Co-Authored-By: Claude Opus 4.6 
    ---
     examples/signal-local/.env.example           |   8 --
     examples/signal-local/01-health-check.ts     |  40 ------
     examples/signal-local/02-send-edit-delete.ts |  81 -------------
     examples/signal-local/03-reactions.ts        |  68 -----------
     examples/signal-local/04-typing.ts           |  51 --------
     examples/signal-local/05-group.ts            |  70 -----------
     examples/signal-local/06-poll-receive.ts     |  72 -----------
     examples/signal-local/07-webhook-server.ts   | 105 ----------------
     examples/signal-local/08-echo-bot.ts         | 121 -------------------
     examples/signal-local/README.md              | 109 -----------------
     examples/signal-local/env.ts                 |  14 ---
     examples/signal-local/package.json           |  25 ----
     examples/signal-local/tsconfig.json          |  11 --
     examples/signal-local/ws.ts                  |  54 ---------
     pnpm-lock.yaml                               |  25 ----
     15 files changed, 854 deletions(-)
     delete mode 100644 examples/signal-local/.env.example
     delete mode 100644 examples/signal-local/01-health-check.ts
     delete mode 100644 examples/signal-local/02-send-edit-delete.ts
     delete mode 100644 examples/signal-local/03-reactions.ts
     delete mode 100644 examples/signal-local/04-typing.ts
     delete mode 100644 examples/signal-local/05-group.ts
     delete mode 100644 examples/signal-local/06-poll-receive.ts
     delete mode 100644 examples/signal-local/07-webhook-server.ts
     delete mode 100644 examples/signal-local/08-echo-bot.ts
     delete mode 100644 examples/signal-local/README.md
     delete mode 100644 examples/signal-local/env.ts
     delete mode 100644 examples/signal-local/package.json
     delete mode 100644 examples/signal-local/tsconfig.json
     delete mode 100644 examples/signal-local/ws.ts
    
    diff --git a/examples/signal-local/.env.example b/examples/signal-local/.env.example
    deleted file mode 100644
    index dbf0e183..00000000
    --- a/examples/signal-local/.env.example
    +++ /dev/null
    @@ -1,8 +0,0 @@
    -# Your bot's phone number registered in signal-cli-rest-api
    -SIGNAL_PHONE_NUMBER=+14155551234
    -
    -# signal-cli-rest-api URL (default: http://localhost:8080)
    -SIGNAL_SERVICE_URL=http://localhost:8080
    -
    -# Phone number to send test messages to
    -SIGNAL_RECIPIENT=+14155559999
    diff --git a/examples/signal-local/01-health-check.ts b/examples/signal-local/01-health-check.ts
    deleted file mode 100644
    index 984142c2..00000000
    --- a/examples/signal-local/01-health-check.ts
    +++ /dev/null
    @@ -1,40 +0,0 @@
    -/**
    - * 01 — Health check & account verification
    - *
    - * Verifies connectivity to signal-cli-rest-api and that
    - * the configured phone number is registered.
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, SERVICE_URL } from "./env";
    -
    -async function main() {
    -  console.log(`📡 Connecting to signal-cli-rest-api at ${SERVICE_URL}`);
    -  console.log(`📱 Using phone number: ${PHONE_NUMBER}`);
    -
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  await bot.initialize();
    -
    -  console.log("\n✅ Health check passed!");
    -  console.log(`   Bot user ID: ${signal.botUserId}`);
    -  console.log(`   Bot username: ${signal.userName}`);
    -
    -  await bot.shutdown();
    -}
    -
    -main().catch((err) => {
    -  console.error("\n❌ Health check failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/02-send-edit-delete.ts b/examples/signal-local/02-send-edit-delete.ts
    deleted file mode 100644
    index c6d98ac2..00000000
    --- a/examples/signal-local/02-send-edit-delete.ts
    +++ /dev/null
    @@ -1,81 +0,0 @@
    -/**
    - * 02 — Send, edit, delete messages
    - *
    - * Posts a message, edits it twice, fetches from cache, then deletes.
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, RECIPIENT, SERVICE_URL } from "./env";
    -
    -if (!RECIPIENT) {
    -  console.error("❌ SIGNAL_RECIPIENT is required for this example");
    -  process.exit(1);
    -}
    -
    -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  await bot.initialize();
    -
    -  const threadId = await signal.openDM(RECIPIENT!);
    -  console.log(`📨 Thread: ${threadId}\n`);
    -
    -  // Post
    -  console.log("1️⃣  Sending message...");
    -  const sent = await signal.postMessage(threadId, "Hello from the Signal adapter! 🚀");
    -  console.log(`   ID: ${sent.id}`);
    -
    -  await delay(2000);
    -
    -  // Edit
    -  console.log("2️⃣  Editing message...");
    -  await signal.editMessage(threadId, sent.id, "Hello from the Signal adapter! ✏️ (edited)");
    -  console.log("   Edited.");
    -
    -  await delay(2000);
    -
    -  // Edit again
    -  console.log("3️⃣  Editing again...");
    -  await signal.editMessage(threadId, sent.id, "Hello from the Signal adapter! ✏️✏️ (edited twice)");
    -  console.log("   Edited again.");
    -
    -  await delay(1000);
    -
    -  // Fetch from cache
    -  console.log("4️⃣  Fetching from cache...");
    -  const fetched = await signal.fetchMessage(threadId, sent.id);
    -  console.log(`   Cached text: "${fetched?.text}"`);
    -  console.log(`   Edited: ${fetched?.metadata.edited}`);
    -
    -  await delay(2000);
    -
    -  // Delete
    -  console.log("5️⃣  Deleting message...");
    -  await signal.deleteMessage(threadId, sent.id);
    -  console.log("   Deleted.");
    -
    -  // Verify deletion from cache
    -  const afterDelete = await signal.fetchMessage(threadId, sent.id);
    -  console.log(`   Still in cache: ${afterDelete !== null}`);
    -
    -  console.log("\n✅ Done!");
    -  await bot.shutdown();
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/03-reactions.ts b/examples/signal-local/03-reactions.ts
    deleted file mode 100644
    index 199f927d..00000000
    --- a/examples/signal-local/03-reactions.ts
    +++ /dev/null
    @@ -1,68 +0,0 @@
    -/**
    - * 03 — Reactions
    - *
    - * Posts a message, adds a reaction, waits, then removes it.
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, RECIPIENT, SERVICE_URL } from "./env";
    -
    -if (!RECIPIENT) {
    -  console.error("❌ SIGNAL_RECIPIENT is required for this example");
    -  process.exit(1);
    -}
    -
    -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  await bot.initialize();
    -
    -  const threadId = await signal.openDM(RECIPIENT!);
    -
    -  // Post a message to react to
    -  console.log("1️⃣  Sending message...");
    -  const sent = await signal.postMessage(threadId, "React to this! 🎯");
    -  console.log(`   ID: ${sent.id}`);
    -
    -  await delay(1500);
    -
    -  // Add thumbs up
    -  console.log("2️⃣  Adding 👍 reaction...");
    -  await signal.addReaction(threadId, sent.id, "thumbs_up");
    -  console.log("   Added.");
    -
    -  await delay(2000);
    -
    -  // Replace with fire (Signal allows only one reaction per user per message)
    -  console.log("3️⃣  Replacing with 🔥 reaction (Signal replaces previous)...");
    -  await signal.addReaction(threadId, sent.id, "fire");
    -  console.log("   Replaced 👍 → 🔥.");
    -
    -  await delay(2000);
    -
    -  // Remove fire (the current reaction)
    -  console.log("4️⃣  Removing 🔥 reaction...");
    -  await signal.removeReaction(threadId, sent.id, "fire");
    -  console.log("   Removed.");
    -
    -  console.log("\n✅ Done!");
    -  await bot.shutdown();
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/04-typing.ts b/examples/signal-local/04-typing.ts
    deleted file mode 100644
    index fb0361b9..00000000
    --- a/examples/signal-local/04-typing.ts
    +++ /dev/null
    @@ -1,51 +0,0 @@
    -/**
    - * 04 — Typing indicator
    - *
    - * Shows a typing indicator, waits, then sends a message.
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, RECIPIENT, SERVICE_URL } from "./env";
    -
    -if (!RECIPIENT) {
    -  console.error("❌ SIGNAL_RECIPIENT is required for this example");
    -  process.exit(1);
    -}
    -
    -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  await bot.initialize();
    -
    -  const threadId = await signal.openDM(RECIPIENT!);
    -
    -  console.log("⌨️  Sending typing indicator...");
    -  await signal.startTyping(threadId);
    -
    -  console.log("   Waiting 3 seconds...");
    -  await delay(3000);
    -
    -  console.log("💬 Sending message...");
    -  await signal.postMessage(threadId, "I was typing for 3 seconds! ⌨️");
    -
    -  console.log("\n✅ Done!");
    -  await bot.shutdown();
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/05-group.ts b/examples/signal-local/05-group.ts
    deleted file mode 100644
    index 5fcbca72..00000000
    --- a/examples/signal-local/05-group.ts
    +++ /dev/null
    @@ -1,70 +0,0 @@
    -/**
    - * 05 — Group messaging
    - *
    - * Posts a message to a Signal group and fetches group metadata.
    - *
    - * Usage:
    - *   SIGNAL_GROUP_ID="group.abc123==" npx tsx 05-group.ts
    - *
    - * To find your group IDs:
    - *   curl http://localhost:8080/v1/groups/YOUR_PHONE_NUMBER
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { GROUP_ID, PHONE_NUMBER, SERVICE_URL } from "./env";
    -
    -if (!GROUP_ID) {
    -  console.error("❌ SIGNAL_GROUP_ID is required for this example");
    -  console.error('   Example: SIGNAL_GROUP_ID="group.abc123==" npx tsx 05-group.ts');
    -  console.error(`   List groups: curl ${SERVICE_URL}/v1/groups/${encodeURIComponent(PHONE_NUMBER!)}`);
    -  process.exit(1);
    -}
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  await bot.initialize();
    -
    -  // Fetch group info
    -  console.log(`📋 Fetching group info for: ${GROUP_ID}`);
    -  try {
    -    const info = await signal.fetchChannelInfo(GROUP_ID!);
    -    console.log(`   Name: ${info.name}`);
    -    console.log(`   Members: ${info.memberCount ?? "unknown"}`);
    -    console.log(`   Is DM: ${info.isDM}`);
    -  } catch (err) {
    -    console.warn(`   ⚠️  Could not fetch group info: ${(err as Error).message}`);
    -  }
    -
    -  // Post to group
    -  const threadId = signal.encodeThreadId({ chatId: GROUP_ID! });
    -  console.log(`\n📨 Posting to group thread: ${threadId}`);
    -  const sent = await signal.postMessage(threadId, "Hello group! 👋 This is a test from the Signal adapter.");
    -  console.log(`   Message ID: ${sent.id}`);
    -
    -  // Fetch messages from cache
    -  const result = await signal.fetchMessages(threadId, { limit: 5 });
    -  console.log(`\n📚 Cached messages in thread: ${result.messages.length}`);
    -  for (const msg of result.messages) {
    -    console.log(`   [${msg.author.userName}] ${msg.text}`);
    -  }
    -
    -  console.log("\n✅ Done!");
    -  await bot.shutdown();
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/06-poll-receive.ts b/examples/signal-local/06-poll-receive.ts
    deleted file mode 100644
    index a0f078aa..00000000
    --- a/examples/signal-local/06-poll-receive.ts
    +++ /dev/null
    @@ -1,72 +0,0 @@
    -/**
    - * 06 — WebSocket receive loop
    - *
    - * Connects to signal-cli-rest-api via WebSocket (json-rpc mode)
    - * and prints incoming messages, reactions, and edits.
    - *
    - * Send messages from another Signal client to see them arrive.
    - * Ctrl+C to stop.
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, SERVICE_URL } from "./env";
    -import { connectSignalWebSocket } from "./ws";
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  // Log every incoming message
    -  bot.onNewMention(async (_thread, message) => {
    -    console.log(`\n📩 [mention] ${message.author.userName}: ${message.text}`);
    -  });
    -
    -  bot.onNewMessage(/./, async (thread, message) => {
    -    const label = message.metadata.edited ? "edited" : thread.isDM ? "DM" : "group";
    -    console.log(message)
    -    console.log(`\n📩 [${label}] ${message.author.userName}: ${message.text}`);
    -    if (message.attachments.length > 0) {
    -      for (const att of message.attachments) {
    -        console.log(`   📎 ${att.type}: ${att.name ?? att.mimeType ?? "unknown"} (${att.size ?? "?"} bytes)`);
    -      }
    -    }
    -  });
    -
    -  bot.onReaction(async (event) => {
    -    console.log(
    -      `\n${event.added ? "➕" : "➖"} Reaction: ${event.rawEmoji} by ${event.user.userName} on message ${event.messageId}`
    -    );
    -  });
    -
    -  await bot.initialize();
    -
    -  console.log("🔄 Listening via WebSocket (Ctrl+C to stop)...\n");
    -  const ws = connectSignalWebSocket(signal, SERVICE_URL, PHONE_NUMBER!);
    -
    -  // Wait for Ctrl+C
    -  await new Promise((resolve) => {
    -    process.on("SIGINT", () => {
    -      console.log("\n🛑 Stopping...");
    -      resolve();
    -    });
    -  });
    -
    -  ws.close();
    -  await bot.shutdown();
    -  console.log("✅ Stopped.");
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/07-webhook-server.ts b/examples/signal-local/07-webhook-server.ts
    deleted file mode 100644
    index 7ec9f225..00000000
    --- a/examples/signal-local/07-webhook-server.ts
    +++ /dev/null
    @@ -1,105 +0,0 @@
    -/**
    - * 07 — Webhook server (alternative to WebSocket)
    - *
    - * Starts an HTTP server on port 3000 that receives Signal webhooks.
    - * Use this if you prefer webhook mode over WebSocket.
    - *
    - * Configure signal-cli-rest-api with:
    - *   RECEIVE_WEBHOOK_URL=http://host.docker.internal:3000/webhook
    - *
    - * (Use host.docker.internal if signal-cli-rest-api runs in Docker)
    - */
    -import { createServer } from "node:http";
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, SERVICE_URL } from "./env";
    -
    -const PORT = Number(process.env.PORT ?? 3000);
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -  });
    -
    -  const bot = new Chat({
    -    userName: "test-bot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  // Log incoming messages
    -  bot.onNewMessage(/./, async (thread, message) => {
    -    console.log(
    -      `📩 [${thread.isDM ? "DM" : "group"}] ${message.author.userName}: ${message.text}`
    -    );
    -  });
    -
    -  bot.onReaction(async (event) => {
    -    console.log(
    -      `${event.added ? "➕" : "➖"} Reaction: ${event.rawEmoji} by ${event.user.userName}`
    -    );
    -  });
    -
    -  await bot.initialize();
    -
    -  // Create HTTP server
    -  const server = createServer(async (req, res) => {
    -    if (req.method === "POST" && req.url === "/webhook") {
    -      const chunks: Buffer[] = [];
    -      for await (const chunk of req) {
    -        chunks.push(chunk as Buffer);
    -      }
    -      const body = Buffer.concat(chunks).toString();
    -
    -      // Convert to a Web Request for the adapter
    -      const webRequest = new Request(`http://localhost:${PORT}/webhook`, {
    -        method: "POST",
    -        headers: Object.fromEntries(
    -          Object.entries(req.headers)
    -            .filter((entry): entry is [string, string] => typeof entry[1] === "string")
    -        ),
    -        body,
    -      });
    -
    -      const response = await signal.handleWebhook(webRequest);
    -      res.writeHead(response.status);
    -      res.end(await response.text());
    -      return;
    -    }
    -
    -    if (req.method === "GET" && req.url === "/health") {
    -      res.writeHead(200);
    -      res.end("ok");
    -      return;
    -    }
    -
    -    res.writeHead(404);
    -    res.end("Not found");
    -  });
    -
    -  server.listen(PORT, () => {
    -    console.log(`\n🌐 Webhook server listening on http://localhost:${PORT}/webhook`);
    -    console.log("   Configure signal-cli-rest-api with:");
    -    console.log(`   RECEIVE_WEBHOOK_URL=http://host.docker.internal:${PORT}/webhook`);
    -    console.log("\n   Ctrl+C to stop.\n");
    -  });
    -
    -  await new Promise((resolve) => {
    -    process.on("SIGINT", () => {
    -      console.log("\n🛑 Stopping...");
    -      server.close();
    -      resolve();
    -    });
    -  });
    -
    -  await bot.shutdown();
    -  console.log("✅ Stopped.");
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/08-echo-bot.ts b/examples/signal-local/08-echo-bot.ts
    deleted file mode 100644
    index e4c1d748..00000000
    --- a/examples/signal-local/08-echo-bot.ts
    +++ /dev/null
    @@ -1,121 +0,0 @@
    -/**
    - * 08 — Echo bot (WebSocket)
    - *
    - * A simple bot using WebSocket receive that:
    - * - Echoes DM messages back
    - * - Echoes group messages when @mentioned
    - * - Reacts to incoming reactions with 🤝
    - * - Handles edits by showing the diff
    - *
    - * Ctrl+C to stop.
    - */
    -import { createSignalAdapter } from "@chat-adapter/signal";
    -import { createMemoryState } from "@chat-adapter/state-memory";
    -import { Chat } from "chat";
    -import { PHONE_NUMBER, SERVICE_URL } from "./env";
    -import { connectSignalWebSocket } from "./ws";
    -
    -async function main() {
    -  const signal = createSignalAdapter({
    -    phoneNumber: PHONE_NUMBER!,
    -    baseUrl: SERVICE_URL,
    -    userName: "echobot",
    -  });
    -
    -  const bot = new Chat({
    -    userName: "echobot",
    -    adapters: { signal },
    -    state: createMemoryState(),
    -    logger: "info",
    -  });
    -
    -  // Subscribe to threads on first mention
    -  bot.onNewMention(async (thread, message) => {
    -    await thread.subscribe();
    -    console.log(`🔔 Subscribed to thread: ${thread.id}`);
    -
    -    await thread.startTyping();
    -    await thread.post(`👋 Hi ${message.author.userName}! I'm now listening here. Send me messages and I'll echo them back.`);
    -  });
    -
    -  // Echo messages in subscribed threads (DMs + groups where mentioned)
    -  bot.onSubscribedMessage(async (thread, message) => {
    -    // Skip bot's own messages
    -    if (message.author.isMe) {
    -      return;
    -    }
    -
    -    // For non-DM threads, only respond if mentioned or it's Signal/Telegram
    -    if (!(thread.isDM || thread.adapter.name === "signal" || message.isMention)) {
    -      return;
    -    }
    -
    -    const prefix = message.metadata.edited ? "✏️ [edited]" : "🔊";
    -
    -    // Echo with typing indicator
    -    await thread.startTyping();
    -
    -    if (message.attachments.length > 0) {
    -      const attList = message.attachments
    -        .map((a) => `${a.type}: ${a.name ?? a.mimeType ?? "file"}`)
    -        .join(", ");
    -      await thread.post(`${prefix} You said: "${message.text}"\n📎 Attachments: ${attList}`);
    -    } else {
    -      await thread.post(`${prefix} You said: "${message.text}"`);
    -    }
    -
    -    // Show cached message count
    -    const cached = await signal.fetchMessages(thread.id, { limit: 100 });
    -    console.log(`   📚 ${cached.messages.length} messages cached for this thread`);
    -  });
    -
    -  // React back to reactions
    -  bot.onReaction(async (event) => {
    -    if (!event.added) {
    -      return;
    -    }
    -
    -    console.log(`${event.rawEmoji} from ${event.user.userName}`);
    -
    -    try {
    -      await event.adapter.addReaction(event.threadId, event.messageId, "🤝");
    -    } catch (err) {
    -      console.warn(`   ⚠️  Could not add reaction: ${(err as Error).message}`);
    -    }
    -  });
    -
    -  // Auto-subscribe and echo on any new message (DMs and groups)
    -  bot.onNewMessage(/./, async (thread, message) => {
    -    if (message.author.isMe) {
    -      return;
    -    }
    -
    -    await thread.subscribe();
    -    const label = thread.isDM ? "DM" : "group";
    -    await thread.post(`👋 Echo bot here (${label})! You said: "${message.text}"`);
    -  });
    -
    -  await bot.initialize();
    -
    -  console.log("🤖 Echo bot started! Listening via WebSocket...");
    -  console.log("   Send a message from your Signal app to test.");
    -  console.log("   Ctrl+C to stop.\n");
    -
    -  const ws = connectSignalWebSocket(signal, SERVICE_URL, PHONE_NUMBER!);
    -
    -  await new Promise((resolve) => {
    -    process.on("SIGINT", () => {
    -      console.log("\n🛑 Shutting down...");
    -      resolve();
    -    });
    -  });
    -
    -  ws.close();
    -  await bot.shutdown();
    -  console.log("✅ Stopped.");
    -}
    -
    -main().catch((err) => {
    -  console.error("❌ Failed:", err.message);
    -  process.exit(1);
    -});
    diff --git a/examples/signal-local/README.md b/examples/signal-local/README.md
    deleted file mode 100644
    index bf7bc4b5..00000000
    --- a/examples/signal-local/README.md
    +++ /dev/null
    @@ -1,109 +0,0 @@
    -# Signal Adapter — Local Testing Examples
    -
    -Standalone scripts for testing the Signal adapter against a local `signal-cli-rest-api` instance running in **json-rpc mode** (WebSocket).
    -
    -## Prerequisites
    -
    -1. **signal-cli-rest-api** running locally in json-rpc mode (default: `http://localhost:8080`)
    -
    -   ```bash
    -   docker run -d --name signal-api \
    -     -p 8080:8080 \
    -     -v $HOME/.local/share/signal-cli:/home/.local/share/signal-cli \
    -     -e MODE=json-rpc \
    -     bbernhard/signal-cli-rest-api:latest
    -   ```
    -
    -2. A registered/linked phone number in signal-cli-rest-api.
    -
    -3. Build the monorepo:
    -
    -   ```bash
    -   pnpm install && pnpm build
    -   ```
    -
    -## Environment
    -
    -Copy `.env.example` to `.env` and fill in values:
    -
    -```bash
    -cp .env.example .env
    -```
    -
    -| Variable | Required | Description |
    -|----------|----------|-------------|
    -| `SIGNAL_PHONE_NUMBER` | Yes | Your bot's registered phone number (e.g. `+14155551234`) |
    -| `SIGNAL_SERVICE_URL` | No | signal-cli-rest-api URL (default: `http://localhost:8080`) |
    -| `SIGNAL_RECIPIENT` | Yes* | Phone number to send test messages to (*scripts 02-05) |
    -
    -## Scripts
    -
    -Run from this directory with `npx tsx` or use the `pnpm` shortcuts:
    -
    -### 1. Health check & account verification
    -
    -```bash
    -npx tsx 01-health-check.ts     # or: pnpm health
    -```
    -
    -Verifies connectivity to signal-cli-rest-api and that your phone number is registered.
    -
    -### 2. Send, edit, delete messages
    -
    -```bash
    -npx tsx 02-send-edit-delete.ts  # or: pnpm send
    -```
    -
    -Posts a message, edits it twice, fetches it from cache, then deletes it.
    -
    -### 3. Reactions
    -
    -```bash
    -npx tsx 03-reactions.ts         # or: pnpm react
    -```
    -
    -Posts a message and adds/removes reactions.
    -
    -### 4. Typing indicator
    -
    -```bash
    -npx tsx 04-typing.ts            # or: pnpm typing
    -```
    -
    -Sends a typing indicator, waits 3s, then posts a message.
    -
    -### 5. Group messaging
    -
    -```bash
    -SIGNAL_GROUP_ID="group.abc123==" npx tsx 05-group.ts  # or: pnpm group
    -```
    -
    -Posts a message to a Signal group, fetches group metadata. List your groups with:
    -
    -```bash
    -curl http://localhost:8080/v1/groups/YOUR_PHONE_NUMBER
    -```
    -
    -### 6. WebSocket receive (json-rpc mode)
    -
    -```bash
    -npx tsx 06-poll-receive.ts      # or: pnpm poll
    -```
    -
    -Connects via WebSocket to `ws://localhost:8080/v1/receive/{number}` and prints incoming messages, reactions, and edits. Send messages from your Signal app to see them arrive. Ctrl+C to stop.
    -
    -### 7. Webhook server (alternative)
    -
    -```bash
    -npx tsx 07-webhook-server.ts    # or: pnpm webhook
    -```
    -
    -Starts an HTTP server on port 3000 that receives Signal webhooks. Use this if you prefer webhook mode. Configure `RECEIVE_WEBHOOK_URL=http://host.docker.internal:3000/webhook` in signal-cli-rest-api.
    -
    -### 8. Echo bot (WebSocket)
    -
    -```bash
    -npx tsx 08-echo-bot.ts          # or: pnpm bot
    -```
    -
    -A simple echo bot using WebSocket receive. Replies to DMs, echoes messages in groups when mentioned, reacts to incoming reactions with 🤝.
    diff --git a/examples/signal-local/env.ts b/examples/signal-local/env.ts
    deleted file mode 100644
    index 9a88b062..00000000
    --- a/examples/signal-local/env.ts
    +++ /dev/null
    @@ -1,14 +0,0 @@
    -import { config } from "dotenv";
    -
    -config();
    -
    -export const PHONE_NUMBER = process.env.SIGNAL_PHONE_NUMBER;
    -export const SERVICE_URL =
    -  process.env.SIGNAL_SERVICE_URL ?? "http://localhost:8080";
    -export const RECIPIENT = process.env.SIGNAL_RECIPIENT;
    -export const GROUP_ID = process.env.SIGNAL_GROUP_ID;
    -
    -if (!PHONE_NUMBER) {
    -  console.error("❌ SIGNAL_PHONE_NUMBER is required. See .env.example");
    -  process.exit(1);
    -}
    diff --git a/examples/signal-local/package.json b/examples/signal-local/package.json
    deleted file mode 100644
    index 6d1e57e5..00000000
    --- a/examples/signal-local/package.json
    +++ /dev/null
    @@ -1,25 +0,0 @@
    -{
    -  "name": "@chat-example/signal-local",
    -  "version": "0.0.0",
    -  "private": true,
    -  "type": "module",
    -  "scripts": {
    -    "health": "tsx 01-health-check.ts",
    -    "send": "tsx 02-send-edit-delete.ts",
    -    "react": "tsx 03-reactions.ts",
    -    "typing": "tsx 04-typing.ts",
    -    "group": "tsx 05-group.ts",
    -    "poll": "tsx 06-poll-receive.ts",
    -    "webhook": "tsx 07-webhook-server.ts",
    -    "bot": "tsx 08-echo-bot.ts"
    -  },
    -  "dependencies": {
    -    "@chat-adapter/signal": "workspace:*",
    -    "@chat-adapter/state-memory": "workspace:*",
    -    "chat": "workspace:*",
    -    "dotenv": "^16.4.7"
    -  },
    -  "devDependencies": {
    -    "tsx": "^4.19.4"
    -  }
    -}
    diff --git a/examples/signal-local/tsconfig.json b/examples/signal-local/tsconfig.json
    deleted file mode 100644
    index 219302d7..00000000
    --- a/examples/signal-local/tsconfig.json
    +++ /dev/null
    @@ -1,11 +0,0 @@
    -{
    -  "compilerOptions": {
    -    "target": "ES2022",
    -    "module": "ESNext",
    -    "moduleResolution": "bundler",
    -    "esModuleInterop": true,
    -    "strict": true,
    -    "skipLibCheck": true
    -  },
    -  "include": ["*.ts"]
    -}
    diff --git a/examples/signal-local/ws.ts b/examples/signal-local/ws.ts
    deleted file mode 100644
    index 2c563c31..00000000
    --- a/examples/signal-local/ws.ts
    +++ /dev/null
    @@ -1,54 +0,0 @@
    -/**
    - * WebSocket helper for signal-cli-rest-api json-rpc mode.
    - *
    - * Connects to ws:///v1/receive/ and feeds incoming
    - * JSON-RPC messages through the adapter's handleWebhook method.
    - */
    -import type { SignalAdapter } from "@chat-adapter/signal";
    -
    -export function connectSignalWebSocket(
    -  signal: SignalAdapter,
    -  serviceUrl: string,
    -  phoneNumber: string
    -): { close: () => void } {
    -  const wsUrl = serviceUrl
    -    .replace(/^http:/, "ws:")
    -    .replace(/^https:/, "wss:")
    -    .replace(/\/+$/, "");
    -
    -  const endpoint = `${wsUrl}/v1/receive/${encodeURIComponent(phoneNumber)}`;
    -  console.log(`🔌 Connecting WebSocket: ${endpoint}`);
    -
    -  const ws = new WebSocket(endpoint);
    -
    -  ws.addEventListener("open", () => {
    -    console.log("🟢 WebSocket connected\n");
    -  });
    -
    -  ws.addEventListener("message", (event) => {
    -    const body = typeof event.data === "string" ? event.data : String(event.data);
    -
    -    // Feed the JSON-RPC message through handleWebhook as a synthetic Request
    -    const request = new Request("http://localhost/ws-receive", {
    -      method: "POST",
    -      headers: { "content-type": "application/json" },
    -      body,
    -    });
    -
    -    signal.handleWebhook(request).catch((err) => {
    -      console.error("⚠️  handleWebhook error:", (err as Error).message);
    -    });
    -  });
    -
    -  ws.addEventListener("error", (event) => {
    -    console.error("🔴 WebSocket error:", event);
    -  });
    -
    -  ws.addEventListener("close", (event) => {
    -    console.log(`🔴 WebSocket closed (code=${event.code}, reason=${event.reason})`);
    -  });
    -
    -  return {
    -    close: () => ws.close(),
    -  };
    -}
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
    index 89b75268..c73cfa53 100644
    --- a/pnpm-lock.yaml
    +++ b/pnpm-lock.yaml
    @@ -239,25 +239,6 @@ importers:
             specifier: ^5.7.2
             version: 5.9.3
     
    -  examples/signal-local:
    -    dependencies:
    -      '@chat-adapter/signal':
    -        specifier: workspace:*
    -        version: link:../../packages/adapter-signal
    -      '@chat-adapter/state-memory':
    -        specifier: workspace:*
    -        version: link:../../packages/state-memory
    -      chat:
    -        specifier: workspace:*
    -        version: link:../../packages/chat
    -      dotenv:
    -        specifier: ^16.4.7
    -        version: 16.6.1
    -    devDependencies:
    -      tsx:
    -        specifier: ^4.19.4
    -        version: 4.21.0
    -
       packages/adapter-discord:
         dependencies:
           '@chat-adapter/shared':
    @@ -3623,10 +3604,6 @@ packages:
       domutils@3.2.2:
         resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
     
    -  dotenv@16.6.1:
    -    resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
    -    engines: {node: '>=12'}
    -
       dotenv@17.2.3:
         resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
         engines: {node: '>=12'}
    @@ -8945,8 +8922,6 @@ snapshots:
           domelementtype: 2.3.0
           domhandler: 5.0.3
     
    -  dotenv@16.6.1: {}
    -
       dotenv@17.2.3: {}
     
       dunder-proto@1.0.1:
    
    From 4d421794b3e9e35a13efcd2bfe105e69ef145e8a Mon Sep 17 00:00:00 2001
    From: Hayden Bleasel 
    Date: Fri, 6 Mar 2026 17:00:32 -0800
    Subject: [PATCH 3/7] reorder adapter checks in inferAdapterFromUserId
    
    Move Signal checks after existing adapters (GChat, Teams, Slack, Discord)
    to avoid any theoretical collisions with established adapter patterns.
    
    Co-Authored-By: Claude Opus 4.6 
    ---
     packages/chat/src/chat.ts | 34 +++++++++++++++++-----------------
     1 file changed, 17 insertions(+), 17 deletions(-)
    
    diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts
    index c620d506..5ccc4732 100644
    --- a/packages/chat/src/chat.ts
    +++ b/packages/chat/src/chat.ts
    @@ -1427,22 +1427,6 @@ export class Chat<
        * Infer which adapter to use based on the userId format.
        */
       private inferAdapterFromUserId(userId: string): Adapter {
    -    // 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;
    -      }
    -    }
    -
         // Google Chat: users/123456789
         if (userId.startsWith("users/")) {
           const adapter = this.adapters.get("gchat");
    @@ -1475,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: Signal (signal:... or +E.164), 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"
         );
       }
    
    From e14d79a6b25aa789eed23dbc7ecd37df70dc5061 Mon Sep 17 00:00:00 2001
    From: Hayden Bleasel 
    Date: Fri, 6 Mar 2026 17:00:37 -0800
    Subject: [PATCH 4/7] fix edit deduplication test to use setIfNotExists
    
    The atomic deduplication change (cc65dc3) switched from set() to
    setIfNotExists(), but this test still asserted on the old API.
    
    Co-Authored-By: Claude Opus 4.6 
    ---
     packages/chat/src/chat.test.ts | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts
    index 6a5c03f4..040e878d 100644
    --- a/packages/chat/src/chat.test.ts
    +++ b/packages/chat/src/chat.test.ts
    @@ -212,7 +212,7 @@ describe("Chat", () => {
           );
     
           expect(handler).toHaveBeenCalledTimes(2);
    -      expect(mockState.set).toHaveBeenCalledWith(
    +      expect(mockState.setIfNotExists).toHaveBeenCalledWith(
             "dedupe:slack:msg-1:edited:1705313100000",
             true,
             300_000
    
    From 7de2e7deda5cb0d2e7dd78f4b2fe1e37fcf28a51 Mon Sep 17 00:00:00 2001
    From: Hayden Bleasel 
    Date: Fri, 6 Mar 2026 17:00:58 -0800
    Subject: [PATCH 5/7] extract identity and cache modules from signal adapter
    
    Break 2,217-line index.ts into focused modules:
    - identity.ts: identifier canonicalization, alias registration, cycle detection
    - cache.ts: message cache operations, pagination, timestamp parsing
    
    Also simplifies normalizePositiveInteger() and makes SignalPollingOptions
    internal (not exported).
    
    Co-Authored-By: Claude Opus 4.6 
    ---
     packages/adapter-signal/src/cache.ts    | 161 +++++++++++++
     packages/adapter-signal/src/identity.ts |  57 +++++
     packages/adapter-signal/src/index.ts    | 290 ++++--------------------
     3 files changed, 260 insertions(+), 248 deletions(-)
     create mode 100644 packages/adapter-signal/src/cache.ts
     create mode 100644 packages/adapter-signal/src/identity.ts
    
    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.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.ts b/packages/adapter-signal/src/index.ts
    index 6842aac7..48f91e78 100644
    --- a/packages/adapter-signal/src/index.ts
    +++ b/packages/adapter-signal/src/index.ts
    @@ -30,6 +30,8 @@ import {
       defaultEmojiResolver,
       Message,
     } from "chat";
    +import { SignalMessageCache } from "./cache";
    +import { SignalIdentityMap } from "./identity";
     import { SignalFormatConverter } from "./markdown";
     import type {
       SignalAdapterConfig,
    @@ -65,7 +67,6 @@ 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 SIGNAL_PHONE_NUMBER_PATTERN = /^\+[1-9]\d{6,14}$/;
     const BASE64_OR_BASE64URL_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/;
     const TRAILING_BASE64_PADDING_PATTERN = /=+$/;
     
    @@ -83,7 +84,7 @@ interface SignalParsedMessageOptions {
       messageIdTimestamp?: number;
     }
     
    -export interface SignalPollingOptions {
    +interface SignalPollingOptions {
       intervalMs?: number;
       timeoutSeconds?: number;
       webhookOptions?: WebhookOptions;
    @@ -101,11 +102,8 @@ export class SignalAdapter
       private readonly configuredTextMode?: SignalTextMode;
       private readonly logger: Logger;
       private readonly formatConverter = new SignalFormatConverter();
    -  private readonly messageCache = new Map<
    -    string,
    -    Message[]
    -  >();
    -  private readonly identifierAliases = new Map();
    +  private readonly messageCache = new SignalMessageCache();
    +  private readonly identityMap = new SignalIdentityMap();
     
       private chat: ChatInstance | null = null;
       private pollingTask: Promise | null = null;
    @@ -202,10 +200,12 @@ export class SignalAdapter
           return;
         }
     
    -    const intervalMs = this.normalizePositiveInteger(
    -      options.intervalMs,
    -      DEFAULT_POLLING_INTERVAL_MS,
    -      MIN_POLLING_INTERVAL_MS
    +    const intervalMs = Math.max(
    +      MIN_POLLING_INTERVAL_MS,
    +      this.normalizePositiveInteger(
    +        options.intervalMs,
    +        DEFAULT_POLLING_INTERVAL_MS
    +      )
         );
     
         const timeoutSeconds =
    @@ -324,7 +324,7 @@ export class SignalAdapter
           timestamp: sentTimestamp,
         });
     
    -    this.cacheMessage(outgoingMessage);
    +    this.messageCache.cache(outgoingMessage);
     
         return {
           id: outgoingMessage.id,
    @@ -394,10 +394,8 @@ export class SignalAdapter
         );
     
         const existing =
    -      (this.messageCache.get(resultingThreadId) ?? []).find(
    -        (cachedMessage) => cachedMessage.id === messageId
    -      ) ??
    -      this.findCachedMessageByTimestamp(
    +      this.messageCache.findById(resultingThreadId, messageId) ??
    +      this.messageCache.findByTimestamp(
             resultingThreadId,
             decodedMessageId.timestamp
           );
    @@ -423,7 +421,7 @@ export class SignalAdapter
               timestamp: decodedMessageId.timestamp,
             });
     
    -    this.cacheMessage(updatedMessage);
    +    this.messageCache.cache(updatedMessage);
     
         return {
           id: updatedMessage.id,
    @@ -450,8 +448,8 @@ export class SignalAdapter
           "deleteMessage"
         );
     
    -    this.deleteCachedMessage(messageId);
    -    this.deleteCachedMessagesByTimestamp(
    +    this.messageCache.deleteById(messageId);
    +    this.messageCache.deleteByTimestamp(
           this.encodeThreadId(parsedThread),
           decodedMessageId.timestamp
         );
    @@ -538,11 +536,8 @@ export class SignalAdapter
           this.resolveThreadId(threadId)
         );
     
    -    const messages = [...(this.messageCache.get(resolvedThreadId) ?? [])].sort(
    -      (a, b) => this.compareMessages(a, b)
    -    );
    -
    -    return this.paginateMessages(messages, options);
    +    const messages = this.messageCache.getThread(resolvedThreadId);
    +    return this.messageCache.paginate(messages, options);
       }
     
       async fetchChannelMessages(
    @@ -561,14 +556,9 @@ export class SignalAdapter
           this.resolveThreadId(threadId)
         );
     
    -    const threadMessages = this.messageCache.get(normalizedThreadId) ?? [];
    -    const directMatch = threadMessages.find(
    -      (message) => message.id === messageId
    -    );
    -
         return (
    -      directMatch ??
    -      this.findCachedMessageByTimestamp(
    +      this.messageCache.findById(normalizedThreadId, messageId) ??
    +      this.messageCache.findByTimestamp(
             normalizedThreadId,
             this.decodeMessageId(messageId).timestamp
           ) ??
    @@ -663,7 +653,7 @@ export class SignalAdapter
         );
         const chatId = this.isGroupChatId(normalizedChatId)
           ? this.normalizeGroupId(normalizedChatId)
    -      : this.canonicalizeIdentifier(normalizedChatId);
    +      : this.identityMap.canonicalize(normalizedChatId);
     
         return `${SIGNAL_THREAD_PREFIX}${chatId}`;
       }
    @@ -689,13 +679,13 @@ export class SignalAdapter
         return {
           chatId: this.isGroupChatId(normalizedChatId)
             ? this.normalizeGroupId(normalizedChatId)
    -        : this.canonicalizeIdentifier(normalizedChatId),
    +        : this.identityMap.canonicalize(normalizedChatId),
         };
       }
     
       parseMessage(raw: SignalRawMessage): Message {
         const message = this.messageFromRaw(raw);
    -    this.cacheMessage(message);
    +    this.messageCache.cache(message);
         return message;
       }
     
    @@ -818,7 +808,7 @@ export class SignalAdapter
           }
         );
     
    -    this.cacheMessage(message);
    +    this.messageCache.cache(message);
         this.chat.processMessage(this, threadId, message, options);
       }
     
    @@ -848,7 +838,7 @@ export class SignalAdapter
           }
         );
     
    -    this.cacheMessage(message);
    +    this.messageCache.cache(message);
         this.chat.processMessage(this, threadId, message, options);
       }
     
    @@ -862,7 +852,7 @@ export class SignalAdapter
         }
     
         const threadId = this.threadIdFromEnvelope(update.envelope, dataMessage);
    -    this.deleteCachedMessagesByTimestamp(threadId, remoteDelete.timestamp);
    +    this.messageCache.deleteByTimestamp(threadId, remoteDelete.timestamp);
       }
     
       private handleIncomingSyncSentMessage(
    @@ -871,7 +861,7 @@ export class SignalAdapter
         options?: WebhookOptions
       ): void {
         const message = this.createMessageFromSyncSentMessage(update, sentMessage);
    -    this.cacheMessage(message);
    +    this.messageCache.cache(message);
     
         if (!this.chat) {
           return;
    @@ -892,7 +882,7 @@ export class SignalAdapter
     
         const threadId = this.threadIdFromEnvelope(update.envelope, dataMessage);
         const targetAuthor = this.resolveReactionTargetAuthor(reaction);
    -    const cachedTargetMessage = this.findCachedMessageByTimestamp(
    +    const cachedTargetMessage = this.messageCache.findByTimestamp(
           threadId,
           reaction.targetSentTimestamp
         );
    @@ -1024,8 +1014,8 @@ export class SignalAdapter
         }
     
         const existingMessage = options.edited
    -      ? (this.findCachedMessageByTimestamp(threadId, messageIdTimestamp) ??
    -        this.findCachedMessageByTimestampAcrossThreads(messageIdTimestamp))
    +      ? (this.messageCache.findByTimestamp(threadId, messageIdTimestamp) ??
    +        this.messageCache.findByTimestampAcrossThreads(messageIdTimestamp))
           : undefined;
     
         const existingMessageAuthor = existingMessage
    @@ -1269,7 +1259,7 @@ export class SignalAdapter
       private resolveEnvelopeSourceIdentifier(
         envelope: SignalEnvelope
       ): string | undefined {
    -    return this.registerIdentifierAliases(
    +    return this.identityMap.registerAliases(
           envelope.sourceNumber ?? undefined,
           envelope.sourceUuid,
           envelope.source
    @@ -1279,7 +1269,7 @@ export class SignalAdapter
       private resolveReactionTargetAuthor(
         reaction: SignalReaction
       ): string | undefined {
    -    return this.registerIdentifierAliases(
    +    return this.identityMap.registerAliases(
           reaction.targetAuthorNumber ?? undefined,
           reaction.targetAuthorUuid,
           reaction.targetAuthor
    @@ -1414,10 +1404,7 @@ export class SignalAdapter
       ): { author: string; timestamp: number } {
         const decoded = this.decodeMessageId(messageId);
     
    -    const threadMessages = this.messageCache.get(threadId) ?? [];
    -    const fromCache = threadMessages.find(
    -      (message) => message.id === messageId
    -    );
    +    const fromCache = this.messageCache.findById(threadId, messageId);
         if (fromCache) {
           const cachedDecoded = this.decodeMessageIdRaw(fromCache.id);
           if (cachedDecoded.author) {
    @@ -1428,7 +1415,7 @@ export class SignalAdapter
           }
         }
     
    -    const fromTimestamp = this.findCachedMessageByTimestamp(
    +    const fromTimestamp = this.messageCache.findByTimestamp(
           threadId,
           decoded.timestamp
         );
    @@ -1457,7 +1444,7 @@ export class SignalAdapter
     
       private encodeMessageId(author: string, timestamp: number): string {
         return this.encodeMessageIdRaw(
    -      this.canonicalizeIdentifier(author),
    +      this.identityMap.canonicalize(author),
           timestamp
         );
       }
    @@ -1476,7 +1463,7 @@ export class SignalAdapter
         }
     
         return {
    -      author: this.canonicalizeIdentifier(decoded.author),
    +      author: this.identityMap.canonicalize(decoded.author),
           timestamp: decoded.timestamp,
         };
       }
    @@ -1510,14 +1497,6 @@ export class SignalAdapter
         );
       }
     
    -  private messageTimestamp(messageId: string): number {
    -    try {
    -      return this.decodeMessageIdRaw(messageId).timestamp;
    -    } catch {
    -      return 0;
    -    }
    -  }
    -
       private signalTimestampToDate(timestamp: number): Date {
         if (timestamp < 1_000_000_000_000) {
           return new Date(timestamp * 1000);
    @@ -1555,7 +1534,7 @@ export class SignalAdapter
         return {
           chatId: this.isGroupChatId(normalized)
             ? this.normalizeGroupId(normalized)
    -        : this.canonicalizeIdentifier(normalized),
    +        : this.identityMap.canonicalize(normalized),
         };
       }
     
    @@ -1566,11 +1545,11 @@ export class SignalAdapter
             )
           : this.normalizeSignalIdentifier(userId);
     
    -    return this.canonicalizeIdentifier(normalized);
    +    return this.identityMap.canonicalize(normalized);
       }
     
       private toSignalUserId(userId: string): string {
    -    const normalized = this.canonicalizeIdentifier(userId);
    +    const normalized = this.identityMap.canonicalize(userId);
         return `${SIGNAL_THREAD_PREFIX}${normalized}`;
       }
     
    @@ -1667,61 +1646,6 @@ export class SignalAdapter
         return value.trim();
       }
     
    -  private canonicalizeIdentifier(value: string): string {
    -    const normalized = this.normalizeSignalIdentifier(value);
    -    if (!normalized) {
    -      return normalized;
    -    }
    -
    -    const visited = new Set();
    -    let current = normalized;
    -
    -    while (!visited.has(current)) {
    -      visited.add(current);
    -      const aliased = this.identifierAliases.get(current);
    -      if (!aliased || aliased === current) {
    -        return current;
    -      }
    -      current = aliased;
    -    }
    -
    -    return current;
    -  }
    -
    -  private registerIdentifierAliases(
    -    ...identifiers: Array
    -  ): string | undefined {
    -    const normalized = identifiers
    -      .map((identifier) =>
    -        identifier ? this.normalizeSignalIdentifier(identifier) : undefined
    -      )
    -      .filter((identifier): identifier is string => Boolean(identifier));
    -
    -    if (normalized.length === 0) {
    -      return undefined;
    -    }
    -
    -    const canonicalCandidate =
    -      normalized.find((identifier) => this.isPhoneNumber(identifier)) ??
    -      normalized[0];
    -
    -    if (!canonicalCandidate) {
    -      return undefined;
    -    }
    -
    -    const canonical = this.canonicalizeIdentifier(canonicalCandidate);
    -
    -    for (const identifier of normalized) {
    -      this.identifierAliases.set(identifier, canonical);
    -    }
    -
    -    return canonical;
    -  }
    -
    -  private isPhoneNumber(value: string): boolean {
    -    return SIGNAL_PHONE_NUMBER_PATTERN.test(value);
    -  }
    -
       private normalizeUserName(value: string): string {
         return value.replace(LEADING_AT_PATTERN, "").trim() || "bot";
       }
    @@ -1738,135 +1662,6 @@ export class SignalAdapter
         return `${text.slice(0, SIGNAL_MESSAGE_LIMIT - 3)}...`;
       }
     
    -  private compareMessages(
    -    a: Message,
    -    b: Message
    -  ): number {
    -    const timestampDifference =
    -      a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime();
    -    if (timestampDifference !== 0) {
    -      return timestampDifference;
    -    }
    -
    -    return this.messageTimestamp(a.id) - this.messageTimestamp(b.id);
    -  }
    -
    -  private cacheMessage(message: Message): void {
    -    const existing = this.messageCache.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.messageCache.set(message.threadId, existing);
    -  }
    -
    -  private findCachedMessageByTimestamp(
    -    threadId: string,
    -    timestamp: number
    -  ): Message | undefined {
    -    const messages = this.messageCache.get(threadId) ?? [];
    -    return messages.find(
    -      (message) => this.messageTimestamp(message.id) === timestamp
    -    );
    -  }
    -
    -  private findCachedMessageByTimestampAcrossThreads(
    -    timestamp: number
    -  ): Message | undefined {
    -    for (const messages of this.messageCache.values()) {
    -      const matchedMessage = messages.find(
    -        (message) => this.messageTimestamp(message.id) === timestamp
    -      );
    -      if (matchedMessage) {
    -        return matchedMessage;
    -      }
    -    }
    -
    -    return undefined;
    -  }
    -
    -  private deleteCachedMessage(messageId: string): void {
    -    for (const [threadId, messages] of this.messageCache.entries()) {
    -      const filtered = messages.filter((message) => message.id !== messageId);
    -      if (filtered.length === 0) {
    -        this.messageCache.delete(threadId);
    -      } else if (filtered.length !== messages.length) {
    -        this.messageCache.set(threadId, filtered);
    -      }
    -    }
    -  }
    -
    -  private deleteCachedMessagesByTimestamp(
    -    threadId: string,
    -    timestamp: number
    -  ): void {
    -    const messages = this.messageCache.get(threadId);
    -    if (!messages) {
    -      return;
    -    }
    -
    -    const filtered = messages.filter(
    -      (message) => this.messageTimestamp(message.id) !== timestamp
    -    );
    -
    -    if (filtered.length === 0) {
    -      this.messageCache.delete(threadId);
    -      return;
    -    }
    -
    -    if (filtered.length !== messages.length) {
    -      this.messageCache.set(threadId, filtered);
    -    }
    -  }
    -
    -  private paginateMessages(
    -    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 toSignalReactionEmoji(emoji: EmojiValue | string): string {
         if (typeof emoji !== "string") {
           return defaultEmojiResolver.toGChat(emoji.name);
    @@ -1904,14 +1699,13 @@ export class SignalAdapter
     
       private normalizePositiveInteger(
         value: number | undefined,
    -    fallback: number,
    -    minimum = 1
    +    fallback: number
       ): number {
         if (typeof value !== "number" || !Number.isFinite(value)) {
           return fallback;
         }
     
    -    return Math.max(minimum, Math.trunc(value));
    +    return Math.max(1, Math.trunc(value));
       }
     
       private async assertSignalServiceHealth(): Promise {
    
    From 55f0856a02c7ef01032014f787ba50fcfde63110 Mon Sep 17 00:00:00 2001
    From: Hayden Bleasel 
    Date: Fri, 6 Mar 2026 17:01:02 -0800
    Subject: [PATCH 6/7] add tests for signal identity and cache modules
    
    - identity.test.ts: 17 tests covering canonicalization, cycle detection,
      alias registration, phone number preference
    - cache.test.ts: 22 tests covering store/retrieve, pagination, timestamp
      lookup, deletion
    
    Co-Authored-By: Claude Opus 4.6 
    ---
     packages/adapter-signal/src/cache.test.ts    | 257 +++++++++++++++++++
     packages/adapter-signal/src/identity.test.ts | 143 +++++++++++
     2 files changed, 400 insertions(+)
     create mode 100644 packages/adapter-signal/src/cache.test.ts
     create mode 100644 packages/adapter-signal/src/identity.test.ts
    
    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/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");
    +    });
    +  });
    +});
    
    From b0820c3dee6f2d3899a57e5358e13acc51d3a955 Mon Sep 17 00:00:00 2001
    From: Hayden Bleasel 
    Date: Fri, 6 Mar 2026 17:01:09 -0800
    Subject: [PATCH 7/7] expand signal adapter test coverage
    
    Add 30 new tests covering: attachment handling (image/video/audio/file),
    attachment downloads, remote delete, bot mention via text pattern,
    DM vs group identification, openDM, channel info, thread info, group
    posting, empty message rejection, message truncation, text mode handling,
    single message fetch, env var validation, network errors, identity alias
    transitions, polling lifecycle, and renderFormatted.
    
    Total: 18 -> 87 tests across 3 files, coverage 66% -> 83%.
    
    Co-Authored-By: Claude Opus 4.6 
    ---
     packages/adapter-signal/src/index.test.ts | 675 ++++++++++++++++++++++
     1 file changed, 675 insertions(+)
    
    diff --git a/packages/adapter-signal/src/index.test.ts b/packages/adapter-signal/src/index.test.ts
    index 02540060..2c5bb70e 100644
    --- a/packages/adapter-signal/src/index.test.ts
    +++ b/packages/adapter-signal/src/index.test.ts
    @@ -758,4 +758,679 @@ describe("SignalAdapter", () => {
           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");
    +  });
     });