From b51abb01af7a46eb064364eb01eaca6c718d0c29 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 18 Jul 2026 19:38:04 +0300 Subject: [PATCH 1/3] Integrate WhatsApp connectivity through the wacli CLI, paired as a WhatsApp Web linked device. JavaClaw supervises wacli sync as a subprocess and receives inbound messages over a local webhook, replying with wacli send. WacliSyncSupervisor: launches and restarts the sync subprocess with exponential backoff and health-based retry reset. WhatsAppWebhookController: handles inbound webhooks, gates on the allowed chat JID, and processes messages off the request thread. WacliWhatsAppChannel: sends replies and suppresses echoes of the agent's own outbound messages by message ID. WhatsAppOnboardingProvider: onboarding step for install/pair/config. Wire the plugin into the build and document setup in the README. --- README.md | 47 ++++ app/build.gradle | 1 + plugins/whatsapp/build.gradle | 12 + .../channels/whatsapp/ProcessLauncher.java | 9 + .../channels/whatsapp/WacliProperties.java | 37 +++ .../whatsapp/WacliSyncSupervisor.java | 231 ++++++++++++++++++ .../whatsapp/WacliWebhookPayload.java | 13 + .../whatsapp/WacliWhatsAppChannel.java | 177 ++++++++++++++ .../WhatsAppChannelAutoConfiguration.java | 22 ++ .../WhatsAppChannelMessageReceivedEvent.java | 17 ++ .../whatsapp/WhatsAppOnboardingProvider.java | 152 ++++++++++++ .../whatsapp/WhatsAppWebhookController.java | 107 ++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../onboarding/steps/whatsapp.html.peb | 66 +++++ .../whatsapp/WacliSyncSupervisorTest.java | 152 ++++++++++++ .../whatsapp/WacliWebhookPayloadTest.java | 51 ++++ .../whatsapp/WacliWhatsAppChannelTest.java | 174 +++++++++++++ .../WhatsAppOnboardingProviderTest.java | 136 +++++++++++ .../WhatsAppWebhookControllerTest.java | 170 +++++++++++++ settings.gradle | 1 + 20 files changed, 1576 insertions(+) create mode 100644 plugins/whatsapp/build.gradle create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/ProcessLauncher.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliProperties.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisor.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayload.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannel.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelAutoConfiguration.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelMessageReceivedEvent.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProvider.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookController.java create mode 100644 plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb create mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisorTest.java create mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayloadTest.java create mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannelTest.java create mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProviderTest.java create mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookControllerTest.java diff --git a/README.md b/README.md index b2cd645b..d3b82577 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 22e2ce0e..53538ac7 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 00000000..9db7b072 --- /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 00000000..d4dfb64c --- /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 00000000..c7e8f9b7 --- /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 00000000..ec194a42 --- /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 00000000..59895506 --- /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 00000000..e15b8b1d --- /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 00000000..711889db --- /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 00000000..2f62c2bf --- /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 00000000..701e1dfe --- /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 00000000..a75f366b --- /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 00000000..bde06980 --- /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 00000000..4fc0f315 --- /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 00000000..3fa5ccad --- /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 00000000..bd38680f --- /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 00000000..4b98c8bc --- /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 00000000..9a10bedd --- /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 00000000..c639b1c0 --- /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 8a54b0c1..6ea34bfb 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' From 14ae879010445d61d4a3ef433b32520e36208781 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sun, 26 Jul 2026 19:14:21 +0300 Subject: [PATCH 2/3] Stream web chat responses token-by-token over WebSocket The agent response for the web chat is now streamed as it is generated instead of arriving as one complete message. DefaultAgent gained a streaming respondTo variant that uses ChatClient.stream() and reports progress through a new ResponseListener callback (onToken/onComplete/ onError), falling back to the blocking call for models that cannot stream. ChatChannel supplies a listener that pushes uniform JSON frames ({type: chunk|done|error, data, conversationId}) to the WebSocket session, and the frontend assembles chunk frames into a live message bubble while htmx keeps handling the existing HTML frames unchanged. Messages buffered while no session is active are flushed on reconnect. Telegram, Discord and WhatsApp channels are unaffected. --- AGENTS.md | 2 +- .../java/ai/javaclaw/chat/ChatChannel.java | 58 +++++++- .../ai/javaclaw/chat/StreamFrameType.java | 21 +++ .../chat/ws/ChatWebSocketHandler.java | 10 +- .../main/resources/templates/chat.html.peb | 69 ++++++++- .../ai/javaclaw/chat/ChatChannelTest.java | 115 ++++++++++++++- .../chat/ws/ChatStreamingIntegrationTest.java | 139 ++++++++++++++++++ .../chat/ws/ChatWebSocketHandlerTest.java | 28 ++++ .../main/java/ai/javaclaw/agent/Agent.java | 7 + .../java/ai/javaclaw/agent/DefaultAgent.java | 38 +++++ .../ai/javaclaw/agent/ResponseListener.java | 50 +++++++ .../ai/javaclaw/agent/DefaultAgentTest.java | 83 +++++++++++ 12 files changed, 606 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/ai/javaclaw/chat/StreamFrameType.java create mode 100644 app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java create mode 100644 base/src/main/java/ai/javaclaw/agent/ResponseListener.java create mode 100644 base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java diff --git a/AGENTS.md b/AGENTS.md index 9f936f64..6840f0ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ Incoming message → ChannelMessageReceivedEvent (channel name, message text) - **`ChannelRegistry`**: Registers channels, tracks last-active channel so background task replies are routed correctly. - **`DiscordChannel`**: JDA `ListenerAdapter`; accepts DMs from the configured user and guild messages only when the bot is mentioned. - **`TelegramChannel`**: `SpringLongPollingBot`; filters by `allowedUsername`; stores `chatId` for routing background replies. -- **`ChatChannel`**: WebSocket-first delivery (`setWsSession()`/`clearWsSession()`); falls back to buffering replies in `ConcurrentLinkedQueue` exposed via `drainPendingMessages()` REST endpoint when no WebSocket session is active. +- **`ChatChannel`**: WebSocket-first delivery (`setWsSession()`/`clearWsSession()`); falls back to buffering replies in `ConcurrentLinkedQueue` exposed via `drainPendingMessages()` REST endpoint when no WebSocket session is active. Web chat responses stream live: `Agent.respondTo(conversationId, question, ResponseListener)` uses `ChatClient.stream()` and reports each token via the listener callback (`onToken`/`onComplete`/`onError`); `ChatChannel` supplies a listener that pushes JSON frames (`chunk`, `done`, `error` — see `StreamFrameType`) to the browser over the WebSocket. `Channel` itself knows nothing about streaming; all other channels use the blocking `respondTo`/`sendMessage()` path. --- diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index 2050c312..c4578b53 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -1,6 +1,7 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.ResponseListener; import ai.javaclaw.channels.Channel; import ai.javaclaw.channels.ChannelMessageReceivedEvent; import ai.javaclaw.channels.ChannelRegistry; @@ -16,10 +17,13 @@ import org.springframework.stereotype.Component; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; @@ -38,13 +42,15 @@ public class ChatChannel implements Channel { private final Agent agent; private final ChannelRegistry channelRegistry; private final ChatMemoryRepository chatMemoryRepository; + private final ObjectMapper objectMapper; private final ConcurrentLinkedQueue pendingMessages = new ConcurrentLinkedQueue<>(); private final AtomicReference wsSession = new AtomicReference<>(); - public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository) { + public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository, ObjectMapper objectMapper) { this.agent = agent; this.channelRegistry = channelRegistry; this.chatMemoryRepository = chatMemoryRepository; + this.objectMapper = objectMapper; channelRegistry.registerChannel(this); log.info("Started Web Chat channel"); } @@ -93,6 +99,18 @@ public void sendMessage(String message) { } } + /** + * Delivers messages buffered while no WebSocket session was active. + * Each buffered message is attempted once; a failed push re-buffers it. + */ + public void flushPendingMessages() { + for (int i = pendingMessages.size(); i > 0; i--) { + String message = pendingMessages.poll(); + if (message == null) break; + sendMessage(message); + } + } + /** * Returns all known conversation IDs, always with "web" first. */ @@ -124,10 +142,46 @@ public List loadHistoryAsHtml(String conversationId) { /** * Handles a chat message from the web UI for the given conversationId. + * The response is streamed to the WebSocket session as JSON frames + * ({@code chunk}/{@code done}/{@code error}); the full response text is returned. */ public String chat(String conversationId, String message) { channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message)); - return agent.respondTo(conversationId, message); + + return agent.respondTo(conversationId, message, ResponseListener.of( + token -> sendChunkFrame(conversationId, token), + () -> sendDoneFrame(conversationId), + error -> sendErrorFrame(conversationId, error))); + } + + private void sendChunkFrame(String conversationId, String token) { + sendFrame(frame(StreamFrameType.CHUNK, conversationId, token)); + } + + private void sendDoneFrame(String conversationId) { + sendFrame(frame(StreamFrameType.DONE, conversationId, null)); + } + + private void sendErrorFrame(String conversationId, String error) { + sendFrame(frame(StreamFrameType.ERROR, conversationId, error == null ? "Unknown error" : error)); + } + + private static Map frame(StreamFrameType type, String conversationId, Object payload) { + Map frame = new LinkedHashMap<>(); + frame.put("type", type.type()); + if (payload != null) frame.put("data", payload); + frame.put("conversationId", conversationId); + return frame; + } + + private void sendFrame(Map frame) { + WebSocketSession session = wsSession.get(); + if (session == null || !session.isOpen()) return; + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(frame))); + } catch (IOException e) { + log.warn("WS push failed, dropping stream frame: {}", e.getMessage()); + } } private static String buildBackgroundMessageHtml(String text) { diff --git a/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java b/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java new file mode 100644 index 00000000..87a48023 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java @@ -0,0 +1,21 @@ +package ai.javaclaw.chat; + +/** + * Type discriminator of the JSON frames streamed to the web chat WebSocket client. + * Every frame carries its payload (if any) in a uniform {@code data} field. + */ +public enum StreamFrameType { + CHUNK("chunk"), + DONE("done"), + ERROR("error"); + + private final String type; + + StreamFrameType(String type) { + this.type = type; + } + + public String type() { + return type; + } +} diff --git a/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java b/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java index 2132c7ac..d3947b50 100644 --- a/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java +++ b/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java @@ -45,6 +45,7 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio Htmx.oobInnerHtml("channel-selector", conversationSelector), Htmx.oobInnerHtml("chat-messages", bubbles), Htmx.oobInnerHtml("chat-input-area", inputArea)); + chatChannel.flushPendingMessages(); } @Override @@ -91,11 +92,10 @@ private void handleUserMessage(Map payload) throws Exception { Htmx.oobReplace("typing-indicator", ChatHtml.typingDots())); try { - // Call agent (blocking — background tasks may push messages via ChatChannel during this) - String response = chatChannel.chat(conversationId, userMessage); - chatChannel.sendHtml( - Htmx.oobAppend("chat-messages", ChatHtml.agentBubble(response)), - Htmx.oobReplace("typing-indicator", "")); + // Call agent (blocking — the response is streamed to the client as JSON frames + // by ChatChannel while this call runs; background tasks may push messages too) + chatChannel.chat(conversationId, userMessage); + chatChannel.sendHtml(Htmx.oobReplace("typing-indicator", "")); } catch (RuntimeException ex) { log.warn("Chat request failed for conversation {}", conversationId, ex); chatChannel.sendHtml( diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 4728c84f..c28dc325 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -149,6 +149,11 @@ .chat-readonly-notice strong { color: rgba(180, 195, 235, .7); } + +.ar-msg--error .ar-msg__bubble { + border-color: color-mix(in srgb, var(--bulma-danger) 45%, transparent); + color: var(--bulma-danger); +} {% endblock %} @@ -259,9 +264,71 @@ if (ta) { ta.value = ''; ta.style.height = ''; } }); document.body.addEventListener('htmx:wsAfterMessage', function () { + scrollToBottom(); + }); + + // --- Streaming frames ------------------------------------------------ + // The server streams the agent response as JSON frames (type: chunk / + // done / error). These are intercepted here and never reach htmx; HTML + // frames (no JSON type) keep the htmx OOB-swap behavior unchanged. + var streamBubbles = {}; // conversationId -> in-progress bubble element + + document.body.addEventListener('htmx:wsBeforeMessage', function (e) { + var frame = parseStreamFrame(e.detail.message); + if (!frame) return; + e.preventDefault(); + handleStreamFrame(frame); + scrollToBottom(); + }); + + function parseStreamFrame(text) { + if (!text || text.charAt(0) !== '{') return null; + try { + var frame = JSON.parse(text); + return frame && typeof frame === 'object' && frame.type ? frame : null; + } catch (err) { + return null; + } + } + + function handleStreamFrame(frame) { + var id = frame.conversationId || 'web'; + if (frame.type === 'chunk') { + streamBubble(id).textContent += frame.data; + } else if (frame.type === 'done') { + clearTypingIndicator(); + delete streamBubbles[id]; + } else if (frame.type === 'error') { + var bubble = streamBubble(id); + bubble.textContent = frame.data; + bubble.closest('.ar-msg').classList.add('ar-msg--error'); + delete streamBubbles[id]; + } + } + + function streamBubble(conversationId) { + var bubble = streamBubbles[conversationId]; + if (!bubble || !bubble.isConnected) { + var article = document.createElement('article'); + article.className = 'ar-msg ar-msg--agent'; + article.innerHTML = '
JC
'; + document.getElementById('chat-messages').appendChild(article); + bubble = article.querySelector('.ar-msg__bubble'); + streamBubbles[conversationId] = bubble; + clearTypingIndicator(); + } + return bubble; + } + + function clearTypingIndicator() { + var typing = document.getElementById('typing-indicator'); + if (typing) typing.innerHTML = ''; + } + + function scrollToBottom() { var body = document.querySelector('.chat-body'); if (body) body.scrollTop = body.scrollHeight; - }); + } }()); {% endblock %} diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index 206bf424..7db87c16 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -1,6 +1,7 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.ResponseListener; import ai.javaclaw.channels.ChannelRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,9 +13,11 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -34,7 +37,7 @@ class ChatChannelTest { @BeforeEach void setUp() { - chatChannel = new ChatChannel(agent, new ChannelRegistry(), chatMemoryRepository); + chatChannel = new ChatChannel(agent, new ChannelRegistry(), chatMemoryRepository, new ObjectMapper()); } // ----------------------------------------------------------------------- @@ -131,21 +134,21 @@ void loadHistoryUsesSuppliedConversationId() { @Test void chatDelegatesToAgentWithConversationId() { - when(agent.respondTo("web", "hello")).thenReturn("hi"); + when(agent.respondTo(eq("web"), eq("hello"), any(ResponseListener.class))).thenReturn("hi"); String response = chatChannel.chat("web", "hello"); assertThat(response).isEqualTo("hi"); - verify(agent).respondTo(eq("web"), eq("hello")); + verify(agent).respondTo(eq("web"), eq("hello"), any(ResponseListener.class)); } @Test void chatUsesSuppliedConversationId() { - when(agent.respondTo(eq("telegram-42"), any())).thenReturn("reply"); + when(agent.respondTo(eq("telegram-42"), any(), any(ResponseListener.class))).thenReturn("reply"); chatChannel.chat("telegram-42", "hello"); - verify(agent).respondTo(eq("telegram-42"), eq("hello")); + verify(agent).respondTo(eq("telegram-42"), eq("hello"), any(ResponseListener.class)); } // ----------------------------------------------------------------------- @@ -214,4 +217,106 @@ void sendMessagePushesOobHtmlToActiveSession() throws IOException { verify(session).sendMessage(any(TextMessage.class)); } + + @Test + void flushPendingMessagesDeliversMessagesBufferedWhileSendFailed() throws IOException { + WebSocketSession failingSession = mock(WebSocketSession.class); + when(failingSession.isOpen()).thenReturn(true); + org.mockito.Mockito.doThrow(new IOException("connection gone")).when(failingSession).sendMessage(any()); + chatChannel.setWsSession(failingSession); + chatChannel.sendMessage("Background result"); + + WebSocketSession session = openSession(); + chatChannel.flushPendingMessages(); + + verify(session).sendMessage(any(TextMessage.class)); + } + + @Test + void flushPendingMessagesDoesNothingWhenBufferIsEmpty() throws IOException { + WebSocketSession session = mock(WebSocketSession.class); + chatChannel.setWsSession(session); + + chatChannel.flushPendingMessages(); + + verify(session, never()).sendMessage(any()); + } + + // ----------------------------------------------------------------------- + // streaming frames + // ----------------------------------------------------------------------- + + @Test + void chatStreamsTokensAsChunkFramesFollowedByDoneFrame() throws IOException { + WebSocketSession session = openSession(); + agentStreams(listener -> { + listener.onToken("Hello "); + listener.onToken("world"); + listener.onComplete(); + }); + + chatChannel.chat("web", "hello"); + + List> frames = capturedFrames(session, 3); + assertThat(frames.get(0)) + .containsEntry("type", "chunk") + .containsEntry("data", "Hello ") + .containsEntry("conversationId", "web"); + assertThat(frames.get(1)) + .containsEntry("type", "chunk") + .containsEntry("data", "world"); + assertThat(frames.get(2)) + .containsEntry("type", "done") + .containsEntry("conversationId", "web") + .doesNotContainKey("data"); + } + + @Test + void chatStreamsErrorFrameWhenResponseFails() throws IOException { + WebSocketSession session = openSession(); + agentStreams(listener -> listener.onError("boom")); + + chatChannel.chat("web", "hello"); + + List> frames = capturedFrames(session, 1); + assertThat(frames.get(0)) + .containsEntry("type", "error") + .containsEntry("data", "boom") + .containsEntry("conversationId", "web"); + } + + @Test + void chatDropsStreamFramesWhenNoSessionIsActive() { + agentStreams(listener -> { + listener.onToken("Hello"); + listener.onComplete(); + }); + + // should not throw + chatChannel.chat("web", "hello"); + } + + private WebSocketSession openSession() { + WebSocketSession session = mock(WebSocketSession.class); + when(session.isOpen()).thenReturn(true); + chatChannel.setWsSession(session); + return session; + } + + private void agentStreams(java.util.function.Consumer progress) { + when(agent.respondTo(eq("web"), eq("hello"), any(ResponseListener.class))).thenAnswer(invocation -> { + progress.accept(invocation.getArgument(2)); + return ""; + }); + } + + @SuppressWarnings("unchecked") + private static List> capturedFrames(WebSocketSession session, int expectedCount) throws IOException { + var messageCaptor = org.mockito.ArgumentCaptor.forClass(TextMessage.class); + verify(session, org.mockito.Mockito.times(expectedCount)).sendMessage(messageCaptor.capture()); + ObjectMapper objectMapper = new ObjectMapper(); + return messageCaptor.getAllValues().stream() + .map(message -> (Map) objectMapper.readValue(message.getPayload(), Map.class)) + .toList(); + } } diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java new file mode 100644 index 00000000..cf177b09 --- /dev/null +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java @@ -0,0 +1,139 @@ +package ai.javaclaw.chat.ws; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.ResponseListener; +import ai.javaclaw.channels.ChannelRegistry; +import ai.javaclaw.chat.ChatChannel; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; +import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; +import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import tools.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Full WebSocket round trip: a userMessage frame is sent to /ws/chat and the streamed + * response must arrive as multiple JSON chunk frames followed by a done frame. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = ChatStreamingIntegrationTest.TestConfig.class) +class ChatStreamingIntegrationTest { + + @LocalServerPort + int port; + + final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void userMessageIsAnsweredWithMultipleChunkFramesBeforeDone() throws Exception { + BlockingQueue received = new LinkedBlockingQueue<>(); + WebSocketSession session = new StandardWebSocketClient() + .execute(new TextWebSocketHandler() { + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + received.add(message.getPayload()); + } + }, "ws://localhost:" + port + "/ws/chat") + .get(5, TimeUnit.SECONDS); + + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "userMessage", + "conversationId", "web", + "message", "hi" + )))); + + List frameTypes = collectFrameTypesUntilDone(received); + + assertThat(frameTypes) + .containsSubsequence("chunk", "chunk", "done") + .last().isEqualTo("done"); + } finally { + session.close(); + } + } + + /** + * Collects the {@code type} of every JSON frame until the done frame arrives. + * Non-JSON payloads (htmx HTML fragments) are ignored. + */ + private List collectFrameTypesUntilDone(BlockingQueue received) throws Exception { + List frameTypes = new ArrayList<>(); + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + String payload = received.poll(500, TimeUnit.MILLISECONDS); + if (payload == null || !payload.startsWith("{")) continue; + + @SuppressWarnings("unchecked") + Map frame = objectMapper.readValue(payload, Map.class); + frameTypes.add((String) frame.get("type")); + if ("done".equals(frame.get("type"))) break; + } + return frameTypes; + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration({TomcatServletWebServerAutoConfiguration.class, + DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class}) + @Import({WebSocketConfig.class, ChatWebSocketHandler.class, ChatChannel.class}) + static class TestConfig { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + ChannelRegistry channelRegistry() { + return new ChannelRegistry(); + } + + @Bean + ChatMemoryRepository chatMemoryRepository() { + return new InMemoryChatMemoryRepository(); + } + + @Bean + Agent agent() { + return new Agent() { + @Override + public String respondTo(String conversationId, String question) { + return "Hello world"; + } + + @Override + public String respondTo(String conversationId, String question, ResponseListener listener) { + listener.onToken("Hello "); + listener.onToken("world"); + listener.onComplete(); + return "Hello world"; + } + + @Override + public T prompt(String conversationId, String input, Class result) { + return null; + } + }; + } + } +} diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java index c150440b..90902957 100644 --- a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java @@ -85,6 +85,34 @@ void handleUserMessageShowsGenericProviderErrorForUnexpectedFailures() throws Ex .contains("Details: boom"); } + @Test + void handleUserMessageClearsTypingIndicatorWithoutAppendingBubbleWhenResponseWasStreamed() throws Exception { + ChatChannel chatChannel = mock(ChatChannel.class); + WebSocketSession session = mock(WebSocketSession.class); + ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); + + // the response is streamed to the client by ChatChannel while chat() runs + when(chatChannel.chat("web", "hello")).thenReturn("streamed response"); + + handler.handleTextMessage(session, new TextMessage(new ObjectMapper().writeValueAsString(Map.of( + "type", "userMessage", + "conversationId", "web", + "message", "hello" + )))); + + ArgumentCaptor htmlCaptor = ArgumentCaptor.forClass(String[].class); + var inOrder = inOrder(chatChannel); + inOrder.verify(chatChannel).sendHtml(htmlCaptor.capture()); + inOrder.verify(chatChannel).chat("web", "hello"); + inOrder.verify(chatChannel).sendHtml(htmlCaptor.capture()); + verifyNoMoreInteractions(chatChannel); + + assertThat(String.join("", htmlCaptor.getAllValues().get(1))) + .contains("typing-indicator") + .doesNotContain("streamed response") + .doesNotContain("ar-msg--agent"); + } + @Test void handleChannelChangedSendsHistoryAndInputArea() throws Exception { ChatChannel chatChannel = mock(ChatChannel.class); diff --git a/base/src/main/java/ai/javaclaw/agent/Agent.java b/base/src/main/java/ai/javaclaw/agent/Agent.java index c01efa20..13836ce9 100644 --- a/base/src/main/java/ai/javaclaw/agent/Agent.java +++ b/base/src/main/java/ai/javaclaw/agent/Agent.java @@ -4,6 +4,13 @@ public interface Agent { String respondTo(String conversationId, String question); + default String respondTo(String conversationId, String question, ResponseListener listener) { + String response = respondTo(conversationId, question); + listener.onToken(response); + listener.onComplete(); + return response; + } + T prompt(String conversationId, String input, Class result); } diff --git a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java index abb50103..e63b4761 100644 --- a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java +++ b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java @@ -1,5 +1,7 @@ package ai.javaclaw.agent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.stereotype.Component; @@ -7,6 +9,8 @@ @Component public class DefaultAgent implements Agent { + private static final Logger log = LoggerFactory.getLogger(DefaultAgent.class); + private final ChatClient chatClient; public DefaultAgent(ChatClient chatClient) { @@ -22,6 +26,35 @@ public String respondTo(String conversationId, String question) { .content(); } + @Override + public String respondTo(String conversationId, String question, ResponseListener listener) { + StringBuilder fullResponse = new StringBuilder(); + try { + chatClient + .prompt(question) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .stream() + .content() + .doOnNext(token -> { + fullResponse.append(token); + listener.onToken(token); + }) + .blockLast(); + } catch (UnsupportedOperationException e) { + // The configured model cannot stream — fall back to the blocking call + String response = respondTo(conversationId, question); + listener.onToken(response); + listener.onComplete(); + return response; + } catch (RuntimeException e) { + log.warn("Streaming response failed for conversation {}", conversationId, e); + listener.onError(summarizeError(e)); + return fullResponse.toString(); + } + listener.onComplete(); + return fullResponse.toString(); + } + @Override public T prompt(String conversationId, String input, Class result) { return chatClient @@ -30,4 +63,9 @@ public T prompt(String conversationId, String input, Class result) { .call() .entity(result); } + + private static String summarizeError(Throwable ex) { + String message = ex.getMessage(); + return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message; + } } diff --git a/base/src/main/java/ai/javaclaw/agent/ResponseListener.java b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java new file mode 100644 index 00000000..b2d05659 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java @@ -0,0 +1,50 @@ +package ai.javaclaw.agent; + +import java.util.function.Consumer; + +/** + * Callback for observing an agent response as it is produced. Callers that can + * render progress incrementally (e.g. the web chat) supply an implementation; + * where the tokens go (WebSocket, SSE, console) is entirely the caller's concern. + */ +public interface ResponseListener { + + /** + * Called for every token as it arrives from the model. + */ + void onToken(String token); + + /** + * Called once after the last token; no further callbacks follow. + */ + void onComplete(); + + /** + * Called when the response fails; no further callbacks follow. Tokens already + * delivered via {@link #onToken(String)} may precede the failure. + */ + void onError(String message); + + /** + * Creates a listener from three lambdas, avoiding an anonymous class at the call site. + */ + static ResponseListener of(Consumer onToken, Runnable onComplete, Consumer onError) { + return new ResponseListener() { + + @Override + public void onToken(String token) { + onToken.accept(token); + } + + @Override + public void onComplete() { + onComplete.run(); + } + + @Override + public void onError(String message) { + onError.accept(message); + } + }; + } +} diff --git a/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java b/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java new file mode 100644 index 00000000..4652fe33 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java @@ -0,0 +1,83 @@ +package ai.javaclaw.agent; + +import org.junit.jupiter.api.BeforeEach; +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.ai.chat.client.ChatClient; +import reactor.core.publisher.Flux; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultAgentTest { + + @Mock ChatClient chatClient; + @Mock ChatClient.ChatClientRequestSpec requestSpec; + @Mock ChatClient.StreamResponseSpec streamSpec; + @Mock ChatClient.CallResponseSpec callSpec; + @Mock ResponseListener listener; + + DefaultAgent agent; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + agent = new DefaultAgent(chatClient); + when(chatClient.prompt("hello")).thenReturn(requestSpec); + when(requestSpec.advisors(any(Consumer.class))).thenReturn(requestSpec); + when(requestSpec.stream()).thenReturn(streamSpec); + } + + @Test + void reportsEachTokenAndCompletionToListener() { + when(streamSpec.content()).thenReturn(Flux.just("Hello ", "world")); + + String response = agent.respondTo("web", "hello", listener); + + assertThat(response).isEqualTo("Hello world"); + var inOrder = inOrder(listener); + inOrder.verify(listener).onToken("Hello "); + inOrder.verify(listener).onToken("world"); + inOrder.verify(listener).onComplete(); + verify(listener, never()).onError(any()); + } + + @Test + void reportsErrorAndReturnsPartialResponseWhenStreamFails() { + when(streamSpec.content()).thenReturn(Flux.concat( + Flux.just("Hello "), + Flux.error(new RuntimeException("boom")))); + + String response = agent.respondTo("web", "hello", listener); + + assertThat(response).isEqualTo("Hello "); + var inOrder = inOrder(listener); + inOrder.verify(listener).onToken("Hello "); + inOrder.verify(listener).onError("boom"); + verify(listener, never()).onComplete(); + } + + @Test + void fallsBackToBlockingCallWhenModelDoesNotSupportStreaming() { + when(streamSpec.content()).thenReturn(Flux.error(new UnsupportedOperationException("streaming is not supported"))); + when(requestSpec.call()).thenReturn(callSpec); + when(callSpec.content()).thenReturn("full response"); + + String response = agent.respondTo("web", "hello", listener); + + assertThat(response).isEqualTo("full response"); + var inOrder = inOrder(listener); + inOrder.verify(listener).onToken("full response"); + inOrder.verify(listener).onComplete(); + verify(listener, never()).onError(any()); + } +} From 7186e5a99d3fa02048dc89c6ab9ed58ee784370e Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sun, 26 Jul 2026 19:14:21 +0300 Subject: [PATCH 3/3] Stream web chat responses token-by-token over WebSocket The agent response for the web chat is now streamed as it is generated instead of arriving as one complete message. DefaultAgent gained a streaming respondTo variant that uses ChatClient.stream() and reports progress through a new ResponseListener callback (onToken/onComplete/ onError), falling back to the blocking call for models that cannot stream. ChatChannel supplies a listener that pushes uniform JSON frames ({type: chunk|done|error, data, conversationId}) to the WebSocket session, and the frontend assembles chunk frames into a live message bubble while htmx keeps handling the existing HTML frames unchanged. Messages buffered while no session is active are flushed on reconnect. Telegram, Discord and WhatsApp channels are unaffected. --- AGENTS.md | 2 +- .../java/ai/javaclaw/chat/ChatChannel.java | 58 ++++- .../ai/javaclaw/chat/StreamFrameType.java | 21 ++ .../chat/ws/ChatWebSocketHandler.java | 10 +- .../main/resources/templates/chat.html.peb | 69 +++++- .../ai/javaclaw/chat/ChatChannelTest.java | 115 ++++++++- .../chat/ws/ChatStreamingIntegrationTest.java | 139 +++++++++++ .../chat/ws/ChatWebSocketHandlerTest.java | 28 +++ .../main/java/ai/javaclaw/agent/Agent.java | 7 + .../java/ai/javaclaw/agent/DefaultAgent.java | 38 +++ .../ai/javaclaw/agent/ResponseListener.java | 50 ++++ .../ai/javaclaw/agent/DefaultAgentTest.java | 83 +++++++ plugins/whatsapp/build.gradle | 12 - .../channels/whatsapp/ProcessLauncher.java | 9 - .../channels/whatsapp/WacliProperties.java | 37 --- .../whatsapp/WacliSyncSupervisor.java | 231 ------------------ .../whatsapp/WacliWebhookPayload.java | 13 - .../whatsapp/WacliWhatsAppChannel.java | 177 -------------- .../WhatsAppChannelAutoConfiguration.java | 22 -- .../WhatsAppChannelMessageReceivedEvent.java | 17 -- .../whatsapp/WhatsAppOnboardingProvider.java | 152 ------------ .../whatsapp/WhatsAppWebhookController.java | 107 -------- ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../onboarding/steps/whatsapp.html.peb | 66 ----- .../whatsapp/WacliSyncSupervisorTest.java | 152 ------------ .../whatsapp/WacliWebhookPayloadTest.java | 51 ---- .../whatsapp/WacliWhatsAppChannelTest.java | 174 ------------- .../WhatsAppOnboardingProviderTest.java | 136 ----------- .../WhatsAppWebhookControllerTest.java | 170 ------------- 29 files changed, 606 insertions(+), 1541 deletions(-) create mode 100644 app/src/main/java/ai/javaclaw/chat/StreamFrameType.java create mode 100644 app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java create mode 100644 base/src/main/java/ai/javaclaw/agent/ResponseListener.java create mode 100644 base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java delete mode 100644 plugins/whatsapp/build.gradle delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/ProcessLauncher.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliProperties.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisor.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayload.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannel.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelAutoConfiguration.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelMessageReceivedEvent.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProvider.java delete mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookController.java delete mode 100644 plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb delete mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisorTest.java delete mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayloadTest.java delete mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannelTest.java delete mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProviderTest.java delete mode 100644 plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookControllerTest.java diff --git a/AGENTS.md b/AGENTS.md index 9f936f64..6840f0ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ Incoming message → ChannelMessageReceivedEvent (channel name, message text) - **`ChannelRegistry`**: Registers channels, tracks last-active channel so background task replies are routed correctly. - **`DiscordChannel`**: JDA `ListenerAdapter`; accepts DMs from the configured user and guild messages only when the bot is mentioned. - **`TelegramChannel`**: `SpringLongPollingBot`; filters by `allowedUsername`; stores `chatId` for routing background replies. -- **`ChatChannel`**: WebSocket-first delivery (`setWsSession()`/`clearWsSession()`); falls back to buffering replies in `ConcurrentLinkedQueue` exposed via `drainPendingMessages()` REST endpoint when no WebSocket session is active. +- **`ChatChannel`**: WebSocket-first delivery (`setWsSession()`/`clearWsSession()`); falls back to buffering replies in `ConcurrentLinkedQueue` exposed via `drainPendingMessages()` REST endpoint when no WebSocket session is active. Web chat responses stream live: `Agent.respondTo(conversationId, question, ResponseListener)` uses `ChatClient.stream()` and reports each token via the listener callback (`onToken`/`onComplete`/`onError`); `ChatChannel` supplies a listener that pushes JSON frames (`chunk`, `done`, `error` — see `StreamFrameType`) to the browser over the WebSocket. `Channel` itself knows nothing about streaming; all other channels use the blocking `respondTo`/`sendMessage()` path. --- diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index 2050c312..c4578b53 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -1,6 +1,7 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.ResponseListener; import ai.javaclaw.channels.Channel; import ai.javaclaw.channels.ChannelMessageReceivedEvent; import ai.javaclaw.channels.ChannelRegistry; @@ -16,10 +17,13 @@ import org.springframework.stereotype.Component; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; @@ -38,13 +42,15 @@ public class ChatChannel implements Channel { private final Agent agent; private final ChannelRegistry channelRegistry; private final ChatMemoryRepository chatMemoryRepository; + private final ObjectMapper objectMapper; private final ConcurrentLinkedQueue pendingMessages = new ConcurrentLinkedQueue<>(); private final AtomicReference wsSession = new AtomicReference<>(); - public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository) { + public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository, ObjectMapper objectMapper) { this.agent = agent; this.channelRegistry = channelRegistry; this.chatMemoryRepository = chatMemoryRepository; + this.objectMapper = objectMapper; channelRegistry.registerChannel(this); log.info("Started Web Chat channel"); } @@ -93,6 +99,18 @@ public void sendMessage(String message) { } } + /** + * Delivers messages buffered while no WebSocket session was active. + * Each buffered message is attempted once; a failed push re-buffers it. + */ + public void flushPendingMessages() { + for (int i = pendingMessages.size(); i > 0; i--) { + String message = pendingMessages.poll(); + if (message == null) break; + sendMessage(message); + } + } + /** * Returns all known conversation IDs, always with "web" first. */ @@ -124,10 +142,46 @@ public List loadHistoryAsHtml(String conversationId) { /** * Handles a chat message from the web UI for the given conversationId. + * The response is streamed to the WebSocket session as JSON frames + * ({@code chunk}/{@code done}/{@code error}); the full response text is returned. */ public String chat(String conversationId, String message) { channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message)); - return agent.respondTo(conversationId, message); + + return agent.respondTo(conversationId, message, ResponseListener.of( + token -> sendChunkFrame(conversationId, token), + () -> sendDoneFrame(conversationId), + error -> sendErrorFrame(conversationId, error))); + } + + private void sendChunkFrame(String conversationId, String token) { + sendFrame(frame(StreamFrameType.CHUNK, conversationId, token)); + } + + private void sendDoneFrame(String conversationId) { + sendFrame(frame(StreamFrameType.DONE, conversationId, null)); + } + + private void sendErrorFrame(String conversationId, String error) { + sendFrame(frame(StreamFrameType.ERROR, conversationId, error == null ? "Unknown error" : error)); + } + + private static Map frame(StreamFrameType type, String conversationId, Object payload) { + Map frame = new LinkedHashMap<>(); + frame.put("type", type.type()); + if (payload != null) frame.put("data", payload); + frame.put("conversationId", conversationId); + return frame; + } + + private void sendFrame(Map frame) { + WebSocketSession session = wsSession.get(); + if (session == null || !session.isOpen()) return; + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(frame))); + } catch (IOException e) { + log.warn("WS push failed, dropping stream frame: {}", e.getMessage()); + } } private static String buildBackgroundMessageHtml(String text) { diff --git a/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java b/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java new file mode 100644 index 00000000..87a48023 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java @@ -0,0 +1,21 @@ +package ai.javaclaw.chat; + +/** + * Type discriminator of the JSON frames streamed to the web chat WebSocket client. + * Every frame carries its payload (if any) in a uniform {@code data} field. + */ +public enum StreamFrameType { + CHUNK("chunk"), + DONE("done"), + ERROR("error"); + + private final String type; + + StreamFrameType(String type) { + this.type = type; + } + + public String type() { + return type; + } +} diff --git a/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java b/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java index 2132c7ac..d3947b50 100644 --- a/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java +++ b/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java @@ -45,6 +45,7 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio Htmx.oobInnerHtml("channel-selector", conversationSelector), Htmx.oobInnerHtml("chat-messages", bubbles), Htmx.oobInnerHtml("chat-input-area", inputArea)); + chatChannel.flushPendingMessages(); } @Override @@ -91,11 +92,10 @@ private void handleUserMessage(Map payload) throws Exception { Htmx.oobReplace("typing-indicator", ChatHtml.typingDots())); try { - // Call agent (blocking — background tasks may push messages via ChatChannel during this) - String response = chatChannel.chat(conversationId, userMessage); - chatChannel.sendHtml( - Htmx.oobAppend("chat-messages", ChatHtml.agentBubble(response)), - Htmx.oobReplace("typing-indicator", "")); + // Call agent (blocking — the response is streamed to the client as JSON frames + // by ChatChannel while this call runs; background tasks may push messages too) + chatChannel.chat(conversationId, userMessage); + chatChannel.sendHtml(Htmx.oobReplace("typing-indicator", "")); } catch (RuntimeException ex) { log.warn("Chat request failed for conversation {}", conversationId, ex); chatChannel.sendHtml( diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 4728c84f..c28dc325 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -149,6 +149,11 @@ .chat-readonly-notice strong { color: rgba(180, 195, 235, .7); } + +.ar-msg--error .ar-msg__bubble { + border-color: color-mix(in srgb, var(--bulma-danger) 45%, transparent); + color: var(--bulma-danger); +} {% endblock %} @@ -259,9 +264,71 @@ if (ta) { ta.value = ''; ta.style.height = ''; } }); document.body.addEventListener('htmx:wsAfterMessage', function () { + scrollToBottom(); + }); + + // --- Streaming frames ------------------------------------------------ + // The server streams the agent response as JSON frames (type: chunk / + // done / error). These are intercepted here and never reach htmx; HTML + // frames (no JSON type) keep the htmx OOB-swap behavior unchanged. + var streamBubbles = {}; // conversationId -> in-progress bubble element + + document.body.addEventListener('htmx:wsBeforeMessage', function (e) { + var frame = parseStreamFrame(e.detail.message); + if (!frame) return; + e.preventDefault(); + handleStreamFrame(frame); + scrollToBottom(); + }); + + function parseStreamFrame(text) { + if (!text || text.charAt(0) !== '{') return null; + try { + var frame = JSON.parse(text); + return frame && typeof frame === 'object' && frame.type ? frame : null; + } catch (err) { + return null; + } + } + + function handleStreamFrame(frame) { + var id = frame.conversationId || 'web'; + if (frame.type === 'chunk') { + streamBubble(id).textContent += frame.data; + } else if (frame.type === 'done') { + clearTypingIndicator(); + delete streamBubbles[id]; + } else if (frame.type === 'error') { + var bubble = streamBubble(id); + bubble.textContent = frame.data; + bubble.closest('.ar-msg').classList.add('ar-msg--error'); + delete streamBubbles[id]; + } + } + + function streamBubble(conversationId) { + var bubble = streamBubbles[conversationId]; + if (!bubble || !bubble.isConnected) { + var article = document.createElement('article'); + article.className = 'ar-msg ar-msg--agent'; + article.innerHTML = '
JC
'; + document.getElementById('chat-messages').appendChild(article); + bubble = article.querySelector('.ar-msg__bubble'); + streamBubbles[conversationId] = bubble; + clearTypingIndicator(); + } + return bubble; + } + + function clearTypingIndicator() { + var typing = document.getElementById('typing-indicator'); + if (typing) typing.innerHTML = ''; + } + + function scrollToBottom() { var body = document.querySelector('.chat-body'); if (body) body.scrollTop = body.scrollHeight; - }); + } }()); {% endblock %} diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index 206bf424..7db87c16 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -1,6 +1,7 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.ResponseListener; import ai.javaclaw.channels.ChannelRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,9 +13,11 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -34,7 +37,7 @@ class ChatChannelTest { @BeforeEach void setUp() { - chatChannel = new ChatChannel(agent, new ChannelRegistry(), chatMemoryRepository); + chatChannel = new ChatChannel(agent, new ChannelRegistry(), chatMemoryRepository, new ObjectMapper()); } // ----------------------------------------------------------------------- @@ -131,21 +134,21 @@ void loadHistoryUsesSuppliedConversationId() { @Test void chatDelegatesToAgentWithConversationId() { - when(agent.respondTo("web", "hello")).thenReturn("hi"); + when(agent.respondTo(eq("web"), eq("hello"), any(ResponseListener.class))).thenReturn("hi"); String response = chatChannel.chat("web", "hello"); assertThat(response).isEqualTo("hi"); - verify(agent).respondTo(eq("web"), eq("hello")); + verify(agent).respondTo(eq("web"), eq("hello"), any(ResponseListener.class)); } @Test void chatUsesSuppliedConversationId() { - when(agent.respondTo(eq("telegram-42"), any())).thenReturn("reply"); + when(agent.respondTo(eq("telegram-42"), any(), any(ResponseListener.class))).thenReturn("reply"); chatChannel.chat("telegram-42", "hello"); - verify(agent).respondTo(eq("telegram-42"), eq("hello")); + verify(agent).respondTo(eq("telegram-42"), eq("hello"), any(ResponseListener.class)); } // ----------------------------------------------------------------------- @@ -214,4 +217,106 @@ void sendMessagePushesOobHtmlToActiveSession() throws IOException { verify(session).sendMessage(any(TextMessage.class)); } + + @Test + void flushPendingMessagesDeliversMessagesBufferedWhileSendFailed() throws IOException { + WebSocketSession failingSession = mock(WebSocketSession.class); + when(failingSession.isOpen()).thenReturn(true); + org.mockito.Mockito.doThrow(new IOException("connection gone")).when(failingSession).sendMessage(any()); + chatChannel.setWsSession(failingSession); + chatChannel.sendMessage("Background result"); + + WebSocketSession session = openSession(); + chatChannel.flushPendingMessages(); + + verify(session).sendMessage(any(TextMessage.class)); + } + + @Test + void flushPendingMessagesDoesNothingWhenBufferIsEmpty() throws IOException { + WebSocketSession session = mock(WebSocketSession.class); + chatChannel.setWsSession(session); + + chatChannel.flushPendingMessages(); + + verify(session, never()).sendMessage(any()); + } + + // ----------------------------------------------------------------------- + // streaming frames + // ----------------------------------------------------------------------- + + @Test + void chatStreamsTokensAsChunkFramesFollowedByDoneFrame() throws IOException { + WebSocketSession session = openSession(); + agentStreams(listener -> { + listener.onToken("Hello "); + listener.onToken("world"); + listener.onComplete(); + }); + + chatChannel.chat("web", "hello"); + + List> frames = capturedFrames(session, 3); + assertThat(frames.get(0)) + .containsEntry("type", "chunk") + .containsEntry("data", "Hello ") + .containsEntry("conversationId", "web"); + assertThat(frames.get(1)) + .containsEntry("type", "chunk") + .containsEntry("data", "world"); + assertThat(frames.get(2)) + .containsEntry("type", "done") + .containsEntry("conversationId", "web") + .doesNotContainKey("data"); + } + + @Test + void chatStreamsErrorFrameWhenResponseFails() throws IOException { + WebSocketSession session = openSession(); + agentStreams(listener -> listener.onError("boom")); + + chatChannel.chat("web", "hello"); + + List> frames = capturedFrames(session, 1); + assertThat(frames.get(0)) + .containsEntry("type", "error") + .containsEntry("data", "boom") + .containsEntry("conversationId", "web"); + } + + @Test + void chatDropsStreamFramesWhenNoSessionIsActive() { + agentStreams(listener -> { + listener.onToken("Hello"); + listener.onComplete(); + }); + + // should not throw + chatChannel.chat("web", "hello"); + } + + private WebSocketSession openSession() { + WebSocketSession session = mock(WebSocketSession.class); + when(session.isOpen()).thenReturn(true); + chatChannel.setWsSession(session); + return session; + } + + private void agentStreams(java.util.function.Consumer progress) { + when(agent.respondTo(eq("web"), eq("hello"), any(ResponseListener.class))).thenAnswer(invocation -> { + progress.accept(invocation.getArgument(2)); + return ""; + }); + } + + @SuppressWarnings("unchecked") + private static List> capturedFrames(WebSocketSession session, int expectedCount) throws IOException { + var messageCaptor = org.mockito.ArgumentCaptor.forClass(TextMessage.class); + verify(session, org.mockito.Mockito.times(expectedCount)).sendMessage(messageCaptor.capture()); + ObjectMapper objectMapper = new ObjectMapper(); + return messageCaptor.getAllValues().stream() + .map(message -> (Map) objectMapper.readValue(message.getPayload(), Map.class)) + .toList(); + } } diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java new file mode 100644 index 00000000..cf177b09 --- /dev/null +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java @@ -0,0 +1,139 @@ +package ai.javaclaw.chat.ws; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.ResponseListener; +import ai.javaclaw.channels.ChannelRegistry; +import ai.javaclaw.chat.ChatChannel; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; +import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; +import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import tools.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Full WebSocket round trip: a userMessage frame is sent to /ws/chat and the streamed + * response must arrive as multiple JSON chunk frames followed by a done frame. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = ChatStreamingIntegrationTest.TestConfig.class) +class ChatStreamingIntegrationTest { + + @LocalServerPort + int port; + + final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void userMessageIsAnsweredWithMultipleChunkFramesBeforeDone() throws Exception { + BlockingQueue received = new LinkedBlockingQueue<>(); + WebSocketSession session = new StandardWebSocketClient() + .execute(new TextWebSocketHandler() { + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + received.add(message.getPayload()); + } + }, "ws://localhost:" + port + "/ws/chat") + .get(5, TimeUnit.SECONDS); + + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "userMessage", + "conversationId", "web", + "message", "hi" + )))); + + List frameTypes = collectFrameTypesUntilDone(received); + + assertThat(frameTypes) + .containsSubsequence("chunk", "chunk", "done") + .last().isEqualTo("done"); + } finally { + session.close(); + } + } + + /** + * Collects the {@code type} of every JSON frame until the done frame arrives. + * Non-JSON payloads (htmx HTML fragments) are ignored. + */ + private List collectFrameTypesUntilDone(BlockingQueue received) throws Exception { + List frameTypes = new ArrayList<>(); + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + String payload = received.poll(500, TimeUnit.MILLISECONDS); + if (payload == null || !payload.startsWith("{")) continue; + + @SuppressWarnings("unchecked") + Map frame = objectMapper.readValue(payload, Map.class); + frameTypes.add((String) frame.get("type")); + if ("done".equals(frame.get("type"))) break; + } + return frameTypes; + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration({TomcatServletWebServerAutoConfiguration.class, + DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class}) + @Import({WebSocketConfig.class, ChatWebSocketHandler.class, ChatChannel.class}) + static class TestConfig { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + ChannelRegistry channelRegistry() { + return new ChannelRegistry(); + } + + @Bean + ChatMemoryRepository chatMemoryRepository() { + return new InMemoryChatMemoryRepository(); + } + + @Bean + Agent agent() { + return new Agent() { + @Override + public String respondTo(String conversationId, String question) { + return "Hello world"; + } + + @Override + public String respondTo(String conversationId, String question, ResponseListener listener) { + listener.onToken("Hello "); + listener.onToken("world"); + listener.onComplete(); + return "Hello world"; + } + + @Override + public T prompt(String conversationId, String input, Class result) { + return null; + } + }; + } + } +} diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java index c150440b..90902957 100644 --- a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java @@ -85,6 +85,34 @@ void handleUserMessageShowsGenericProviderErrorForUnexpectedFailures() throws Ex .contains("Details: boom"); } + @Test + void handleUserMessageClearsTypingIndicatorWithoutAppendingBubbleWhenResponseWasStreamed() throws Exception { + ChatChannel chatChannel = mock(ChatChannel.class); + WebSocketSession session = mock(WebSocketSession.class); + ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); + + // the response is streamed to the client by ChatChannel while chat() runs + when(chatChannel.chat("web", "hello")).thenReturn("streamed response"); + + handler.handleTextMessage(session, new TextMessage(new ObjectMapper().writeValueAsString(Map.of( + "type", "userMessage", + "conversationId", "web", + "message", "hello" + )))); + + ArgumentCaptor htmlCaptor = ArgumentCaptor.forClass(String[].class); + var inOrder = inOrder(chatChannel); + inOrder.verify(chatChannel).sendHtml(htmlCaptor.capture()); + inOrder.verify(chatChannel).chat("web", "hello"); + inOrder.verify(chatChannel).sendHtml(htmlCaptor.capture()); + verifyNoMoreInteractions(chatChannel); + + assertThat(String.join("", htmlCaptor.getAllValues().get(1))) + .contains("typing-indicator") + .doesNotContain("streamed response") + .doesNotContain("ar-msg--agent"); + } + @Test void handleChannelChangedSendsHistoryAndInputArea() throws Exception { ChatChannel chatChannel = mock(ChatChannel.class); diff --git a/base/src/main/java/ai/javaclaw/agent/Agent.java b/base/src/main/java/ai/javaclaw/agent/Agent.java index c01efa20..13836ce9 100644 --- a/base/src/main/java/ai/javaclaw/agent/Agent.java +++ b/base/src/main/java/ai/javaclaw/agent/Agent.java @@ -4,6 +4,13 @@ public interface Agent { String respondTo(String conversationId, String question); + default String respondTo(String conversationId, String question, ResponseListener listener) { + String response = respondTo(conversationId, question); + listener.onToken(response); + listener.onComplete(); + return response; + } + T prompt(String conversationId, String input, Class result); } diff --git a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java index abb50103..e63b4761 100644 --- a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java +++ b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java @@ -1,5 +1,7 @@ package ai.javaclaw.agent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.stereotype.Component; @@ -7,6 +9,8 @@ @Component public class DefaultAgent implements Agent { + private static final Logger log = LoggerFactory.getLogger(DefaultAgent.class); + private final ChatClient chatClient; public DefaultAgent(ChatClient chatClient) { @@ -22,6 +26,35 @@ public String respondTo(String conversationId, String question) { .content(); } + @Override + public String respondTo(String conversationId, String question, ResponseListener listener) { + StringBuilder fullResponse = new StringBuilder(); + try { + chatClient + .prompt(question) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .stream() + .content() + .doOnNext(token -> { + fullResponse.append(token); + listener.onToken(token); + }) + .blockLast(); + } catch (UnsupportedOperationException e) { + // The configured model cannot stream — fall back to the blocking call + String response = respondTo(conversationId, question); + listener.onToken(response); + listener.onComplete(); + return response; + } catch (RuntimeException e) { + log.warn("Streaming response failed for conversation {}", conversationId, e); + listener.onError(summarizeError(e)); + return fullResponse.toString(); + } + listener.onComplete(); + return fullResponse.toString(); + } + @Override public T prompt(String conversationId, String input, Class result) { return chatClient @@ -30,4 +63,9 @@ public T prompt(String conversationId, String input, Class result) { .call() .entity(result); } + + private static String summarizeError(Throwable ex) { + String message = ex.getMessage(); + return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message; + } } diff --git a/base/src/main/java/ai/javaclaw/agent/ResponseListener.java b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java new file mode 100644 index 00000000..b2d05659 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java @@ -0,0 +1,50 @@ +package ai.javaclaw.agent; + +import java.util.function.Consumer; + +/** + * Callback for observing an agent response as it is produced. Callers that can + * render progress incrementally (e.g. the web chat) supply an implementation; + * where the tokens go (WebSocket, SSE, console) is entirely the caller's concern. + */ +public interface ResponseListener { + + /** + * Called for every token as it arrives from the model. + */ + void onToken(String token); + + /** + * Called once after the last token; no further callbacks follow. + */ + void onComplete(); + + /** + * Called when the response fails; no further callbacks follow. Tokens already + * delivered via {@link #onToken(String)} may precede the failure. + */ + void onError(String message); + + /** + * Creates a listener from three lambdas, avoiding an anonymous class at the call site. + */ + static ResponseListener of(Consumer onToken, Runnable onComplete, Consumer onError) { + return new ResponseListener() { + + @Override + public void onToken(String token) { + onToken.accept(token); + } + + @Override + public void onComplete() { + onComplete.run(); + } + + @Override + public void onError(String message) { + onError.accept(message); + } + }; + } +} diff --git a/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java b/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java new file mode 100644 index 00000000..4652fe33 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java @@ -0,0 +1,83 @@ +package ai.javaclaw.agent; + +import org.junit.jupiter.api.BeforeEach; +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.ai.chat.client.ChatClient; +import reactor.core.publisher.Flux; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultAgentTest { + + @Mock ChatClient chatClient; + @Mock ChatClient.ChatClientRequestSpec requestSpec; + @Mock ChatClient.StreamResponseSpec streamSpec; + @Mock ChatClient.CallResponseSpec callSpec; + @Mock ResponseListener listener; + + DefaultAgent agent; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + agent = new DefaultAgent(chatClient); + when(chatClient.prompt("hello")).thenReturn(requestSpec); + when(requestSpec.advisors(any(Consumer.class))).thenReturn(requestSpec); + when(requestSpec.stream()).thenReturn(streamSpec); + } + + @Test + void reportsEachTokenAndCompletionToListener() { + when(streamSpec.content()).thenReturn(Flux.just("Hello ", "world")); + + String response = agent.respondTo("web", "hello", listener); + + assertThat(response).isEqualTo("Hello world"); + var inOrder = inOrder(listener); + inOrder.verify(listener).onToken("Hello "); + inOrder.verify(listener).onToken("world"); + inOrder.verify(listener).onComplete(); + verify(listener, never()).onError(any()); + } + + @Test + void reportsErrorAndReturnsPartialResponseWhenStreamFails() { + when(streamSpec.content()).thenReturn(Flux.concat( + Flux.just("Hello "), + Flux.error(new RuntimeException("boom")))); + + String response = agent.respondTo("web", "hello", listener); + + assertThat(response).isEqualTo("Hello "); + var inOrder = inOrder(listener); + inOrder.verify(listener).onToken("Hello "); + inOrder.verify(listener).onError("boom"); + verify(listener, never()).onComplete(); + } + + @Test + void fallsBackToBlockingCallWhenModelDoesNotSupportStreaming() { + when(streamSpec.content()).thenReturn(Flux.error(new UnsupportedOperationException("streaming is not supported"))); + when(requestSpec.call()).thenReturn(callSpec); + when(callSpec.content()).thenReturn("full response"); + + String response = agent.respondTo("web", "hello", listener); + + assertThat(response).isEqualTo("full response"); + var inOrder = inOrder(listener); + inOrder.verify(listener).onToken("full response"); + inOrder.verify(listener).onComplete(); + verify(listener, never()).onError(any()); + } +} diff --git a/plugins/whatsapp/build.gradle b/plugins/whatsapp/build.gradle deleted file mode 100644 index 9db7b072..00000000 --- a/plugins/whatsapp/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index d4dfb64c..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/ProcessLauncher.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index c7e8f9b7..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliProperties.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index ec194a42..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisor.java +++ /dev/null @@ -1,231 +0,0 @@ -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 deleted file mode 100644 index 59895506..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayload.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index e15b8b1d..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannel.java +++ /dev/null @@ -1,177 +0,0 @@ -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 deleted file mode 100644 index 711889db..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelAutoConfiguration.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 2f62c2bf..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppChannelMessageReceivedEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 701e1dfe..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProvider.java +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index a75f366b..00000000 --- a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookController.java +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index bde06980..00000000 --- a/plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4fc0f315..00000000 --- a/plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb +++ /dev/null @@ -1,66 +0,0 @@ -

-

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 deleted file mode 100644 index 3fa5ccad..00000000 --- a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliSyncSupervisorTest.java +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index bd38680f..00000000 --- a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWebhookPayloadTest.java +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 4b98c8bc..00000000 --- a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WacliWhatsAppChannelTest.java +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index 9a10bedd..00000000 --- a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppOnboardingProviderTest.java +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index c639b1c0..00000000 --- a/plugins/whatsapp/src/test/java/ai/javaclaw/channels/whatsapp/WhatsAppWebhookControllerTest.java +++ /dev/null @@ -1,170 +0,0 @@ -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()); - } -}