diff --git a/README.md b/README.md index b2cd645..d3b8257 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,53 @@ agent: allowed-user: ``` +### 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//` containing a `SKILL.md` file and the agent will load it automatically via `SkillsTool`. diff --git a/app/build.gradle b/app/build.gradle index 22e2ce0..53538ac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/plugins/whatsapp/build.gradle b/plugins/whatsapp/build.gradle new file mode 100644 index 0000000..9db7b07 --- /dev/null +++ b/plugins/whatsapp/build.gradle @@ -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' +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/ProcessLauncher.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/ProcessLauncher.java new file mode 100644 index 0000000..d4dfb64 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/ProcessLauncher.java @@ -0,0 +1,9 @@ +package ai.javaclaw.channels.whatsapp; + +import java.io.IOException; + + +@FunctionalInterface +interface ProcessLauncher { + Process launch(ProcessBuilder processBuilder) throws IOException; +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliProperties.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliProperties.java new file mode 100644 index 0000000..c7e8f9b --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliProperties.java @@ -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; + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisor.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisor.java new file mode 100644 index 0000000..ec194a4 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisor.java @@ -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. + * + *

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 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; + } + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayload.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayload.java new file mode 100644 index 0000000..5989550 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayload.java @@ -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) { +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannel.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannel.java new file mode 100644 index 0000000..e15b8b1 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannel.java @@ -0,0 +1,177 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.channels.Channel; +import ai.javaclaw.channels.ChannelRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class WacliWhatsAppChannel implements Channel { + + private static final Logger LOGGER = LoggerFactory.getLogger(WacliWhatsAppChannel.class); + + static final String CHANNEL_ID = "whatsapp"; + + private static final long SEND_TIMEOUT_SECONDS = 10L; + + /** How many recently-sent message IDs to remember for echo suppression. */ + private static final int RECENT_SENT_ID_CAPACITY = 256; + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + private final WacliProperties properties; + private final ChannelRegistry channelRegistry; + private final ProcessLauncher processLauncher; + private final WacliSyncSupervisor supervisor; + + /** + * IDs of messages this channel has sent, so inbound webhooks that echo our own replies can be + * distinguished from messages a human actually typed (which matters on a single WhatsApp + * account, where both carry {@code FromMe=true}). Bounded, insertion-order eviction; the echo + * always arrives within seconds of the send. + */ + private final Set recentlySentIds = Collections.newSetFromMap( + // +1 capacity leaves room for the transient entry that briefly overshoots before + // removeEldestEntry (checked after insertion) evicts it, avoiding a resize. + Collections.synchronizedMap(new LinkedHashMap<>(RECENT_SENT_ID_CAPACITY + 1, 1.0f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > RECENT_SENT_ID_CAPACITY; + } + })); + + public WacliWhatsAppChannel(WacliProperties properties, ChannelRegistry channelRegistry, int webhookPort) { + this(properties, channelRegistry, webhookPort, ProcessBuilder::start, Thread::sleep, System::nanoTime); + } + + WacliWhatsAppChannel(WacliProperties properties, ChannelRegistry channelRegistry, int webhookPort, + ProcessLauncher processLauncher, + WacliSyncSupervisor.Sleeper sleeper, WacliSyncSupervisor.Ticker ticker) { + this.properties = properties; + this.channelRegistry = channelRegistry; + this.processLauncher = processLauncher; + this.supervisor = new WacliSyncSupervisor(properties, webhookPort, processLauncher, sleeper, ticker); + } + + @Override + public String getName() { + return CHANNEL_ID; + } + + @PostConstruct + public void start() { + if (supervisor.start()) { + channelRegistry.registerChannel(this); + LOGGER.info("Started WhatsApp integration via wacli"); + } + } + + @PreDestroy + public void stop() { + supervisor.stop(); + channelRegistry.unregisterChannel(this); + } + + @Override + public void sendMessage(String message) { + if (message == null || message.isBlank()) { + return; + } + if (!supervisor.isRunning()) { + LOGGER.warn("WhatsApp channel is not running, cannot send message '{}'", message); + return; + } + + List command = List.of( + properties.getWacliPath(), "send", "text", + "--to", properties.getAllowedChatJid(), + "--message", message, + "--json"); + + Process process = null; + try { + process = processLauncher.launch(new ProcessBuilder(command).redirectErrorStream(true)); + if (!process.waitFor(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + LOGGER.error("wacli send timed out after {}s for WhatsApp message '{}'", + SEND_TIMEOUT_SECONDS, message); + return; + } + String output = readOutput(process); + if (process.exitValue() != 0) { + LOGGER.error("wacli send exited with code {}. Output (stdout/stderr): {}", + process.exitValue(), output); + } else { + rememberSentMessageId(output); + } + } catch (IOException e) { + LOGGER.error("Failed to run wacli send for WhatsApp message '{}'", message, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (process != null) { + process.destroyForcibly(); + } + LOGGER.error("Interrupted while sending WhatsApp message '{}'", message, e); + } + } + + private static String readOutput(Process process) throws IOException { + try (InputStream in = process.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + } + + /** + * @return {@code true} if {@code messageId} identifies a message this channel recently sent, so + * an inbound webhook echoing our own reply can be dropped instead of answered again + */ + boolean wasSentByAgent(String messageId) { + return messageId != null && recentlySentIds.contains(messageId); + } + + private void rememberSentMessageId(String wacliSendOutput) { + String id = extractMessageId(wacliSendOutput); + if (id != null) { + recentlySentIds.add(id); + } else { + LOGGER.debug("No message ID in wacli send output; this reply's echo may not be suppressed: {}", + wacliSendOutput); + } + } + + private static String extractMessageId(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + WacliSendResponse response = JSON_MAPPER.readValue(json, WacliSendResponse.class); + String id = response.data() == null ? null : response.data().id(); + return (id == null || id.isBlank()) ? null : id; + } catch (RuntimeException e) { + LOGGER.debug("Could not parse wacli send output: {}", json, e); + return null; + } + } + + private record WacliSendResponse(Data data) { + private record Data(String id) { + } + } + + boolean isRunning() { + return supervisor.isRunning(); + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelAutoConfiguration.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelAutoConfiguration.java new file mode 100644 index 0000000..711889d --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelAutoConfiguration.java @@ -0,0 +1,22 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.channels.ChannelRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableConfigurationProperties(WacliProperties.class) +public class WhatsAppChannelAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "agent.channels.whatsapp", name = "enabled", havingValue = "true") + public WacliWhatsAppChannel wacliWhatsAppChannel(WacliProperties properties, ChannelRegistry channelRegistry, + @Value("${server.port:8080}") int webhookPort) { + return new WacliWhatsAppChannel(properties, channelRegistry, webhookPort); + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelMessageReceivedEvent.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelMessageReceivedEvent.java new file mode 100644 index 0000000..2f62c2b --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelMessageReceivedEvent.java @@ -0,0 +1,17 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.channels.ChannelMessageReceivedEvent; + +public class WhatsAppChannelMessageReceivedEvent extends ChannelMessageReceivedEvent { + + private final String conversationId; + + public WhatsAppChannelMessageReceivedEvent(String channel, String message, String conversationId) { + super(channel, message); + this.conversationId = conversationId; + } + + public String getConversationId() { + return conversationId; + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProvider.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProvider.java new file mode 100644 index 0000000..701e1df --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProvider.java @@ -0,0 +1,152 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.onboarding.OnboardingProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +@Component +@Order(56) +public class WhatsAppOnboardingProvider implements OnboardingProvider { + + static final String SESSION_ALLOWED_JID = "onboarding.whatsapp.allowed-chat-jid"; + + private static final String ENABLED_PROPERTY = "agent.channels.whatsapp.enabled"; + private static final String ALLOWED_JID_PROPERTY = "agent.channels.whatsapp.allowed-chat-jid"; + + private static final Pattern JID_PATTERN = + Pattern.compile("^[0-9A-Za-z._-]+@(s\\.whatsapp\\.net|g\\.us|lid|newsletter|broadcast)$"); + + private final Environment env; + private final WacliCli wacliCli; + + @Autowired + public WhatsAppOnboardingProvider(Environment env) { + this(env, new DefaultWacliCli(env.getProperty("agent.channels.whatsapp.wacli-path", "wacli"))); + } + + WhatsAppOnboardingProvider(Environment env, WacliCli wacliCli) { + this.env = env; + this.wacliCli = wacliCli; + } + + @Override + public boolean isOptional() {return true;} + + @Override + public String getStepId() {return "whatsapp";} + + @Override + public String getStepTitle() {return "WhatsApp";} + + @Override + public String getTemplatePath() {return "onboarding/steps/whatsapp";} + + @Override + public void prepareModel(Map session, Map model) { + boolean installed = wacliCli.isInstalled(); + model.put("wacliInstalled", installed); + model.put("wacliPaired", installed && wacliCli.isPaired()); + model.put("whatsappAllowedChatJid", session.getOrDefault(SESSION_ALLOWED_JID, + env.getProperty(ALLOWED_JID_PROPERTY, ""))); + } + + @Override + public String processStep(Map formParams, Map session) { + if (!wacliCli.isInstalled()) { + return "wacli is not installed. Install it (macOS: 'brew install openclaw/tap/wacli', " + + "Linux: 'go install github.com/openclaw/wacli@latest') and try again."; + } + + String jid = normalizeJid(formParams.get("whatsappAllowedChatJid")); + if (jid == null) { + return "Enter the WhatsApp chat JID in the format 1234567890@s.whatsapp.net."; + } + if (!JID_PATTERN.matcher(jid).matches()) { + return "That doesn't look like a valid WhatsApp chat JID. Use the format 1234567890@s.whatsapp.net."; + } + + session.put(SESSION_ALLOWED_JID, jid); + + if (!wacliCli.isPaired()) { + return "wacli is not paired yet. Run 'wacli auth' in your terminal, scan the QR code with " + + "WhatsApp on your phone (Linked devices), then click Continue."; + } + + return null; + } + + @Override + public void saveConfiguration(Map session, ConfigurationManager configurationManager) throws IOException { + String jid = (String) session.get(SESSION_ALLOWED_JID); + if (jid == null) { + return; + } + Map properties = new LinkedHashMap<>(); + properties.put(ENABLED_PROPERTY, true); + properties.put(ALLOWED_JID_PROPERTY, jid); + configurationManager.updateProperties(properties); + } + + private static String normalizeJid(String jid) { + if (jid == null) { + return null; + } + String normalized = jid.trim(); + return normalized.isBlank() ? null : normalized; + } + + interface WacliCli { + boolean isInstalled(); + + boolean isPaired(); + } + + static class DefaultWacliCli implements WacliCli { + + private final String wacliPath; + + DefaultWacliCli(String wacliPath) { + this.wacliPath = wacliPath; + } + + @Override + public boolean isInstalled() { + return runQuietly(List.of(wacliPath, "version")); + } + + @Override + public boolean isPaired() { + return runQuietly(Arrays.asList(wacliPath, "auth", "status", "--json")); + } + + private boolean runQuietly(List command) { + try { + Process process = new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (IOException e) { + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookController.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookController.java new file mode 100644 index 0000000..a75f366 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookController.java @@ -0,0 +1,107 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@RestController +@ConditionalOnProperty(prefix = "agent.channels.whatsapp", name = "enabled", havingValue = "true") +public class WhatsAppWebhookController { + + private static final Logger LOGGER = LoggerFactory.getLogger(WhatsAppWebhookController.class); + + private final WacliProperties properties; + private final ChannelRegistry channelRegistry; + private final Agent agent; + private final WacliWhatsAppChannel channel; + private final Executor executor; + + @Autowired + public WhatsAppWebhookController(WacliProperties properties, ChannelRegistry channelRegistry, + Agent agent, WacliWhatsAppChannel channel) { + this(properties, channelRegistry, agent, channel, defaultExecutor()); + } + + WhatsAppWebhookController(WacliProperties properties, ChannelRegistry channelRegistry, + Agent agent, WacliWhatsAppChannel channel, Executor executor) { + this.properties = properties; + this.channelRegistry = channelRegistry; + this.agent = agent; + this.channel = channel; + this.executor = executor; + } + + private static ExecutorService defaultExecutor() { + return Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "whatsapp-webhook-worker"); + thread.setDaemon(true); + return thread; + }); + } + + @PreDestroy + public void shutdown() { + if (executor instanceof ExecutorService service) { + service.shutdown(); + } + } + + @PostMapping("/api/whatsapp/webhook") + public ResponseEntity webhook(@RequestBody(required = false) WacliWebhookPayload payload) { + if (payload == null) { + return ResponseEntity.ok().build(); + } + + if (channel.wasSentByAgent(payload.id())) { + // Our own outbound replies are echoed back by 'wacli sync'. Drop them by message ID + // (not FromMe) so the agent does not answer itself in a loop -- keying on the ID is what + // lets you drive the assistant from your own "Message Yourself" chat on a single number, + // where the messages you type also carry FromMe=true. + return ResponseEntity.ok().build(); + } + + if (!isAllowedChat(payload.chat())) { + LOGGER.warn("Ignoring WhatsApp message from unauthorized chat '{}'", payload.chat()); + return ResponseEntity.ok().build(); + } + String text = payload.text(); + if (text == null || text.isBlank()) { + return ResponseEntity.ok().build(); + } + + String conversationId = payload.chat(); + channelRegistry.publishMessageReceivedEvent( + new WhatsAppChannelMessageReceivedEvent(channel.getName(), text, conversationId)); + executor.execute(() -> handleMessage(conversationId, text)); + return ResponseEntity.ok().build(); + } + + private void handleMessage(String conversationId, String text) { + try { + String response = agent.respondTo(conversationId, text); + channel.sendMessage(response); + } catch (RuntimeException e) { + LOGGER.error("Failed to handle WhatsApp message for chat '{}'", conversationId, e); + } + } + + private boolean isAllowedChat(String chatJid) { + String allowed = properties.getAllowedChatJid(); + if (allowed == null || allowed.isBlank() || chatJid == null) { + return false; + } + return allowed.trim().equalsIgnoreCase(chatJid.trim()); + } +} diff --git a/plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..bde0698 --- /dev/null +++ b/plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +ai.javaclaw.channels.whatsapp.WhatsAppChannelAutoConfiguration diff --git a/plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb b/plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb new file mode 100644 index 0000000..4fc0f31 --- /dev/null +++ b/plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb @@ -0,0 +1,66 @@ +

+

Step {{ currentStepNumber }} of {{ totalSteps }}

+

Connect WhatsApp.

+

JavaClaw talks to WhatsApp through + wacli, a small CLI that pairs as a + WhatsApp Web linked device. Only the WhatsApp account you configure below will be allowed to + message your assistant. +

+ + {% if error %} +
+
{{ error }}
+
+ {% endif %} + + {% if not wacliInstalled %} +
+
+

wacli is not installed

+

Install it, then click Continue:

+
macOS:  brew install openclaw/tap/wacli
+Linux:  go install github.com/openclaw/wacli@latest
+
+
+ {% elseif not wacliPaired %} +
+
+

Pair your phone

+

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

+
wacli auth
+

Once pairing succeeds, click Continue.

+
+
+ {% else %} +
+
wacli is installed and paired. 🎉
+
+ {% endif %} + +
+
+ +
+ +
+

The JID of the WhatsApp chat the assistant should respond 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 this chat are handled.

+
+ +
+

Stored properties

+

agent.channels.whatsapp.enabled

+

agent.channels.whatsapp.allowed-chat-jid

+

agent.channels.whatsapp.wacli-path

+
+ +
+ Back + + {% if isOptional %}Skip{% endif %} + Saving... +
+
+
diff --git a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisorTest.java b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisorTest.java new file mode 100644 index 0000000..3fa5cca --- /dev/null +++ b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisorTest.java @@ -0,0 +1,152 @@ +package ai.javaclaw.channels.whatsapp; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WacliSyncSupervisorTest { + + private WacliProperties properties() { + WacliProperties properties = new WacliProperties(); + properties.setEnabled(true); + properties.setWacliPath("wacli"); + properties.setAllowedChatJid("1234567890@s.whatsapp.net"); + return properties; + } + + private WacliSyncSupervisor newSupervisor(ProcessLauncher launcher, WacliSyncSupervisor.Sleeper sleeper) { + return newSupervisor(launcher, sleeper, System::nanoTime); + } + + private WacliSyncSupervisor newSupervisor(ProcessLauncher launcher, WacliSyncSupervisor.Sleeper sleeper, + WacliSyncSupervisor.Ticker ticker) { + return new WacliSyncSupervisor(properties(), WacliSyncSupervisor.DEFAULT_WEBHOOK_PORT, + launcher, sleeper, ticker); + } + + @Test + void restartsSyncProcessOnUnexpectedExitAndGivesUpAfterMaxRetries() throws Exception { + ProcessLauncher launcher = mock(ProcessLauncher.class); + Process process = mock(Process.class); + when(process.waitFor()).thenReturn(1); + when(process.getErrorStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + when(launcher.launch(any())).thenReturn(process); + + WacliSyncSupervisor supervisor = newSupervisor(launcher, millis -> { }); + + supervisor.runSyncLoop(); + + verify(launcher, times(1 + WacliSyncSupervisor.MAX_RESTART_RETRIES)).launch(any()); + assertThat(supervisor.isRunning()).isFalse(); + } + + @Test + void resetsRetryCounterAfterHealthyUptimeSoItKeepsRestarting() throws Exception { + ProcessLauncher launcher = mock(ProcessLauncher.class); + Process process = mock(Process.class); + when(process.waitFor()).thenReturn(1); + when(process.getErrorStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + when(launcher.launch(any())).thenReturn(process); + + AtomicLong clock = new AtomicLong(); + WacliSyncSupervisor.Ticker ticker = () -> clock.getAndAdd(TimeUnit.MINUTES.toNanos(2)); + + WacliSyncSupervisor[] ref = new WacliSyncSupervisor[1]; + AtomicInteger backoffs = new AtomicInteger(); + WacliSyncSupervisor.Sleeper sleeper = millis -> { + if (backoffs.incrementAndGet() >= 20) { + ref[0].stop(); + } + }; + WacliSyncSupervisor supervisor = newSupervisor(launcher, sleeper, ticker); + ref[0] = supervisor; + + supervisor.runSyncLoop(); + + verify(launcher, atLeast(1 + WacliSyncSupervisor.MAX_RESTART_RETRIES + 1)).launch(any()); + } + + @Test + void stopDuringBackoffHaltsTheRestartLoop() throws Exception { + ProcessLauncher launcher = mock(ProcessLauncher.class); + Process process = mock(Process.class); + when(process.waitFor()).thenReturn(1); + when(process.getErrorStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + when(launcher.launch(any())).thenReturn(process); + + WacliSyncSupervisor[] ref = new WacliSyncSupervisor[1]; + WacliSyncSupervisor supervisor = newSupervisor(launcher, millis -> ref[0].stop()); + ref[0] = supervisor; + + supervisor.runSyncLoop(); + + verify(launcher, times(1)).launch(any()); + } + + @Test + void destroysProcessLaunchedAfterShutdownWasRequested() throws Exception { + Process process = mock(Process.class); + WacliSyncSupervisor[] ref = new WacliSyncSupervisor[1]; + ProcessLauncher launcher = mock(ProcessLauncher.class); + when(launcher.launch(any())).thenAnswer(invocation -> { + ref[0].stop(); + return process; + }); + + WacliSyncSupervisor supervisor = newSupervisor(launcher, millis -> { }); + ref[0] = supervisor; + + supervisor.runSyncLoop(); + + verify(process).destroy(); + verify(process, never()).waitFor(); + verify(launcher, times(1)).launch(any()); + } + + @Test + void destroysProcessWhenInterruptedWhileAwaitingExit() throws Exception { + Process process = mock(Process.class); + when(process.getErrorStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + when(process.waitFor()).thenThrow(new InterruptedException("stopping")); + ProcessLauncher launcher = mock(ProcessLauncher.class); + when(launcher.launch(any())).thenReturn(process); + + WacliSyncSupervisor supervisor = newSupervisor(launcher, millis -> { }); + + supervisor.runSyncLoop(); + + verify(process).destroy(); + verify(launcher, times(1)).launch(any()); + assertThat(Thread.interrupted()).isTrue(); + } + + @Test + void doesNotStartWhenWacliBinaryIsMissing() throws Exception { + Process versionProcess = mock(Process.class); + when(versionProcess.waitFor(anyLong(), any())).thenReturn(true); + when(versionProcess.exitValue()).thenReturn(1); + ProcessLauncher launcher = mock(ProcessLauncher.class); + when(launcher.launch(any())).thenReturn(versionProcess); + + WacliSyncSupervisor supervisor = newSupervisor(launcher, millis -> { }); + + boolean started = supervisor.start(); + + assertThat(started).isFalse(); + assertThat(supervisor.isRunning()).isFalse(); + verify(launcher, times(1)).launch(any()); + } +} diff --git a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayloadTest.java b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayloadTest.java new file mode 100644 index 0000000..bd38680 --- /dev/null +++ b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayloadTest.java @@ -0,0 +1,51 @@ +package ai.javaclaw.channels.whatsapp; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +class WacliWebhookPayloadTest { + + private final JsonMapper mapper = JsonMapper.builder().build(); + + @Test + void bindsRealWacliWireFormat() throws Exception { + String json = """ + { + "Chat": "1234567890@s.whatsapp.net", + "ID": "3EB0ABCDEF", + "SenderJID": "1234567890@s.whatsapp.net", + "Timestamp": "2024-01-03T00:00:00Z", + "FromMe": false, + "Text": "hello there", + "PushName": "Alice", + "Buttons": null, + "Media": null, + "IsForwarded": false, + "ForwardingScore": 0, + "Starred": false + } + """; + + WacliWebhookPayload payload = mapper.readValue(json, WacliWebhookPayload.class); + + assertThat(payload.chat()).isEqualTo("1234567890@s.whatsapp.net"); + assertThat(payload.senderJid()).isEqualTo("1234567890@s.whatsapp.net"); + assertThat(payload.fromMe()).isFalse(); + assertThat(payload.text()).isEqualTo("hello there"); + assertThat(payload.id()).isEqualTo("3EB0ABCDEF"); + assertThat(payload.pushName()).isEqualTo("Alice"); + assertThat(payload.timestamp()).isEqualTo("2024-01-03T00:00:00Z"); + } + + @Test + void bindsFromMeTrue() throws Exception { + String json = "{\"Chat\":\"1@s.whatsapp.net\",\"FromMe\":true,\"Text\":\"mine\"}"; + + WacliWebhookPayload payload = mapper.readValue(json, WacliWebhookPayload.class); + + assertThat(payload.fromMe()).isTrue(); + assertThat(payload.chat()).isEqualTo("1@s.whatsapp.net"); + } +} diff --git a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannelTest.java b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannelTest.java new file mode 100644 index 0000000..4b98c8b --- /dev/null +++ b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannelTest.java @@ -0,0 +1,174 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.channels.ChannelRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WacliWhatsAppChannelTest { + + private final ChannelRegistry channelRegistry = new ChannelRegistry(); + private final CountDownLatch syncRelease = new CountDownLatch(1); + private final AtomicReference> sendCommand = new AtomicReference<>(); + private final Process sendProcess = mock(Process.class); + private WacliWhatsAppChannel started; + + @AfterEach + void tearDown() { + if (started != null) { + started.stop(); + } + syncRelease.countDown(); + } + + private WacliProperties properties() { + WacliProperties properties = new WacliProperties(); + properties.setEnabled(true); + properties.setWacliPath("wacli"); + properties.setAllowedChatJid("1234567890@s.whatsapp.net"); + return properties; + } + + private WacliWhatsAppChannel newChannel(WacliProperties props, ProcessLauncher launcher, + WacliSyncSupervisor.Sleeper sleeper) { + return new WacliWhatsAppChannel(props, channelRegistry, + WacliSyncSupervisor.DEFAULT_WEBHOOK_PORT, launcher, sleeper, System::nanoTime); + } + + private WacliWhatsAppChannel startedChannel(WacliProperties props) throws InterruptedException { + Process versionProcess = mock(Process.class); + when(versionProcess.waitFor(anyLong(), any())).thenReturn(true); + when(versionProcess.exitValue()).thenReturn(0); + + Process syncProcess = mock(Process.class); + when(syncProcess.getErrorStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + when(syncProcess.waitFor()).thenAnswer(invocation -> { + syncRelease.await(); + return 0; + }); + + ProcessLauncher launcher = processBuilder -> { + List command = processBuilder.command(); + if (command.contains("version")) { + return versionProcess; + } + if (command.contains("sync")) { + return syncProcess; + } + sendCommand.set(command); + return sendProcess; + }; + + WacliWhatsAppChannel channel = newChannel(props, launcher, _ -> { }); + channel.start(); + started = channel; + return channel; + } + + @Test + void sendMessageInvokesWacliSendWithConfiguredArguments() throws Exception { + when(sendProcess.waitFor(anyLong(), any())).thenReturn(true); + when(sendProcess.exitValue()).thenReturn(0); + when(sendProcess.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + + WacliWhatsAppChannel channel = startedChannel(properties()); + + channel.sendMessage("hi there"); + + assertThat(sendCommand.get()).containsExactly( + "wacli", "send", "text", + "--to", "1234567890@s.whatsapp.net", + "--message", "hi there", + "--json"); + } + + @Test + void remembersSentMessageIdSoItsEchoCanBeSuppressed() throws Exception { + // Exact 'wacli send --json' output: the message ID is nested under "data". + String wacliSendResponse = """ + { + "success": true, + "data": { "id": "3EB062AF5A2B5A7066B238", "sent": true, "to": "95318997741682@lid" }, + "error": null + } + """; + when(sendProcess.waitFor(anyLong(), any())).thenReturn(true); + when(sendProcess.exitValue()).thenReturn(0); + when(sendProcess.getInputStream()).thenReturn(new ByteArrayInputStream( + wacliSendResponse.getBytes(StandardCharsets.UTF_8))); + + WacliWhatsAppChannel channel = startedChannel(properties()); + + channel.sendMessage("hi there"); + + assertThat(channel.wasSentByAgent("3EB062AF5A2B5A7066B238")).isTrue(); + assertThat(channel.wasSentByAgent("someone-elses-id")).isFalse(); + assertThat(channel.wasSentByAgent(null)).isFalse(); + } + + @Test + void sendMessageForciblyDestroysProcessThatTimesOut() throws Exception { + when(sendProcess.waitFor(anyLong(), any())).thenReturn(false); + + WacliWhatsAppChannel channel = startedChannel(properties()); + + channel.sendMessage("hello"); + + verify(sendProcess).destroyForcibly(); + verify(sendProcess, never()).exitValue(); + } + + @Test + void skipsLaunchWhenNotRunning() throws IOException { + ProcessLauncher launcher = mock(ProcessLauncher.class); + WacliWhatsAppChannel channel = newChannel(properties(), launcher, millis -> { }); + + channel.stop(); + + channel.sendMessage("hello"); + + verify(launcher, times(0)).launch(any()); + } + + @Test + void doesNotRegisterWhenWacliBinaryIsMissing() throws Exception { + Process versionProcess = mock(Process.class); + when(versionProcess.waitFor(anyLong(), any())).thenReturn(true); + when(versionProcess.exitValue()).thenReturn(1); + ProcessLauncher launcher = mock(ProcessLauncher.class); + when(launcher.launch(any())).thenReturn(versionProcess); + + WacliWhatsAppChannel channel = newChannel(properties(), launcher, millis -> { }); + + channel.start(); + + assertThat(channel.isRunning()).isFalse(); + assertThat(channelRegistry.getLatestChannel()).isNull(); + } + + @Test + void stopUnregistersChannelFromRegistry() throws InterruptedException { + WacliWhatsAppChannel channel = startedChannel(properties()); + assertThat(channelRegistry.getLatestChannel()).isSameAs(channel); + + channel.stop(); + started = null; + + assertThat(channelRegistry.getLatestChannel()).isNull(); + } +} diff --git a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProviderTest.java b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProviderTest.java new file mode 100644 index 0000000..9a10bed --- /dev/null +++ b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProviderTest.java @@ -0,0 +1,136 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.configuration.ConfigurationManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; + +import java.util.HashMap; +import java.util.Map; + +import static ai.javaclaw.channels.whatsapp.WhatsAppOnboardingProvider.SESSION_ALLOWED_JID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppOnboardingProviderTest { + + @Mock + Environment environment; + + @Mock + ConfigurationManager configurationManager; + + private WhatsAppOnboardingProvider provider(boolean installed, boolean paired) { + return new WhatsAppOnboardingProvider(environment, new WhatsAppOnboardingProvider.WacliCli() { + @Override + public boolean isInstalled() { + return installed; + } + + @Override + public boolean isPaired() { + return paired; + } + }); + } + + @Test + void stepMetadataIsCorrect() { + WhatsAppOnboardingProvider provider = provider(true, true); + + assertThat(provider.getStepId()).isEqualTo("whatsapp"); + assertThat(provider.getStepTitle()).isEqualTo("WhatsApp"); + assertThat(provider.getTemplatePath()).isEqualTo("onboarding/steps/whatsapp"); + assertThat(provider.isOptional()).isTrue(); + } + + @Test + void processStepBlocksWhenWacliNotInstalled() { + WhatsAppOnboardingProvider provider = provider(false, false); + + String result = provider.processStep(Map.of("whatsappAllowedChatJid", "1234567890@s.whatsapp.net"), new HashMap<>()); + + assertThat(result).contains("wacli is not installed"); + } + + @Test + void processStepRejectsInvalidJid() { + WhatsAppOnboardingProvider provider = provider(true, true); + + String result = provider.processStep(Map.of("whatsappAllowedChatJid", "not-a-jid"), new HashMap<>()); + + assertThat(result).contains("valid WhatsApp chat JID"); + } + + @Test + void processStepAcceptsChatJidWithDeviceSuffix() { + WhatsAppOnboardingProvider provider = provider(true, true); + Map session = new HashMap<>(); + + String result = provider.processStep(Map.of("whatsappAllowedChatJid", "235137262432490_1@s.whatsapp.net"), session); + + assertThat(result).isNull(); + assertThat(session).containsEntry(SESSION_ALLOWED_JID, "235137262432490_1@s.whatsapp.net"); + } + + @Test + void processStepBlocksWhenNotPairedButKeepsJid() { + WhatsAppOnboardingProvider provider = provider(true, false); + Map session = new HashMap<>(); + + String result = provider.processStep(Map.of("whatsappAllowedChatJid", "1234567890@s.whatsapp.net"), session); + + assertThat(result).contains("not paired"); + assertThat(session).containsEntry(SESSION_ALLOWED_JID, "1234567890@s.whatsapp.net"); + } + + @Test + void processStepStoresJidWhenInstalledAndPaired() { + WhatsAppOnboardingProvider provider = provider(true, true); + Map session = new HashMap<>(); + + String result = provider.processStep(Map.of("whatsappAllowedChatJid", " 1234567890@s.whatsapp.net "), session); + + assertThat(result).isNull(); + assertThat(session).containsEntry(SESSION_ALLOWED_JID, "1234567890@s.whatsapp.net"); + } + + @Test + void saveConfigurationWritesEnabledAndJid() throws Exception { + WhatsAppOnboardingProvider provider = provider(true, true); + Map session = Map.of(SESSION_ALLOWED_JID, "1234567890@s.whatsapp.net"); + + provider.saveConfiguration(session, configurationManager); + + verify(configurationManager).updateProperties(Map.of( + "agent.channels.whatsapp.enabled", true, + "agent.channels.whatsapp.allowed-chat-jid", "1234567890@s.whatsapp.net" + )); + } + + @Test + void saveConfigurationDoesNothingWhenJidMissing() throws Exception { + WhatsAppOnboardingProvider provider = provider(true, true); + + provider.saveConfiguration(new HashMap<>(), configurationManager); + + verifyNoInteractions(configurationManager); + } + + @Test + void prepareModelReportsInstalledAndPairedFlags() { + when(environment.getProperty("agent.channels.whatsapp.allowed-chat-jid", "")).thenReturn(""); + WhatsAppOnboardingProvider provider = provider(true, true); + Map model = new HashMap<>(); + + provider.prepareModel(new HashMap<>(), model); + + assertThat(model).containsEntry("wacliInstalled", true); + assertThat(model).containsEntry("wacliPaired", true); + } +} diff --git a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookControllerTest.java b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookControllerTest.java new file mode 100644 index 0000000..c639b1c --- /dev/null +++ b/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookControllerTest.java @@ -0,0 +1,170 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppWebhookControllerTest { + + private static final String ALLOWED_JID = "1234567890@s.whatsapp.net"; + + @Mock + private ChannelRegistry channelRegistry; + + @Mock + private Agent agent; + + @Mock + private WacliWhatsAppChannel channel; + + private WhatsAppWebhookController controller() { + return controller(Runnable::run); + } + + private WhatsAppWebhookController controller(Executor executor) { + WacliProperties properties = new WacliProperties(); + properties.setEnabled(true); + properties.setAllowedChatJid(ALLOWED_JID); + return new WhatsAppWebhookController(properties, channelRegistry, agent, channel, executor); + } + + private static WacliWebhookPayload payload(String chat, String senderJid, boolean fromMe, String text) { + return new WacliWebhookPayload(chat, "msg-id", senderJid, fromMe, text, "Tester", "2024-01-03T00:00:00Z"); + } + + @Test + void ignoresMessagesFromUnauthorizedChat() { + WhatsAppWebhookController controller = controller(); + + ResponseEntity response = controller.webhook( + payload("999@s.whatsapp.net", "5555@s.whatsapp.net", false, "hello")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verifyNoInteractions(agent, channelRegistry); + verify(channel, never()).sendMessage(anyString()); + } + + @Test + void ignoresEchoesOfRepliesTheAssistantSent() { + WhatsAppWebhookController controller = controller(); + when(channel.wasSentByAgent("msg-id")).thenReturn(true); + + ResponseEntity response = controller.webhook( + payload(ALLOWED_JID, ALLOWED_JID, true, "hi there")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verifyNoInteractions(agent, channelRegistry); + verify(channel, never()).sendMessage(anyString()); + } + + @Test + void handlesYourOwnTypedMessageInSelfChat() { + // A message you type yourself in "Message Yourself" also carries FromMe=true, but is not an + // echo of an assistant reply, so it must still be handled -- this is the single-phone path. + WhatsAppWebhookController controller = controller(); + when(channel.getName()).thenReturn("whatsapp"); + when(agent.respondTo(ALLOWED_JID, "remind me at 6")).thenReturn("Reminder set"); + + ResponseEntity response = controller.webhook( + payload(ALLOWED_JID, ALLOWED_JID, true, "remind me at 6")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(agent).respondTo(ALLOWED_JID, "remind me at 6"); + verify(channel).sendMessage("Reminder set"); + } + + @Test + void ignoresMessagesWithBlankText() { + WhatsAppWebhookController controller = controller(); + + ResponseEntity response = controller.webhook( + payload(ALLOWED_JID, ALLOWED_JID, false, " ")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verifyNoInteractions(agent, channelRegistry); + verify(channel, never()).sendMessage(anyString()); + } + + @Test + void firesEventAndRepliesForAuthorizedChat() { + WhatsAppWebhookController controller = controller(); + when(channel.getName()).thenReturn("whatsapp"); + when(agent.respondTo(eq(ALLOWED_JID), eq("hello"))).thenReturn("hi there"); + + ResponseEntity response = controller.webhook( + payload(ALLOWED_JID, "someone-else@s.whatsapp.net", false, "hello")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(channelRegistry).publishMessageReceivedEvent(argThat(event -> + event instanceof WhatsAppChannelMessageReceivedEvent whatsAppEvent + && ALLOWED_JID.equals(whatsAppEvent.getConversationId()) + && "hello".equals(whatsAppEvent.getMessage()) + && "whatsapp".equals(whatsAppEvent.getChannel()))); + verify(agent).respondTo(ALLOWED_JID, "hello"); + verify(channel).sendMessage("hi there"); + } + + @Test + void returnsOkForEmptyBody() { + WhatsAppWebhookController controller = controller(); + + ResponseEntity response = controller.webhook(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verifyNoInteractions(agent, channelRegistry, channel); + } + + @Test + void respondsImmediatelyAndDispatchesAgentTurnToExecutor() { + List deferred = new ArrayList<>(); + WhatsAppWebhookController controller = controller(deferred::add); + when(channel.getName()).thenReturn("whatsapp"); + + ResponseEntity response = controller.webhook( + payload(ALLOWED_JID, ALLOWED_JID, false, "hello")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(channelRegistry).publishMessageReceivedEvent(any()); + verifyNoInteractions(agent); + assertThat(deferred).hasSize(1); + + when(agent.respondTo(ALLOWED_JID, "hello")).thenReturn("hi there"); + deferred.get(0).run(); + verify(agent).respondTo(ALLOWED_JID, "hello"); + verify(channel).sendMessage("hi there"); + } + + @Test + void swallowsAgentFailureInWorker() { + WhatsAppWebhookController controller = controller(); + when(channel.getName()).thenReturn("whatsapp"); + when(agent.respondTo(ALLOWED_JID, "hello")).thenThrow(new RuntimeException("boom")); + + ResponseEntity response = controller.webhook( + payload(ALLOWED_JID, ALLOWED_JID, false, "hello")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(agent).respondTo(ALLOWED_JID, "hello"); + verify(channel, never()).sendMessage(anyString()); + } +} diff --git a/settings.gradle b/settings.gradle index 8a54b0c..6ea34bf 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,6 +6,7 @@ include 'plugins:discord' include 'plugins:telegram' include 'plugins:playwright' include 'plugins:brave' +include 'plugins:whatsapp' include 'providers' include 'providers:anthropic' include 'providers:google'