From 84a23bf5f53377f7476fe6131bc49ce261476c3f Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Mon, 13 Apr 2026 18:55:43 +0300 Subject: [PATCH 1/3] Whatsapp --- app/build.gradle | 1 + build.gradle | 4 + plugins/whatsapp/build.gradle | 20 ++ .../channels/whatsapp/WhatsappChannel.java | 132 ++++++++++ .../WhatsappChannelAutoConfiguration.java | 33 +++ .../whatsapp/WhatsappOnboardingProvider.java | 107 ++++++++ .../channels/whatsapp/WhatsappProperties.java | 20 ++ .../channels/whatsapp/WhatsappService.java | 90 +++++++ .../WhatsappOnboardingActionsController.java | 72 ++++++ .../WhatsappOnboardingLinkService.java | 241 ++++++++++++++++++ .../WhatsappOnboardingQrController.java | 124 +++++++++ .../WhatsappOnboardingSessionKeys.java | 12 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../onboarding/steps/whatsapp.html.peb | 79 ++++++ settings.gradle | 1 + 15 files changed, 938 insertions(+) create mode 100644 plugins/whatsapp/build.gradle create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappChannel.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/WhatsappOnboardingProvider.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappProperties.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappService.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingActionsController.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingLinkService.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingQrController.java create mode 100644 plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingSessionKeys.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 diff --git a/app/build.gradle b/app/build.gradle index 8907aab5..10b2f4a4 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/build.gradle b/build.gradle index 7b822418..412dbc9a 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,10 @@ allprojects { name = 'central-portal-snapshots' url = 'https://central.sonatype.com/repository/maven-snapshots/' } + // Cobalt declares Aspose + Jitpack repos in its Maven POM. Gradle doesn't inherit those repositories, + // so we add them explicitly to resolve transitive dependencies (e.g. com.aspose:aspose-words). + maven { url = 'https://releases.aspose.com/java/repo/' } + maven { url = 'https://jitpack.io' } } } diff --git a/plugins/whatsapp/build.gradle b/plugins/whatsapp/build.gradle new file mode 100644 index 00000000..b9bfb376 --- /dev/null +++ b/plugins/whatsapp/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':base') + implementation 'org.springframework.boot:spring-boot-starter' + + // Unofficial WhatsApp Web / Linked Devices library (QR login, session persistence, events). + // If this version breaks, bump it and adjust the API calls in WhatsappService/WhatsappChannel. + implementation 'com.github.auties00:cobalt:0.0.10' + + // This plugin optionally exposes REST endpoints; the main app already includes WebMVC. + // Keeping this as compileOnly avoids pulling WebMVC into non-web apps that might depend on the plugin. + compileOnly 'org.springframework.boot:spring-boot-starter-webmvc' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappChannel.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappChannel.java new file mode 100644 index 00000000..8e90030e --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappChannel.java @@ -0,0 +1,132 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.Channel; +import ai.javaclaw.channels.ChannelMessageReceivedEvent; +import ai.javaclaw.channels.ChannelRegistry; +import it.auties.whatsapp.api.Whatsapp; +import it.auties.whatsapp.model.info.ChatMessageInfo; +import it.auties.whatsapp.model.jid.Jid; +import it.auties.whatsapp.model.jid.JidServer; +import it.auties.whatsapp.model.message.standard.TextMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + + +public class WhatsappChannel implements Channel { + private static final Logger log = LoggerFactory.getLogger(WhatsappChannel.class); + + private final WhatsappService whatsappService; + private final Agent agent; + private final ChannelRegistry channelRegistry; + + private final Jid allowedChatJid; + private final AtomicReference lastChatJid = new AtomicReference<>(); + + public WhatsappChannel(WhatsappService whatsappService, + WhatsappProperties properties, + Agent agent, + ChannelRegistry channelRegistry) { + this.whatsappService = whatsappService; + this.agent = agent; + this.channelRegistry = channelRegistry; + this.allowedChatJid = normalizeAllowedChatJid(properties.normalizedAllowedChatJid()); + + channelRegistry.registerChannel(this); + whatsappService.start(this::onIncomingChatMessage); + log.info("Started WhatsApp integration (allowedChatJid={})", allowedChatJid); + } + + private void onIncomingChatMessage(Whatsapp api, ChatMessageInfo info) { + if (info.fromMe()) { + log.info("Received chat message from me {}", tryExtractText(info).get()); + return; + } + + var chatJid = info.chatJid(); + + if (!isAllowedChat(info)) { + return; + } + + try { + api.markMessageRead(info) + .orTimeout(5, TimeUnit.SECONDS) + .get(); + } catch (Throwable t) { + log.debug("Failed to start markMessageRead", t); + } + + lastChatJid.set(chatJid); + + var text = tryExtractText(info).orElse(null); + if (text == null) { + return; + } + + channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), text)); + + String response = agent.respondTo(getConversationId(chatJid), text); + api.sendMessage(chatJid, response); + } + + @Override + public void sendMessage(String message) { + var chat = lastChatJid.get(); + if (chat == null) { + log.error("No known WhatsApp chat, cannot send message '{}'", message); + return; + } + + try { + whatsappService.sendTextMessage(chat.toString(), message); + } catch (Exception e) { + log.warn("Failed to send WhatsApp message", e); + } + } + + private boolean isAllowedChat(ChatMessageInfo info) { + if (allowedChatJid == null) { + return false; + } + var chat = info.chatJid(); + return chat != null && chat.toSimpleJid().equals(allowedChatJid.toSimpleJid()); + } + + private static String getConversationId(Jid chatJid) { + return "whatsapp-" + chatJid; + } + + private static Jid normalizeAllowedChatJid(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + + if (raw.contains("@")) { + return Jid.of(raw).toSimpleJid(); + } + + var normalized = raw.replace("+", "").replaceAll("\\s+", ""); + return Jid.of(normalized, JidServer.whatsapp()).toSimpleJid(); + } + + + private static Optional tryExtractText(ChatMessageInfo info) { + var container = info.message(); + if (container == null) { + return Optional.empty(); + } + + var content = container.content(); + if (content instanceof TextMessage textMessage) { + var text = textMessage.text(); + return text == null || text.isBlank() ? Optional.empty() : Optional.of(text.trim()); + } + + return Optional.empty(); + } +} 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..fbb62fa1 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappChannelAutoConfiguration.java @@ -0,0 +1,33 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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; +import org.springframework.web.bind.annotation.RestController; + +@AutoConfiguration +@EnableConfigurationProperties(WhatsappProperties.class) +public class WhatsappChannelAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "agent.channels.whatsapp", name = "enabled", havingValue = "true") + public WhatsappService whatsappService(WhatsappProperties properties) { + return new WhatsappService(properties); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "agent.channels.whatsapp", name = "enabled", havingValue = "true") + public WhatsappChannel whatsappChannel(Agent agent, + WhatsappService whatsappService, + WhatsappProperties properties, + ChannelRegistry channelRegistry) { + return new WhatsappChannel(whatsappService, properties, agent, channelRegistry); + } +} 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..454de9a8 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappOnboardingProvider.java @@ -0,0 +1,107 @@ +package ai.javaclaw.channels.whatsapp; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.channels.whatsapp.onboarding.WhatsappOnboardingLinkService; +import ai.javaclaw.channels.whatsapp.onboarding.WhatsappOnboardingSessionKeys; +import ai.javaclaw.onboarding.OnboardingProvider; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Map; + +@Component +@Order(55) +public class WhatsappOnboardingProvider implements OnboardingProvider { + + private static final String ENABLED_PROPERTY = "agent.channels.whatsapp.enabled"; + private static final String ALIAS_PROPERTY = "agent.channels.whatsapp.session-alias"; + private static final String ALLOWED_CHAT_JID_PROPERTY = "agent.channels.whatsapp.allowed-chat-jid"; + + private final Environment env; + private final WhatsappOnboardingLinkService linkService; + + public WhatsappOnboardingProvider(Environment env, WhatsappOnboardingLinkService linkService) { + this.env = env; + this.linkService = linkService; + } + + @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) { + model.put("whatsappSessionAlias", session.getOrDefault( + WhatsappOnboardingSessionKeys.SESSION_ALIAS, env.getProperty(ALIAS_PROPERTY, "javaclaw-whatsapp"))); + + String connKey = (String) session.getOrDefault(WhatsappOnboardingSessionKeys.CONN_KEY, ""); + model.put("whatsappConnKey", connKey); + model.put("whatsappLinked", !connKey.isBlank() && linkService.isLinked(connKey)); + + model.put("whatsappAllowedChatJid", session.getOrDefault( + WhatsappOnboardingSessionKeys.ALLOWED_CHAT_JID, env.getProperty(ALLOWED_CHAT_JID_PROPERTY, ""))); + } + + @Override + public String processStep(Map formParams, Map session) { + String alias = formParams.getOrDefault("whatsappSessionAlias", "").trim(); + String allowedChatJid = formParams.getOrDefault("whatsappAllowedChatJid", "").trim(); + + if (alias.isBlank()) { + return "Enter a session alias to continue (used for session persistence)."; + } + + session.put(WhatsappOnboardingSessionKeys.SESSION_ALIAS, alias); + if (!allowedChatJid.isBlank()) { + session.put(WhatsappOnboardingSessionKeys.ALLOWED_CHAT_JID, allowedChatJid); + } + + String connKey = (String) session.get(WhatsappOnboardingSessionKeys.CONN_KEY); + if (connKey == null || connKey.isBlank()) { + return "Click 'Generate QR' to start linking WhatsApp, then scan the QR code."; + } + if (!linkService.isLinked(connKey)) { + return "Waiting for WhatsApp linking. Scan the QR code, then click Continue."; + } + + if (allowedChatJid.isBlank()) { + return "Enter the allowed WhatsApp chat JID/phone (only this chat can control the agent)."; + } + + session.put(WhatsappOnboardingSessionKeys.ENABLED, "true"); + return null; + } + + @Override + public void saveConfiguration(Map session, ConfigurationManager configurationManager) throws IOException { + var enabled = (String) session.get(WhatsappOnboardingSessionKeys.ENABLED); + var alias = (String) session.get(WhatsappOnboardingSessionKeys.SESSION_ALIAS); + var allowedChatJid = (String) session.get(WhatsappOnboardingSessionKeys.ALLOWED_CHAT_JID); + + if ("true".equalsIgnoreCase(enabled) && alias != null && !alias.isBlank() && allowedChatJid != null && !allowedChatJid.isBlank()) { + configurationManager.updateProperties(Map.of( + ENABLED_PROPERTY, "true", + ALIAS_PROPERTY, alias, + ALLOWED_CHAT_JID_PROPERTY, allowedChatJid + )); + } + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappProperties.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappProperties.java new file mode 100644 index 00000000..b059b2fd --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappProperties.java @@ -0,0 +1,20 @@ +package ai.javaclaw.channels.whatsapp; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "agent.channels.whatsapp") +public record WhatsappProperties( + boolean enabled, + String sessionAlias, + String allowedChatJid +) { + public String effectiveSessionAlias() { + var alias = sessionAlias == null ? "" : sessionAlias.trim(); + return alias.isBlank() ? "javaclaw-whatsapp" : alias; + } + + public String normalizedAllowedChatJid() { + var raw = allowedChatJid == null ? "" : allowedChatJid.trim(); + return raw.isBlank() ? null : raw; + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappService.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappService.java new file mode 100644 index 00000000..c23e4c29 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/WhatsappService.java @@ -0,0 +1,90 @@ +package ai.javaclaw.channels.whatsapp; + +import it.auties.whatsapp.api.Listener; +import it.auties.whatsapp.api.QrHandler; +import it.auties.whatsapp.api.WebHistorySetting; +import it.auties.whatsapp.api.Whatsapp; +import it.auties.whatsapp.model.info.ChatMessageInfo; +import it.auties.whatsapp.model.info.NewsletterMessageInfo; +import it.auties.whatsapp.model.jid.Jid; +import it.auties.whatsapp.model.jid.JidServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + + +public class WhatsappService { + private static final Logger log = LoggerFactory.getLogger(WhatsappService.class); + + private final WhatsappProperties properties; + private final AtomicReference clientRef = new AtomicReference<>(); + + public WhatsappService(WhatsappProperties properties) { + this.properties = properties; + } + + public void start(BiConsumer onIncomingChatMessage) { + + var alias = properties.effectiveSessionAlias(); + log.info("Starting WhatsApp integration (sessionAlias={})", alias); + + var options = Whatsapp + .webBuilder() + .newConnection(alias); + + var whatsapp = options.registered() + .orElseGet(() -> options.unregistered(QrHandler.toPlainString(qr -> + log.info("WhatsApp requires linking. Use the onboarding step to scan the QR (sessionAlias={}).", alias) + ))); + + whatsapp.addLoggedInListener(_ -> log.info("WhatsApp logged in (sessionAlias={})", alias)); + whatsapp.addDisconnectedListener(reason -> log.warn("WhatsApp disconnected: {}", reason)); + whatsapp.addNewChatMessageListener(onIncomingChatMessage::accept); + + + clientRef.set(whatsapp); + whatsapp.connect() + .whenComplete((api, err) -> { + if (err != null) { + log.error("WhatsApp connection failed", err); + } + }); + } + + public Path defaultSessionPath() { + // Cobalt default session path: + // macOS/Linux: $HOME/.whatsapp4j/web// + // Windows: %USERPROFILE%\\.whatsapp4j\\web\\\\ + return Path.of(System.getProperty("user.home"), ".whatsapp4j", "web", properties.effectiveSessionAlias()); + } + + public void sendTextMessage(String to, String message) { + var api = clientRef.get(); + if (api == null) { + throw new IllegalStateException("WhatsApp is not initialized yet (client is null)."); + } + + var destination = parseDestination(to); + api.sendMessage(destination, message); + } + + private static Jid parseDestination(String to) { + var trimmed = to == null ? "" : to.trim(); + if (trimmed.isBlank()) { + throw new IllegalArgumentException("Missing destination 'to'"); + } + + if (trimmed.contains("@")) { + return Jid.of(trimmed); + } + + var normalized = trimmed.replace("+", "").replaceAll("\\s+", ""); + return Jid.of(normalized, JidServer.whatsapp()); + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingActionsController.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingActionsController.java new file mode 100644 index 00000000..e31b9f7c --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingActionsController.java @@ -0,0 +1,72 @@ +package ai.javaclaw.channels.whatsapp.onboarding; + +import jakarta.servlet.http.HttpSession; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +public class WhatsappOnboardingActionsController { + private final WhatsappOnboardingLinkService linkService; + + public WhatsappOnboardingActionsController(WhatsappOnboardingLinkService linkService) { + this.linkService = linkService; + } + + @PostMapping(value = "/onboarding/whatsapp/start", produces = MediaType.TEXT_HTML_VALUE) + public String start(@RequestParam(name = "whatsappSessionAlias", required = false) String alias, + @RequestParam(name = "whatsappAllowedChatJid", required = false) String allowedChatJid, + HttpSession session) { + var trimmedAlias = alias == null ? "" : alias.trim(); + if (trimmedAlias.isBlank()) { + return """ + + """; + } + + session.setAttribute(WhatsappOnboardingSessionKeys.SESSION_ALIAS, trimmedAlias); + var trimmedAllowed = allowedChatJid == null ? "" : allowedChatJid.trim(); + if (!trimmedAllowed.isBlank()) { + session.setAttribute(WhatsappOnboardingSessionKeys.ALLOWED_CHAT_JID, trimmedAllowed); + } + + String key = (String) session.getAttribute(WhatsappOnboardingSessionKeys.CONN_KEY); + if (key == null || key.isBlank()) { + key = UUID.randomUUID().toString(); + session.setAttribute(WhatsappOnboardingSessionKeys.CONN_KEY, key); + } + + linkService.startOrAttach(key, trimmedAlias); + + return """ + + + """.formatted(escapeHtmlAttr(key)); + } + + private static String escapeHtmlAttr(String input) { + if (input == null) { + return ""; + } + return input + .replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") + .replace("'", "'"); + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingLinkService.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingLinkService.java new file mode 100644 index 00000000..6b210207 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingLinkService.java @@ -0,0 +1,241 @@ +package ai.javaclaw.channels.whatsapp.onboarding; + +import it.auties.whatsapp.api.QrHandler; +import it.auties.whatsapp.api.WebHistorySetting; +import it.auties.whatsapp.api.Whatsapp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + + +@Service +public class WhatsappOnboardingLinkService { + private static final Logger log = LoggerFactory.getLogger(WhatsappOnboardingLinkService.class); + + public record Snapshot( + String sessionAlias, + boolean connected, + boolean linked, + String qrDataUriPng, + String qrRaw, + String error, + Instant updatedAt, + String sessionPath + ) { + } + + private static final class State { + final String sessionAlias; + final AtomicBoolean started = new AtomicBoolean(false); + volatile Whatsapp client; + volatile CompletableFuture connectFuture; + volatile boolean linked; + volatile boolean connected; + volatile String qrRaw; + volatile String qrDataUriPng; + volatile String error; + volatile Instant updatedAt = Instant.now(); + + State(String sessionAlias) { + this.sessionAlias = sessionAlias; + } + } + + private final Map states = new ConcurrentHashMap<>(); + + public void startOrAttach(String key, String sessionAlias) { + var state = states.compute(key, (k, existing) -> { + if (existing == null) { + return new State(sessionAlias); + } + + if (!existing.sessionAlias.equals(sessionAlias)) { + safeDisconnect(existing); + return new State(sessionAlias); + } + return existing; + }); + + if (!state.started.compareAndSet(false, true)) { + return; + } + + try { + var options = Whatsapp.webBuilder() + .newConnection(sessionAlias) + + .historySetting(WebHistorySetting.discard(false)) + .automaticMessageReceipts(true); + + + var whatsapp = options.registered().orElseGet(() -> + options.unregistered(QrHandler.toPlainString(qr -> onQr(state, qr)))); + + state.client = whatsapp; + + whatsapp.addLoggedInListener(api -> { + state.linked = true; + state.connected = api.isConnected(); + state.updatedAt = Instant.now(); + log.info("WhatsApp linked during onboarding (sessionAlias={})", sessionAlias); + }); + + whatsapp.addDisconnectedListener(reason -> { + state.connected = false; + state.updatedAt = Instant.now(); + log.warn("WhatsApp onboarding client disconnected: {}", reason); + }); + + state.connectFuture = whatsapp.connect().whenComplete((api, err) -> { + if (err != null) { + state.error = String.valueOf(err.getMessage()); + state.updatedAt = Instant.now(); + log.warn("WhatsApp onboarding connect failed", err); + return; + } + state.connected = api.isConnected(); + if (state.connected) { + state.linked = true; + } + state.updatedAt = Instant.now(); + }); + } catch (Throwable t) { + state.error = String.valueOf(t.getMessage()); + state.updatedAt = Instant.now(); + log.warn("Failed to start WhatsApp onboarding flow", t); + } + } + + public boolean isLinked(String key) { + var state = states.get(key); + if (state == null) { + return false; + } + + var client = state.client; + return state.linked || state.connected || (client != null && client.isConnected()); + } + + public Optional snapshot(String key) { + var state = states.get(key); + if (state == null) { + return Optional.empty(); + } + + var home = System.getProperty("user.home"); + Path sessionPath = Path.of(home, ".whatsapp4j", "web", state.sessionAlias); + + return Optional.of(new Snapshot( + state.sessionAlias, + state.connected, + state.linked, + state.qrDataUriPng, + state.qrRaw, + state.error, + state.updatedAt, + sessionPath.toString() + )); + } + + private void onQr(State state, String qr) { + state.qrRaw = qr; + state.updatedAt = Instant.now(); + + try { + var matrix = QrHandler.createMatrix(qr, 512, 512); + var cropped = cropToCode(matrix); + var padded = addPadding(cropped, 16); + var img = toImage(padded); + var bytes = toPng(img); + state.qrDataUriPng = "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes); + } catch (Throwable t) { + state.qrDataUriPng = null; + } + } + + private static com.google.zxing.common.BitMatrix cropToCode(com.google.zxing.common.BitMatrix matrix) { + int[] rect = matrix.getEnclosingRectangle(); + if (rect == null) { + return matrix; + } + + int left = rect[0]; + int top = rect[1]; + int width = rect[2]; + int height = rect[3]; + + if (width <= 0 || height <= 0) { + return matrix; + } + + var cropped = new com.google.zxing.common.BitMatrix(width, height); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (matrix.get(left + x, top + y)) { + cropped.set(x, y); + } + } + } + return cropped; + } + + private static com.google.zxing.common.BitMatrix addPadding(com.google.zxing.common.BitMatrix matrix, int padPx) { + if (padPx <= 0) { + return matrix; + } + + int width = matrix.getWidth(); + int height = matrix.getHeight(); + var padded = new com.google.zxing.common.BitMatrix(width + padPx * 2, height + padPx * 2); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (matrix.get(x, y)) { + padded.set(x + padPx, y + padPx); + } + } + } + return padded; + } + + private static BufferedImage toImage(com.google.zxing.common.BitMatrix matrix) { + int width = matrix.getWidth(); + int height = matrix.getHeight(); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + int on = 0x000000; + int off = 0xFFFFFF; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + image.setRGB(x, y, matrix.get(x, y) ? on : off); + } + } + return image; + } + + private static byte[] toPng(BufferedImage image) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "PNG", out); + return out.toByteArray(); + } + + private static void safeDisconnect(State state) { + try { + var c = state.client; + if (c != null) { + c.disconnect(); + } + } catch (Throwable ignored) { + } + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingQrController.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingQrController.java new file mode 100644 index 00000000..c00d073c --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingQrController.java @@ -0,0 +1,124 @@ +package ai.javaclaw.channels.whatsapp.onboarding; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static java.util.Objects.requireNonNullElse; + +@RestController +public class WhatsappOnboardingQrController { + private final WhatsappOnboardingLinkService linkService; + + public WhatsappOnboardingQrController(WhatsappOnboardingLinkService linkService) { + this.linkService = linkService; + } + + @GetMapping(value = "/onboarding/whatsapp/fragment", produces = MediaType.TEXT_HTML_VALUE) + public String fragment(@RequestParam(name = "key", required = false) String key) { + if (key == null || key.isBlank()) { + return """ +
+
Missing onboarding key.
+
+ """; + } + + var snapshotOpt = linkService.snapshot(key); + if (snapshotOpt.isEmpty()) { + return """ +
+
No WhatsApp onboarding session found. Click Generate QR again.
+
+ """; + } + + var s = snapshotOpt.get(); + if (s.linked() || s.connected()) { + return """ + + + + """.formatted(escapeHtml(s.sessionPath()), escapeHtml(s.sessionPath())); + } + + var error = requireNonNullElse(s.error(), "").trim(); + if (!error.isBlank()) { + return """ +
+
+

