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
49 changes: 49 additions & 0 deletions base/src/main/java/ai/javaclaw/cli/CliRunner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package ai.javaclaw.cli;

import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Component
public class CliRunner {

public CliResult run(List<String> command) throws IOException, InterruptedException {
return run(command, Duration.ofSeconds(10));
}

public CliResult run(List<String> command, Duration timeout) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).start();
boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!finished) {
process.destroyForcibly();
throw new IOException("Command timed out after " + timeout + ": " + command);
}
String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
return new CliResult(process.exitValue(), stdout, stderr);
}

public Process startDaemon(List<String> command) throws IOException {
return new ProcessBuilder(command)
.redirectErrorStream(true)
.start();
}

public record CliResult(int exitCode, String stdout, String stderr) {

public boolean isSuccess() {
return exitCode == 0;
}

public String requireSuccess() throws IOException {
if (!isSuccess()) {
throw new IOException("Command failed (exit " + exitCode + "): " + stderr.strip());
}
return stdout;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ai.javaclaw.utils.threads;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public class NamedThreadFactory implements ThreadFactory {
private final AtomicInteger threadCount = new AtomicInteger();
private final String threadNamePrefix;
private final boolean daemon;

public NamedThreadFactory(String threadNamePrefix, boolean daemon) {
this.threadNamePrefix = threadNamePrefix;
this.daemon = daemon;
}

@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, this.threadNamePrefix + "-" + this.threadCount.getAndIncrement());
thread.setDaemon(daemon);
return thread;
}
}
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;
}
}
Loading
Loading