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
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,53 @@ agent:
allowed-user: <your-discord-user-id>
```

### WhatsApp

WhatsApp connectivity uses [wacli](https://github.com/openclaw/wacli), a small Go CLI that pairs as a
WhatsApp Web linked device. There is no Java WhatsApp library — JavaClaw launches `wacli` as a
subprocess and receives inbound messages through a local webhook.

**1. Install wacli**

```bash
# macOS
brew install openclaw/tap/wacli

# Linux
go install github.com/openclaw/wacli@latest
```

**2. Pair your phone**

Run the following and scan the QR code with WhatsApp on your phone (Settings → Linked devices → Link a
device):

```bash
wacli auth
```

**3. Configure**

Configure during onboarding or by setting:

```yaml
agent:
channels:
whatsapp:
enabled: true
wacli-path: wacli
allowed-chat-jid: "1234567890@s.whatsapp.net"
store-path: ""
```

`allowed-chat-jid` is the JID of the single WhatsApp chat the assistant responds in — a contact's
number followed by `@s.whatsapp.net` (e.g. `1234567890@s.whatsapp.net`) or a group (`@g.us`). Only
messages received in that chat are handled; messages you send yourself are ignored. `wacli-path`
defaults to `wacli` on your `PATH`, and `store-path` is optional.

> **Disclaimer:** wacli uses the unofficial WhatsApp Web protocol — use at your own risk.


## Skills

Skills extend the agent's capabilities at runtime without code changes. Create a directory under `workspace/skills/<skill-name>/` containing a `SKILL.md` file and the agent will load it automatically via `SkillsTool`.
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies {
implementation project(':plugins:telegram')
implementation project(':plugins:playwright')
implementation project(':plugins:brave')
implementation project(':plugins:whatsapp')

implementation 'org.springframework.ai:spring-ai-client-chat'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
Expand Down
12 changes: 12 additions & 0 deletions plugins/whatsapp/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
plugins {
id 'java-library'
}

dependencies {
implementation project(':base')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-json'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ai.javaclaw.channels.whatsapp;

import java.io.IOException;


@FunctionalInterface
interface ProcessLauncher {
Process launch(ProcessBuilder processBuilder) throws IOException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package ai.javaclaw.channels.whatsapp;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "agent.channels.whatsapp")
public class WacliProperties {

private boolean enabled;

private String wacliPath = "wacli";

private String allowedChatJid;

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public String getWacliPath() {
return wacliPath;
}

public void setWacliPath(String wacliPath) {
this.wacliPath = wacliPath;
}

public String getAllowedChatJid() {
return allowedChatJid;
}

public void setAllowedChatJid(String allowedChatJid) {
this.allowedChatJid = allowedChatJid;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package ai.javaclaw.channels.whatsapp;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
* Supervises the long-running {@code wacli sync} subprocess. It verifies the binary is present,
* launches the process on a background daemon thread, restarts it with exponential backoff when it
* exits unexpectedly, and gives up after too many failures in quick succession.
*
* <p>All lifecycle methods are safe to call from different threads; cross-thread state is held in
* {@code volatile} fields. Time and process creation are injected as seams so the restart/backoff
* logic can be driven deterministically in tests.
*/
class WacliSyncSupervisor {

private static final Logger LOGGER = LoggerFactory.getLogger(WacliSyncSupervisor.class);

static final int MAX_RESTART_RETRIES = 5;

static final long HEALTHY_UPTIME_MILLIS = 60_000L;

static final int DEFAULT_WEBHOOK_PORT = 8080;

private static final long BASE_BACKOFF_MILLIS = 1_000L;
private static final long MAX_BACKOFF_MILLIS = 30_000L;

private static final long VERSION_CHECK_TIMEOUT_SECONDS = 10L;

@FunctionalInterface
interface Sleeper {
void sleep(long millis) throws InterruptedException;
}

@FunctionalInterface
interface Ticker {
long nanoTime();
}

private final WacliProperties properties;
private final int webhookPort;
private final ProcessLauncher processLauncher;
private final Sleeper sleeper;
private final Ticker ticker;

private volatile boolean running;
private volatile boolean shuttingDown;
private volatile Process syncProcess;
private volatile String lastStderrLine;
private volatile Thread monitorThread;

WacliSyncSupervisor(WacliProperties properties, int webhookPort,
ProcessLauncher processLauncher, Sleeper sleeper, Ticker ticker) {
this.properties = properties;
this.webhookPort = webhookPort;
this.processLauncher = processLauncher;
this.sleeper = sleeper;
this.ticker = ticker;
}

/**
* Verifies the wacli binary and, if it is runnable, starts the background monitor thread.
*
* @return {@code true} if supervision started, {@code false} if wacli is unavailable
*/
boolean start() {
if (!verifyWacliBinary()) {
LOGGER.error("wacli binary '{}' not found or not runnable. The WhatsApp channel is disabled. "
+ "Install wacli (macOS: 'brew install openclaw/tap/wacli', "
+ "Linux: 'go install github.com/openclaw/wacli@latest').",
properties.getWacliPath());
return false;
}
running = true;
monitorThread = new Thread(this::runSyncLoop, "wacli-sync-monitor");
monitorThread.setDaemon(true);
monitorThread.start();
return true;
}

void stop() {
shuttingDown = true;
running = false;
Process process = syncProcess;
if (process != null) {
process.destroy();
try {
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
process.waitFor(2, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
}
}
Thread monitor = monitorThread;
if (monitor != null) {
monitor.interrupt();
}
}

boolean isRunning() {
return running;
}

void runSyncLoop() {
int retries = 0;
while (!shuttingDown) {
long startedNanos = ticker.nanoTime();
launchSyncProcessAndAwaitExit();
if (shuttingDown) {
return;
}
long upMillis = TimeUnit.NANOSECONDS.toMillis(ticker.nanoTime() - startedNanos);
if (upMillis >= HEALTHY_UPTIME_MILLIS) {
retries = 0;
}
retries++;
if (retries > MAX_RESTART_RETRIES) {
LOGGER.error("wacli sync process failed {} times in quick succession; "
+ "giving up and stopping the WhatsApp channel.{}",
retries, reasonSuffix());
running = false;
return;
}
backoff(retries);
}
}

private void launchSyncProcessAndAwaitExit() {
Process process;
try {
process = processLauncher.launch(syncProcessBuilder());
} catch (IOException e) {
LOGGER.warn("Failed to start wacli sync process", e);
return;
}
this.syncProcess = process;
this.lastStderrLine = null;
if (shuttingDown) {
process.destroy();
return;
}
Thread pump = pumpStderrToDebugLog(process);
try {
int exitCode = process.waitFor();
pump.join(TimeUnit.SECONDS.toMillis(1));
if (!shuttingDown) {
LOGGER.warn("wacli sync process exited unexpectedly with code {}{}", exitCode, reasonSuffix());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
shuttingDown = true;
process.destroy();
}
}

private String reasonSuffix() {
String reason = lastStderrLine;
return (reason == null || reason.isBlank()) ? "" : " (" + reason + ")";
}

private ProcessBuilder syncProcessBuilder() {
List<String> command = List.of(
properties.getWacliPath(), "sync", "--follow",
"--webhook", webhookUrl(),
"--webhook-allow-private");
return new ProcessBuilder(command).redirectOutput(ProcessBuilder.Redirect.DISCARD);
}

private String webhookUrl() {
return "http://localhost:" + webhookPort + "/api/whatsapp/webhook";
}

private boolean verifyWacliBinary() {
try {
Process process = processLauncher.launch(
new ProcessBuilder(List.of(properties.getWacliPath(), "version"))
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD));
if (!process.waitFor(VERSION_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly();
return false;
}
return process.exitValue() == 0;
} catch (IOException e) {
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}

private Thread pumpStderrToDebugLog(Process process) {
Thread pump = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
LOGGER.debug("[wacli] {}", line);
if (!line.isBlank()) {
lastStderrLine = line;
}
}
} catch (IOException e) {
LOGGER.debug("Stopped reading wacli stderr", e);
}
}, "wacli-stderr-pump");
pump.setDaemon(true);
pump.start();
return pump;
}

private void backoff(int attempt) {
long delay = Math.min(BASE_BACKOFF_MILLIS * (1L << (attempt - 1)), MAX_BACKOFF_MILLIS);
try {
sleeper.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
shuttingDown = true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ai.javaclaw.channels.whatsapp;

import com.fasterxml.jackson.annotation.JsonProperty;

public record WacliWebhookPayload(
@JsonProperty("Chat") String chat,
@JsonProperty("ID") String id,
@JsonProperty("SenderJID") String senderJid,
@JsonProperty("FromMe") boolean fromMe,
@JsonProperty("Text") String text,
@JsonProperty("PushName") String pushName,
@JsonProperty("Timestamp") String timestamp) {
}
Loading
Loading