Connection error. %s

+

Fix the issue, then click Generate QR again.

+
+
+ + """.formatted(escapeHtml(error)); + } + + var img = requireNonNullElse(s.qrDataUriPng(), "").trim(); + var raw = requireNonNullElse(s.qrRaw(), "").trim(); + if (!img.isBlank()) { + return """ +
+

Status: QR ready. Waiting for scan...

+

Scan this QR code in WhatsApp

+
+ WhatsApp QR code +
+

WhatsApp → Settings → Linked devices → Link a device

+

Session will be stored at: %s

+
+ + """.formatted(img, escapeHtml(s.sessionPath())); + } + + if (!raw.isBlank()) { + return """ +
+

Status: QR ready. Waiting for scan...

+

QR payload (could not render image)

+
%s
+

Session will be stored at: %s

+
+ + """.formatted(escapeHtml(raw), escapeHtml(s.sessionPath())); + } + + return """ +
+
+

Connecting…

+

Status: loading/restoring session or waiting for QR generation.

+

This page updates automatically.

+
+
+ + """; + } + + private static String escapeHtml(String input) { + if (input == null) { + return ""; + } + return input + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingSessionKeys.java b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingSessionKeys.java new file mode 100644 index 00000000..2ca41318 --- /dev/null +++ b/plugins/whatsapp/src/main/java/ai/javaclaw/channels/whatsapp/onboarding/WhatsappOnboardingSessionKeys.java @@ -0,0 +1,12 @@ +package ai.javaclaw.channels.whatsapp.onboarding; + + +public final class WhatsappOnboardingSessionKeys { + private WhatsappOnboardingSessionKeys() { + } + + public static final String ENABLED = "onboarding.whatsapp.enabled"; + public static final String SESSION_ALIAS = "onboarding.whatsapp.session-alias"; + public static final String CONN_KEY = "onboarding.whatsapp.conn-key"; + public static final String ALLOWED_CHAT_JID = "onboarding.whatsapp.allowed-chat-jid"; +} 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..c225b65b --- /dev/null +++ b/plugins/whatsapp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +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..7ffffb7b --- /dev/null +++ b/plugins/whatsapp/src/main/resources/templates/onboarding/steps/whatsapp.html.peb @@ -0,0 +1,79 @@ +
+

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

+

Connect WhatsApp (Linked Devices).

+

+ This uses an unofficial WhatsApp Web / Linked Devices library (Cobalt). On first start it prints a QR code in the server logs; + scan it in WhatsApp → Settings → Linked devices → Link a device. +

+ + {% if error %} +
+
{{ error }}
+
+ {% endif %} + +
+
+ +
+ +
+

Used to persist/restore the session on disk. Changing it creates a new session requiring a new QR scan.

+
+ +
+ +
+ +
+

Only this chat will be allowed to control the agent from WhatsApp.

+
+ + + +
+

Stored properties

+

agent.channels.whatsapp.enabled

+

agent.channels.whatsapp.session-alias

+

agent.channels.whatsapp.allowed-chat-jid

+
+ +
+ Back + + + + {% if isOptional %}Skip{% endif %} + Saving... +
+
+
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 3ccbd67f5e85bfe83743034738a8bdeb5120989a Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Wed, 15 Apr 2026 17:31:35 +0300 Subject: [PATCH 2/3] Whatsapp --- app/src/test/java/ai/.DS_Store | Bin 6148 -> 0 bytes app/workspace/app.mv.db | Bin 53248 -> 0 bytes base/src/test/resources/workspace/AGENT.md | 1 - workspace/AGENT.md | 6 - workspace/INFO.md | 11 -- workspace/skills/skill-creator/SKILL.md | 135 --------------------- 6 files changed, 153 deletions(-) delete mode 100644 app/src/test/java/ai/.DS_Store delete mode 100644 app/workspace/app.mv.db delete mode 100644 base/src/test/resources/workspace/AGENT.md delete mode 100644 workspace/AGENT.md delete mode 100644 workspace/INFO.md delete mode 100644 workspace/skills/skill-creator/SKILL.md diff --git a/app/src/test/java/ai/.DS_Store b/app/src/test/java/ai/.DS_Store deleted file mode 100644 index 36f3d054cd2b50329518571123b23958ee414042..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyG{c^3>-s>pfo8{?l15Mt0?(``~XNqY0x2^2=!Hb7oW!XAw+bcL83up$(~)W zXHPf9`3%6e=cjvM4PZ%k#Jh*N`MLYdZYpC$I`4SJh%*L^Z--g+^@MZp@Q4$RZ+H#esC*bAq`_;fJD2tZsg z9maLc62#^SVlSK$nW0%yiAl8@F)ZoKx2o%fQ)1F#HGEh-*=j-2ZbTpF)`XP gH{OmnQIvJf*F5iqQ)1AW4?0ml1Fnlq3jDPKpF`pm%K!iX diff --git a/app/workspace/app.mv.db b/app/workspace/app.mv.db deleted file mode 100644 index 5495ecf769ab11fdfd29b028bce4ca2cd2b770b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 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 P3nMzXI_?}7)-(SHgzsox diff --git a/base/src/test/resources/workspace/AGENT.md b/base/src/test/resources/workspace/AGENT.md deleted file mode 100644 index 9daeafb9..00000000 --- a/base/src/test/resources/workspace/AGENT.md +++ /dev/null @@ -1 +0,0 @@ -test diff --git a/workspace/AGENT.md b/workspace/AGENT.md deleted file mode 100644 index 6857384c..00000000 --- a/workspace/AGENT.md +++ /dev/null @@ -1,6 +0,0 @@ -You are an interactive assistant working for helping them with all their tasks and todos. Use the skills and the tools available to you to assist the user. -Here is useful information about : - - email: - - work role: - - address: - - any other info you want to share like spouse (with birthdate) and children. diff --git a/workspace/INFO.md b/workspace/INFO.md deleted file mode 100644 index 9c8ac050..00000000 --- a/workspace/INFO.md +++ /dev/null @@ -1,11 +0,0 @@ - -## Here is important information: - - the environment you are running in: {ENVIRONMENT_INFO} - - Your workspace is in folder `./workspace` (later noted as ``) and contains: - - Context and your main memory are in the `/context` folder. Here, all context can be found and must saved. Always search in this folder first before answering or taking action. Use it to verify your answers. - - Tasks need to be managed via the `TaskTool` that you can use (and only via the `TaskTool`). They are saved as markdown files in the `/tasks` folder and structured as follows: - - normal tasks `yyyy-MM-dd/--.md` - - recurring tasks `recurring/.md` - -### Tool calling -You have access to various tools and skills. Try to use them as much as possible. \ No newline at end of file diff --git a/workspace/skills/skill-creator/SKILL.md b/workspace/skills/skill-creator/SKILL.md deleted file mode 100644 index 7b8f3601..00000000 --- a/workspace/skills/skill-creator/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: skill-creator -description: Create new skills, modify and improve existing skills for JavaClaw. Use when users want to create a skill from scratch, edit or improve an existing skill, or refine a skill's description so it triggers more reliably. Also use when a user asks to "turn this workflow into a skill", "save these instructions as a skill", or "make the agent better at X". ---- - -# Skill Creator - -A skill for creating and iteratively improving JavaClaw skills. - -Skills in JavaClaw live at `workspace/skills//SKILL.md` and are loaded dynamically by the `SkillsTool` at runtime. The agent picks them up automatically — no restart needed. - -The core loop: -1. Understand what the skill should do -2. Write a draft `SKILL.md` -3. Walk through 2-3 test scenarios and evaluate the output inline -4. Refine based on feedback -5. Repeat until the user is happy - -Jump in wherever the user is. If they already have a draft, skip straight to testing. If they just have a vague idea, interview them first. - ---- - -## Creating a skill - -### Capture Intent - -Start by understanding what the user actually wants. If the conversation already shows a workflow the user wants to capture (a sequence of steps, tools used, corrections made), extract the answers from that — then fill gaps with the user. - -Key questions: -1. What should this skill enable the agent to do? -2. When should it trigger? (what kinds of user messages) -3. What does a good output look like? - -### Interview - -Ask about edge cases, expected inputs, success criteria, and any context the agent will need. Don't start writing until you have enough to write something useful — but don't over-interview either. If you can make a reasonable assumption, make it and note it. - -### Write the SKILL.md - -Create `workspace/skills//SKILL.md` with: - -- **`name`**: kebab-case identifier matching the directory name -- **`description`**: The primary triggering mechanism. Include _what_ the skill does AND _when_ to use it. The agent decides whether to load a skill based solely on this field — so be specific and make it slightly "pushy". Instead of "Helps with data analysis", write "Helps with data analysis. Use this skill whenever the user asks about datasets, CSV files, charts, or wants to understand or transform data — even if they don't say 'analysis'." -- **Body**: Step-by-step instructions for what the agent should do. Explain the _why_ behind important steps so the agent can apply judgment, not just follow rules mechanically. - -#### Anatomy of a skill - -``` -workspace/skills/ -└── skill-name/ - └── SKILL.md ← required; keep under ~300 lines -``` - -JavaClaw skills are single-file. The agent reads `SKILL.md` when the skill triggers. Keep it focused and readable — a dense wall of text is harder to follow than clear, structured instructions. - -#### Writing patterns - -Use imperative form. Explain reasoning where it matters. - -**Output format example:** -```markdown -## Report structure -Use this template every time: -# [Title] -## Summary -## Details -## Next steps -``` - -**Example pattern:** -```markdown -## Commit message format -User said: "Added login with Google" -Write: feat(auth): add Google OAuth login -``` - -#### Principles - -- Lean over comprehensive. A shorter, well-reasoned skill beats a long checklist. -- Explain the *why* so the agent can adapt to situations the skill didn't explicitly anticipate. -- Avoid ALL CAPS MUSTs and rigid structures where possible — trust the agent's judgment when given good context. -- Skills must not contain malware, exploit code, or content that would surprise the user given the skill's stated purpose. - ---- - -## Testing the skill - -After writing a draft, come up with 2-3 realistic test prompts — things a real user would actually say. Share them and confirm with the user before proceeding. - -For each test prompt, read the skill and follow its instructions yourself to complete the task. Present the output to the user and ask for feedback: - -> "Here's what the agent would do for: _[prompt]_. Does this look right? Anything you'd change?" - -This is intentionally lightweight — you wrote the skill and you're running it, so you have full context. The goal is a quick sanity check, not a rigorous benchmark. The human review is what matters. - ---- - -## Improving the skill - -After the user reviews, update the skill based on their feedback. A few principles: - -1. **Generalize, don't patch.** If a test case revealed a gap, think about what the underlying issue is and fix that — don't just add a special case for the exact example. - -2. **Stay lean.** Remove instructions that aren't pulling their weight. If the agent is already doing something naturally, you don't need to spell it out. - -3. **Explain the why.** If you find yourself adding a rigid rule, ask whether you could instead explain the reasoning so the agent understands _why_ and can apply it flexibly. - -After updating, re-run the same test prompts (and any new ones) and repeat until: -- The user is satisfied -- There's nothing more to improve - ---- - -## Description optimization - -The `description` field is the only thing the agent sees when deciding whether to load a skill. A weak description means the skill never triggers; an overly broad one means it triggers when it shouldn't. - -After finishing the skill, review the description with the user: - -1. Identify 3-5 kinds of user messages that _should_ trigger this skill — including indirect ones that don't name the skill explicitly. -2. Identify 2-3 near-miss cases — messages that share keywords but actually need something different. -3. Revise the description to clearly cover the should-trigger cases and implicitly exclude the near-misses. - -Present before/after to the user and confirm. - ---- - -## Updating an existing skill - -If the user wants to improve an existing skill rather than create a new one: -- Read the current `SKILL.md` first before suggesting any changes. -- Keep the skill's `name` and directory unchanged. -- Apply the same test → feedback → refine loop as above. - ---- \ No newline at end of file From e761c520c0570ac581e74a3c5551f10cf5df442b Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Wed, 15 Apr 2026 19:37:58 +0300 Subject: [PATCH 3/3] commit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f6fec4ab..193d50f5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ workspace/ !workspace/AGENT.md !workspace/skills/skill-creator *.private* +.gradle-home/