Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,32 @@ WHATSAPP_API_URL=http://127.0.0.1:8080/api
# Twilio webhook transport (only needed when using --transport twilio)
# ---------------------------------------------------------------------------

# Twilio Auth Token — used to validate X-Twilio-Signature on each request.
# If unset, signature validation is skipped (unsafe; only for local testing).
# Twilio credentials for the TypeScript wa-channel server.
# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# TWILIO_AUTH_TOKEN=your_twilio_auth_token_here
# TWILIO_PHONE_NUMBER=whatsapp:+14155238886

# Public URL Twilio will POST to, used for signature validation.
# PUBLIC_WEBHOOK_URL=https://your-domain.example.com/webhook
# Public URL Twilio will POST to, used for X-Twilio-Signature validation.
# This must exactly match the public webhook URL configured in Twilio.
# WEBHOOK_URL=https://your-domain.example.com/webhook

# Port and host for the FastAPI webhook server.
# WEBHOOK_PORT=8000
# Dev only: bypasses Twilio signature validation. Unsafe in production.
# SKIP_TWILIO_VALIDATION=true

# Attachment safety limits for inbound media downloads.
# WA_CHANNEL_MAX_ATTACHMENTS=4
# WA_CHANNEL_MAX_MEDIA_BYTES=10485760
# WA_CHANNEL_ALLOWED_MEDIA_TYPES=image/*,audio/*,video/*,application/pdf,text/plain

# Port for the TypeScript/Bun wa-channel webhook server.
# WEBHOOK_PORT=3000

# ---------------------------------------------------------------------------
# Legacy FastAPI webhook transport (Python webhook/server.py only)
# ---------------------------------------------------------------------------

# Host for the legacy FastAPI webhook server.
# WEBHOOK_HOST=0.0.0.0

# SQLite DB for webhook inbound/outbound messages.
# SQLite DB for legacy FastAPI webhook inbound/outbound messages.
# WEBHOOK_DB_PATH=/home/deet/whatsapp-claude/webhook_messages.db
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ poller/__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/

node_modules/
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ systemctl --user status whatsapp-bridge whatsapp-mcp-server whatsapp-poller
journalctl --user -u whatsapp-bridge -f
```

## Twilio wa-channel notes

The TypeScript `wa-channel` transport uses Twilio WhatsApp webhooks plus an allowlist in `~/.claude/channels/wa-channel/access.json`.

Required Twilio env vars:
- `TWILIO_ACCOUNT_SID`
- `TWILIO_AUTH_TOKEN`
- `TWILIO_PHONE_NUMBER` (for example, `whatsapp:+14155238886`)
- `WEBHOOK_URL` — must exactly match the public webhook URL configured in Twilio; it is used for `X-Twilio-Signature` validation.

Security notes:
- `SKIP_TWILIO_VALIDATION=true` bypasses Twilio signature validation. Use it only for local development, never production.
- Outbound replies are sent only to allowlisted WhatsApp numbers.
- Unknown senders do **not** receive pairing codes. The webhook stores a pending request in `~/.claude/channels/wa-channel/access.json` under `pending`, logs `wa-channel access request: from=... code=...` to the wa-channel process/container logs, and sends the sender only: `Access request sent; an owner must approve you.`
- Owners approve requests from a Claude Code session with the `approve_access_request` MCP tool using the code from the logs. `list_access_requests` shows pending numbers and timestamps. Approval adds the normalized number to `allowFrom` and removes it from `pending`.
- Inbound `PAIR <code>` messages from unknown senders are ignored for authorization and receive the same owner-approval message.
- Attachment handling is bounded by `WA_CHANNEL_MAX_ATTACHMENTS` (default `4`), `WA_CHANNEL_MAX_MEDIA_BYTES` (default `10485760`), and `WA_CHANNEL_ALLOWED_MEDIA_TYPES` (default `image/*,audio/*,video/*,application/pdf,text/plain`).

## Repo layout

- `install.sh` — Linux server bootstrap
Expand Down
58 changes: 58 additions & 0 deletions allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

let tempHome: string;
let allowlist: any;
let originalChannelHome: string | undefined;

beforeAll(async () => {
originalChannelHome = process.env.WA_CHANNEL_HOME;
tempHome = await mkdtemp(join(tmpdir(), "wa-channel-access-"));
process.env.WA_CHANNEL_HOME = join(tempHome, "wa-channel");
allowlist = await import(`./allowlist.ts?test=${Date.now()}`);
});

afterAll(async () => {
if (originalChannelHome === undefined) delete process.env.WA_CHANNEL_HOME;
else process.env.WA_CHANNEL_HOME = originalChannelHome;
await rm(tempHome, { recursive: true, force: true });
});

test("access requests stay pending until owner approval by code", async () => {
const requested = await allowlist.issueAccessRequest("+15551234567");

expect(requested.from).toBe("whatsapp:+15551234567");
expect(requested.code).toMatch(/^[A-F0-9]{8}$/);
expect(await allowlist.isAllowlisted("+15551234567")).toBe(false);
expect(await allowlist.listAccessRequests()).toEqual([
{ from: "whatsapp:+15551234567", createdAt: requested.createdAt },
]);

expect(await allowlist.approveAccessRequest("WRONG-CODE")).toEqual({ approved: false });
expect(await allowlist.isAllowlisted("+15551234567")).toBe(false);

expect(await allowlist.approveAccessRequest(requested.code)).toEqual({
approved: true,
from: "whatsapp:+15551234567",
});
expect(await allowlist.isAllowlisted("+15551234567")).toBe(true);
expect(await allowlist.listAccessRequests()).toEqual([]);
});

test("repeat access requests do not rotate the owner-visible code", async () => {
const first = await allowlist.issueAccessRequest("+15557654321");
const second = await allowlist.issueAccessRequest("whatsapp:+15557654321");

expect(first.alreadyPending).toBe(false);
expect(second).toEqual({
from: "whatsapp:+15557654321",
code: "",
createdAt: first.createdAt,
alreadyPending: true,
});
expect(await allowlist.listAccessRequests()).toEqual([
{ from: "whatsapp:+15557654321", createdAt: first.createdAt },
]);
});
156 changes: 156 additions & 0 deletions allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { randomBytes, timingSafeEqual } from "node:crypto";

export type AccessRequest = {
from: string;
codeHash: string;
createdAt: string;
};

export type AccessConfig = {
allowFrom: string[];
dmPolicy: "allowlisted";
pending: AccessRequest[];
};

const LEGACY_PENDING_KEY = "pairings";

type RawAccessConfig = Partial<Omit<AccessConfig, "pending">> & {
pending?: unknown;
pairings?: unknown;
};

export const CHANNEL_HOME = process.env.WA_CHANNEL_HOME ?? join(homedir(), ".claude", "channels", "wa-channel");
export const ACCESS_PATH = join(CHANNEL_HOME, "access.json");
const LEGACY_PENDING_PATH = join(CHANNEL_HOME, "pairing.json");

const DEFAULT_ACCESS: AccessConfig = { allowFrom: [], dmPolicy: "allowlisted", pending: [] };

async function ensureHome(): Promise<void> {
await mkdir(CHANNEL_HOME, { recursive: true });
}

export function normalizeWhatsAppAddress(value: string): string {
const trimmed = value.trim();
if (trimmed.startsWith("whatsapp:")) return trimmed;
if (trimmed.startsWith("+")) return `whatsapp:${trimmed}`;
return trimmed;
}

function normalizePending(value: unknown): AccessRequest[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => {
if (!entry || typeof entry !== "object") return undefined;
const candidate = entry as Partial<AccessRequest>;
if (!candidate.from || !candidate.codeHash || !candidate.createdAt) return undefined;
return {
from: normalizeWhatsAppAddress(String(candidate.from)),
codeHash: String(candidate.codeHash),
createdAt: String(candidate.createdAt),
};
})
.filter((entry): entry is AccessRequest => Boolean(entry));
}

async function readLegacyPending(): Promise<AccessRequest[]> {
try {
const parsed = JSON.parse(await readFile(LEGACY_PENDING_PATH, "utf8")) as { pairings?: unknown };
return normalizePending(parsed.pairings);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
return [];
}
}

export async function readAccessConfig(): Promise<AccessConfig> {
await ensureHome();
try {
const parsed = JSON.parse(await readFile(ACCESS_PATH, "utf8")) as RawAccessConfig;
const rawPending = parsed.pending ?? parsed[LEGACY_PENDING_KEY];
const access: AccessConfig = {
allowFrom: Array.isArray(parsed.allowFrom)
? parsed.allowFrom.map((n) => normalizeWhatsAppAddress(String(n)))
: [],
dmPolicy: "allowlisted",
pending: normalizePending(rawPending),
};
if (rawPending === undefined) {
access.pending = await readLegacyPending();
}
return access;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
await writeAccessConfig(DEFAULT_ACCESS);
return { ...DEFAULT_ACCESS, allowFrom: [], pending: [] };
}
}

export async function writeAccessConfig(config: AccessConfig): Promise<void> {
await mkdir(dirname(ACCESS_PATH), { recursive: true });
const clean: AccessConfig = {
allowFrom: Array.from(new Set(config.allowFrom.map(normalizeWhatsAppAddress))).sort(),
dmPolicy: "allowlisted",
pending: normalizePending(config.pending).sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
};
await writeFile(ACCESS_PATH, `${JSON.stringify(clean, null, 2)}\n`, "utf8");
}

export async function isAllowlisted(from: string): Promise<boolean> {
const access = await readAccessConfig();
return access.allowFrom.includes(normalizeWhatsAppAddress(from));
}

function hashCode(code: string): string {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(code);
return hasher.digest("hex");
}

function codeEquals(code: string, hash: string): boolean {
const left = Buffer.from(hashCode(code), "hex");
const right = Buffer.from(hash, "hex");
return left.length === right.length && timingSafeEqual(left, right);
}

export async function issueAccessRequest(from: string): Promise<{ from: string; code: string; createdAt: string; alreadyPending: boolean }> {
const normalized = normalizeWhatsAppAddress(from);
const access = await readAccessConfig();
const existing = access.pending.find((p) => p.from === normalized);
if (existing) {
return { from: normalized, code: "", createdAt: existing.createdAt, alreadyPending: true };
}

const code = randomBytes(4).toString("hex").toUpperCase();
const createdAt = new Date().toISOString();
access.pending.push({ from: normalized, codeHash: hashCode(code), createdAt });
await writeAccessConfig(access);
console.error(`wa-channel access request: from=${normalized} code=${code} createdAt=${createdAt}`);
return { from: normalized, code, createdAt, alreadyPending: false };
}

export async function hasPendingAccessRequest(from: string): Promise<boolean> {
const normalized = normalizeWhatsAppAddress(from);
const access = await readAccessConfig();
return access.pending.some((p) => p.from === normalized);
}

export async function listAccessRequests(): Promise<Array<{ from: string; createdAt: string }>> {
const access = await readAccessConfig();
return access.pending.map(({ from, createdAt }) => ({ from, createdAt }));
}

export async function approveAccessRequest(code: string): Promise<{ approved: boolean; from?: string }> {
const access = await readAccessConfig();
const match = access.pending.find((p) => codeEquals(code, p.codeHash));
if (!match) return { approved: false };

if (!access.allowFrom.includes(match.from)) {
access.allowFrom.push(match.from);
}
access.pending = access.pending.filter((p) => p !== match);
await writeAccessConfig(access);
return { approved: true, from: match.from };
}
Loading
Loading