Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ workspace/
!workspace/AGENT.md
!workspace/skills/skill-creator
*.private*
.gradle-home/
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies {
implementation project(':plugins:telegram')
implementation project(':plugins:playwright')
implementation project(':plugins:brave')
implementation project(':plugins:whatsapp')

implementation 'org.springframework.ai:spring-ai-client-chat'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
Expand Down
Binary file removed app/src/test/java/ai/.DS_Store
Binary file not shown.
Binary file removed app/workspace/app.mv.db
Binary file not shown.
1 change: 0 additions & 1 deletion base/src/test/resources/workspace/AGENT.md

This file was deleted.

4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
}

Expand Down
20 changes: 20 additions & 0 deletions plugins/whatsapp/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}

Original file line number Diff line number Diff line change
@@ -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<Jid> 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<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> session, Map<String, Object> 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<String, String> formParams, Map<String, Object> 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<String, Object> 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
));
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading