README.md

# Irish

WhatsApp Web client for Elixir, powered by [Baileys](https://github.com/WhiskeySockets/Baileys).

Irish runs Baileys inside a Deno subprocess and talks to it over a JSON-lines
protocol on stdio. Incoming WhatsApp events land in your GenServer as plain
Elixir messages with structured data. Outgoing commands are synchronous calls
that return when WhatsApp responds.

```
Elixir GenServer  ──stdio──>  Deno (bridge.ts)  ──WebSocket──>  WhatsApp
```

## Prerequisites

- **Elixir** >= 1.18
- **Deno** >= 2.0 (`brew install deno` / [deno.land](https://deno.land))
- **npm** (ships with Node.js — only needed to install Baileys once)

## Installation

Add `irish` to your deps:

```elixir
def deps do
  [
    {:irish, "~> 1.0"}
  ]
end
```

Then fetch deps and install the Baileys bridge:

```bash
mix deps.get
mix irish.setup
```

`mix irish.setup` runs `npm install` inside Irish's `priv/` directory to fetch
Baileys and its dependencies. You only need to run it once (and again after
upgrading Irish).

## Quick start

```elixir
# Start a connection — events go to the calling process
{:ok, wa} = Irish.start_link(auth_dir: "/tmp/wa_auth", handler: self())

# First connection: scan the QR code
receive do
  {:wa, "connection.update", %{"qr" => qr}} ->
    # print or render the QR string
    IO.puts("Scan this QR code in WhatsApp > Linked Devices:\n#{qr}")
end

# Wait for the connection to open
receive do
  {:wa, "connection.update", %{"connection" => "open"}} ->
    IO.puts("Connected!")
end

# Send a text message
{:ok, %Irish.Message{} = msg} =
  Irish.send_message(wa, "15551234567@s.whatsapp.net", %{text: "Hello from Elixir!"})

# Send an image from a URL
{:ok, _} = Irish.send_message(wa, "15551234567@s.whatsapp.net", %{
  image: %{url: "https://example.com/photo.jpg"},
  caption: "Check this out"
})
```

After the first QR scan, credentials are saved to `auth_dir`. Subsequent
starts reconnect automatically — no QR needed.

## Pairing code (no QR scan)

If you prefer phone-number-based pairing:

```elixir
{:ok, wa} = Irish.start_link(auth_dir: "/tmp/wa_auth", handler: self())

# Wait for connection to reach "connecting" state, then request a code
receive do
  {:wa, "connection.update", %{"connection" => "connecting"}} -> :ok
end

{:ok, code} = Irish.request_pairing_code(wa, "15551234567")
IO.puts("Enter this code in WhatsApp: #{code}")
```

## Receiving messages

All Baileys events arrive as `{:wa, event_name, data}` messages. By default,
event data is converted to typed structs (see [Data types](#data-types) below).

```elixir
def handle_info({:wa, "messages.upsert", %{messages: messages, type: :notify}}, state) do
  for msg <- messages do
    sender = Irish.Message.from(msg)
    text = Irish.Message.text(msg)
    type = Irish.Message.type(msg)

    if text do
      IO.puts("[#{type}] #{msg.push_name} (#{sender}): #{text}")
    end

    # React to media messages
    if Irish.Message.media?(msg) do
      Irish.react(state.conn, msg.key.remote_jid, msg.key, "👀")
    end
  end
  {:noreply, state}
end

def handle_info({:wa, "contacts.upsert", contacts}, state) do
  for %Irish.Contact{} = contact <- contacts do
    IO.puts("New contact: #{Irish.Contact.display_name(contact)}")
  end
  {:noreply, state}
end

def handle_info({:wa, "presence.update", %{id: chat_id, presences: presences}}, state) do
  for {jid, %Irish.Presence{last_known_presence: presence}} <- presences do
    IO.puts("#{jid} is #{presence} in #{chat_id}")
  end
  {:noreply, state}
end

def handle_info({:wa, "call", calls}, state) do
  for %Irish.Call{} = call <- calls do
    kind = if call.is_video, do: "video", else: "voice"
    IO.puts("Incoming #{kind} call from #{call.from}: #{call.status}")
  end
  {:noreply, state}
end

def handle_info({:wa, "connection.update", %{"connection" => "close"}}, state) do
  IO.puts("Disconnected — supervisor will restart")
  {:noreply, state}
end

# Catch-all for events you don't care about
def handle_info({:wa, _event, _data}, state), do: {:noreply, state}
```

### Opting out of structs

If you prefer raw maps (the Baileys JSON as-is), pass `struct_events: false`:

```elixir
{:ok, wa} = Irish.start_link(
  auth_dir: "/tmp/wa_auth",
  handler: self(),
  struct_events: false
)

# Events now arrive as raw camelCase maps:
receive do
  {:wa, "messages.upsert", %{"messages" => messages, "type" => "notify"}} ->
    for msg <- messages do
      text = get_in(msg, ["message", "conversation"])
      IO.puts("#{msg["key"]["remoteJid"]}: #{text}")
    end
end
```

## Supervision

Add Irish to your supervision tree for automatic restarts:

```elixir
children = [
  {Irish, auth_dir: "/tmp/wa_auth", handler: MyApp.WAHandler, name: :whatsapp}
]

Supervisor.start_link(children, strategy: :one_for_one)
```

Then call functions by name:

```elixir
Irish.send_message(:whatsapp, jid, %{text: "hello"})
```

## Data types

Irish converts Baileys' camelCase JSON maps into Elixir structs with
snake_case fields. All structs provide a `from_raw/1` function for manual
conversion.

| Struct | Description | Key fields |
|---|---|---|
| `Irish.Message` | A WhatsApp message | `key`, `message`, `push_name`, `status`, `message_timestamp` |
| `Irish.MessageKey` | Identifies a specific message | `remote_jid`, `from_me`, `id`, `participant` |
| `Irish.Contact` | A WhatsApp contact | `id`, `name`, `notify`, `verified_name`, `phone_number` |
| `Irish.Chat` | A conversation | `id`, `name`, `unread_count`, `archived`, `pinned` |
| `Irish.Group` | Group metadata | `id`, `subject`, `owner`, `description`, `participants` |
| `Irish.Group.Participant` | A group member | `id`, `phone_number`, `admin` |
| `Irish.Presence` | Online/typing status | `last_known_presence`, `last_seen` |
| `Irish.Call` | A call event | `id`, `from`, `status`, `is_video`, `is_group` |

### Message helpers

`Irish.Message` provides helpers for common access patterns:

```elixir
msg = List.first(messages)

Irish.Message.text(msg)    # => "Hello!" — extracts text from any message type
Irish.Message.type(msg)    # => :text — content type atom
Irish.Message.media?(msg)  # => false — true for image/video/audio/document/sticker
Irish.Message.from(msg)    # => "15551234567@s.whatsapp.net" — sender JID
```

Content type atoms: `:text`, `:image`, `:video`, `:audio`, `:document`,
`:sticker`, `:location`, `:live_location`, `:contact`, `:contacts`,
`:reaction`, `:poll`, `:view_once`, `:ephemeral`, `:edited`, `:protocol`,
`:unknown`.

Message status atoms: `:error`, `:pending`, `:server_ack`, `:delivery_ack`,
`:read`, `:played`.

### MessageKey

`Irish.MessageKey` round-trips between structs and the raw maps the bridge
expects:

```elixir
# From an event
key = msg.key  # => %Irish.MessageKey{remote_jid: "...", from_me: false, id: "..."}

# Use directly in API calls
Irish.react(conn, key.remote_jid, key, "👍")
Irish.read_messages(conn, [key])

# Convert back to raw map if needed
Irish.MessageKey.to_raw(key)  # => %{"remoteJid" => "...", "fromMe" => false, "id" => "..."}
```

### Contact helpers

```elixir
Irish.Contact.display_name(contact)
# Returns first non-nil of: name, notify, verified_name, phone_number, id
```

## API reference

### Messaging

| Function | Returns | Description |
|---|---|---|
| `send_message(conn, jid, content, opts \\ %{})` | `{:ok, %Message{}}` | Send any message type |
| `read_messages(conn, keys)` | `{:ok, any}` | Mark messages as read (accepts `%MessageKey{}` or raw maps) |
| `react(conn, jid, key, emoji)` | `{:ok, %Message{}}` | React to a message (accepts `%MessageKey{}` or raw maps) |
| `unreact(conn, jid, key)` | `{:ok, %Message{}}` | Remove a reaction |
| `send_receipts(conn, keys, type)` | `{:ok, any}` | Send read/played receipts (accepts `%MessageKey{}` or raw maps) |
| `send_presence(conn, type, jid \\ nil)` | `{:ok, any}` | Send `"composing"`, `"available"`, etc. |
| `presence_subscribe(conn, jid)` | `{:ok, any}` | Get notified of a contact's presence |
| `download_media(conn, message)` | `{:ok, binary}` | Download and decrypt media |

### Profile

| Function | Description |
|---|---|
| `profile_picture_url(conn, jid)` | Get profile picture URL |
| `update_profile_status(conn, text)` | Update your status/about |
| `update_profile_name(conn, name)` | Update your display name |
| `fetch_status(conn, jids)` | Get status text for JIDs |
| `on_whatsapp(conn, phone_numbers)` | Check if numbers are registered |

### Groups

| Function | Returns | Description |
|---|---|---|
| `group_metadata(conn, jid)` | `{:ok, %Group{}}` | Get group info |
| `group_create(conn, subject, participants)` | `{:ok, %Group{}}` | Create a group |
| `group_fetch_all(conn)` | `{:ok, [%Group{}]}` | List all groups you're in |
| `group_get_invite_info(conn, code)` | `{:ok, %Group{}}` | Info about an invite link |
| `group_update_subject(conn, jid, subject)` | | Rename a group |
| `group_update_description(conn, jid, desc)` | | Update group description |
| `group_participants_update(conn, jid, participants, action)` | | `"add"`, `"remove"`, `"promote"`, `"demote"` |
| `group_invite_code(conn, jid)` | | Get invite link code |
| `group_leave(conn, jid)` | | Leave a group |

### Privacy

| Function | Description |
|---|---|
| `update_block_status(conn, jid, action)` | `"block"` or `"unblock"` |
| `fetch_blocklist(conn)` | Get blocked JIDs |

### Auth

| Function | Description |
|---|---|
| `request_pairing_code(conn, phone, code \\ nil)` | Phone-based pairing (no QR) |
| `logout(conn)` | Log out and invalidate session |

## Events

Key events you'll receive as `{:wa, event_name, data}`:

| Event | Struct shape | When |
|---|---|---|
| `"connection.update"` | raw map | Connection state changes, QR codes |
| `"messages.upsert"` | `%{messages: [%Message{}], type: :notify \| :append}` | New messages |
| `"messages.update"` | `[%{key: %MessageKey{}, update: map}]` | Delivery/read receipts |
| `"messages.delete"` | `%{keys: [%MessageKey{}]}` | Deleted messages |
| `"messages.reaction"` | `[%{key: %MessageKey{}, reaction: map}]` | Reactions |
| `"message-receipt.update"` | `[%{key: %MessageKey{}, receipt: map}]` | Granular receipts |
| `"chats.upsert"` | `[%Chat{}]` | New chats appear |
| `"chats.update"` | `[%Chat{}]` | Chat metadata changes |
| `"contacts.upsert"` | `[%Contact{}]` | New contacts |
| `"contacts.update"` | `[%Contact{}]` | Contact changes |
| `"groups.upsert"` | `[%Group{}]` | New groups |
| `"groups.update"` | `[%Group{}]` | Group metadata changes |
| `"group-participants.update"` | raw map | Members added/removed/promoted |
| `"presence.update"` | `%{id: jid, presences: %{jid => %Presence{}}}` | Typing, online status |
| `"call"` | `[%Call{}]` | Incoming calls |
| `"creds.update"` | raw map | Session credentials changed |
| `"messaging-history.set"` | raw map | History sync chunks |

## Options

| Option | Default | Description |
|---|---|---|
| `:auth_store` | file store | `{module, opts}` — custom auth persistence ([guide](guides/auth-stores.md)) |
| `:auth_dir` | `"./wa_auth"` | Directory to store WhatsApp session (shorthand for file store) |
| `:handler` | caller PID | PID to receive `{:wa, event, data}` messages |
| `:name` | none | Optional registered name for the process |
| `:config` | `%{}` | Baileys socket config overrides |
| `:timeout` | `30_000` | Default command timeout in ms |
| `:struct_events` | `true` | Convert event data to structs (`false` for raw maps) |

## JID format

WhatsApp identifies users and groups by JID:

- **User:** `15551234567@s.whatsapp.net` (country code + number, no `+` or spaces)
- **Group:** `120363001234567890@g.us`
- **Status broadcast:** `status@broadcast`

## Message content types

The `content` argument to `send_message/4` is a map matching Baileys'
`AnyMessageContent`. Common shapes:

```elixir
# Text
%{text: "Hello!"}

# Image (from URL)
%{image: %{url: "https://..."}, caption: "optional"}

# Video
%{video: %{url: "https://..."}, caption: "optional"}

# Document
%{document: %{url: "https://..."}, mimetype: "application/pdf", fileName: "doc.pdf"}

# Audio voice note
%{audio: %{url: "https://..."}, ptt: true}

# Location
%{location: %{degreesLatitude: 40.7128, degreesLongitude: -74.0060}}

# Contact
%{contacts: %{displayName: "Jane", contacts: [%{vcard: "BEGIN:VCARD\n..."}]}}

# Reaction (accepts %MessageKey{} struct or raw map)
%{react: %{text: "👍", key: msg.key}}

# Reply (pass original message as option)
Irish.send_message(conn, jid, %{text: "replying!"}, %{quoted: original_msg})
```

## How it works

Irish spawns a Deno process running `bridge.ts`, which creates a Baileys
WhatsApp socket and bridges it to Elixir over stdin/stdout:

- **Events** (WhatsApp -> Elixir): Baileys emits events; the bridge serializes
  them as JSON lines on stdout; the GenServer decodes them, converts to structs
  via `Irish.Event`, and forwards to your handler as `{:wa, event, data}`.

- **Commands** (Elixir -> WhatsApp): `Irish.send_message/4` etc. write a JSON
  command with a correlation ID to the bridge's stdin. The bridge calls the
  corresponding Baileys method and writes the result back. The GenServer matches
  the response to the waiting caller.

- **Reconnection**: If the WhatsApp connection drops (but isn't a logout), the
  bridge reconnects internally. If the bridge process itself crashes, the
  GenServer exits and your supervisor restarts it.

- **Auth state**: Credentials and Signal keys are persisted through the
  `Irish.Auth.Store` behaviour. By default, files in `auth_dir` (matching
  Baileys' layout). Pass `auth_store: {MyStore, opts}` for database or
  other backends — see the [Custom Auth Stores](guides/auth-stores.md) guide.

## Guides

- [Building a GenServer Handler](guides/integration.md) — supervision,
  connection lifecycle, reconnection, test mode
- [Custom Auth Stores](guides/auth-stores.md) — store credentials in a
  database, Redis, S3, or any custom backend
- [Common Patterns](guides/common-patterns.md) — event filtering, message
  processing, media downloads, groups, LID translation, error handling,
  telemetry
- [Event Reference](guides/events.md) — typed event structs, compatibility
  policy

## License

MIT