From 11ecd5f1029d8447927fb0cb01d079e625dcfccd Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Wed, 1 Apr 2026 14:25:18 +0300 Subject: [PATCH 1/3] Add Discord channel integration using JDA --- app/build.gradle | 3 +- .../main/java/ai/javaclaw/chat/ChatHtml.java | 1 + plugins/discord/build.gradle | 12 ++ .../channels/discord/DiscordChannel.java | 121 ++++++++++++++ .../DiscordChannelAutoConfiguration.java | 45 +++++ .../discord/DiscordOnboardingProvider.java | 89 ++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../onboarding/steps/discord.html.peb | 49 ++++++ .../channels/discord/DiscordChannelTest.java | 154 ++++++++++++++++++ .../DiscordOnboardingProviderTest.java | 119 ++++++++++++++ settings.gradle | 2 +- 11 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 plugins/discord/build.gradle create mode 100644 plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java create mode 100644 plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannelAutoConfiguration.java create mode 100644 plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java create mode 100644 plugins/discord/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 plugins/discord/src/main/resources/templates/onboarding/steps/discord.html.peb create mode 100644 plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordChannelTest.java create mode 100644 plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordOnboardingProviderTest.java diff --git a/app/build.gradle b/app/build.gradle index d440d87..fa2c39d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,7 @@ plugins { dependencies { implementation project(':base') + implementation project(':plugins:discord') implementation project(':plugins:telegram') implementation project(':plugins:playwright') implementation project(':plugins:brave') @@ -33,4 +34,4 @@ dependencies { bootRun { workingDir = rootProject.projectDir -} \ No newline at end of file +} diff --git a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java index 54e9a9c..c6416c8 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java @@ -82,6 +82,7 @@ public static String conversationSelector(List ids, String selectedId) { private static String labelFor(String conversationId) { if ("web".equals(conversationId)) return "Web Chat"; + if (conversationId.startsWith("discord-")) return "Discord (" + conversationId.substring("discord-".length()) + ")"; if (conversationId.startsWith("telegram-")) return "Telegram (" + conversationId.substring("telegram-".length()) + ")"; return conversationId; } diff --git a/plugins/discord/build.gradle b/plugins/discord/build.gradle new file mode 100644 index 0000000..67184a2 --- /dev/null +++ b/plugins/discord/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':base') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'net.dv8tion:JDA:6.1.1' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java new file mode 100644 index 0000000..5517392 --- /dev/null +++ b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java @@ -0,0 +1,121 @@ +package ai.javaclaw.channels.discord; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.Channel; +import ai.javaclaw.channels.ChannelMessageReceivedEvent; +import ai.javaclaw.channels.ChannelRegistry; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.util.Optional.ofNullable; +import static java.util.regex.Pattern.quote; + +public class DiscordChannel extends ListenerAdapter implements Channel { + + private static final Logger log = LoggerFactory.getLogger(DiscordChannel.class); + + private final String allowedUserId; + private final Agent agent; + private final ChannelRegistry channelRegistry; + private volatile MessageChannel lastChannel; + + public DiscordChannel(String allowedUserId, Agent agent, ChannelRegistry channelRegistry) { + this.allowedUserId = normalizeUserId(allowedUserId); + this.agent = agent; + this.channelRegistry = channelRegistry; + channelRegistry.registerChannel(this); + log.info("Started Discord integration"); + } + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + if (!shouldHandle(event)) { + return; + } + + String userId = normalizeUserId(event.getAuthor().getId()); + MessageChannel channel = event.getChannel(); + String content = normalizeText(event.getJDA(), event.getMessage(), event.isFromGuild()); + + if (content == null) { + return; + } + + if (!isAllowedUser(userId)) { + log.warn("Ignoring Discord message from unauthorized user '{}'", userId); + reply(channel, "I'm sorry, I don't accept instructions from you."); + return; + } + + lastChannel = channel; + channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), content)); + String response = agent.respondTo(getConversationId(channel.getId()), content); + reply(channel, response); + } + + @Override + public void sendMessage(String message) { + MessageChannel channel = lastChannel; + if (channel == null) { + log.error("No known Discord channel, cannot send message '{}'", message); + return; + } + reply(channel, message); + } + + private boolean shouldHandle(MessageReceivedEvent event) { + User author = event.getAuthor(); + if (author.isBot() || event.isWebhookMessage()) { + return false; + } + return event.isFromType(ChannelType.PRIVATE) + || event.getMessage().getMentions().isMentioned(event.getJDA().getSelfUser()); + } + + private boolean isAllowedUser(String userId) { + return userId != null && userId.equalsIgnoreCase(allowedUserId); + } + + private static void reply(MessageChannel channel, String text) { + channel.sendMessage(text).queue(); + } + + private static String normalizeText(JDA jda, Message message, boolean guildMessage) { + String content = message.getContentRaw(); + if (content == null) { + return null; + } + if (guildMessage) { + String mention = ofNullable(jda.getSelfUser()).map(User::getAsMention).orElse(""); + content = content.replaceFirst("^\\s*" + quote(mention) + "\\s*", ""); + } + content = content.trim(); + return content.isBlank() ? null : content; + } + + private static String getConversationId(String channelId) { + return "discord-" + channelId; + } + + private static String normalizeUserId(String userId) { + if (userId == null) { + return null; + } + String normalized = userId.trim(); + if (normalized.startsWith("<@") && normalized.endsWith(">")) { + normalized = normalized.substring(2, normalized.length() - 1); + if (normalized.startsWith("!")) { + normalized = normalized.substring(1); + } + } + return normalized.isBlank() ? null : normalized; + } +} diff --git a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannelAutoConfiguration.java b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannelAutoConfiguration.java new file mode 100644 index 0000000..a6b272a --- /dev/null +++ b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannelAutoConfiguration.java @@ -0,0 +1,45 @@ +package ai.javaclaw.channels.discord; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.MemberCachePolicy; +import net.dv8tion.jda.api.utils.cache.CacheFlag; +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.context.annotation.Bean; + +import java.util.EnumSet; + +@AutoConfiguration +public class DiscordChannelAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "agent.channels.discord", name = {"token", "allowed-user"}) + public DiscordChannel discordChannel(@Value("${agent.channels.discord.allowed-user}") String allowedUser, + Agent agent, + ChannelRegistry channelRegistry) { + return new DiscordChannel(allowedUser, agent, channelRegistry); + } + + @Bean(destroyMethod = "shutdown") + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "agent.channels.discord", name = {"token", "allowed-user"}) + public JDA discordJda(@Value("${agent.channels.discord.token}") String token, + DiscordChannel discordChannel) throws InterruptedException { + return JDABuilder.createLight(token, + GatewayIntent.GUILD_MESSAGES, + GatewayIntent.DIRECT_MESSAGES, + GatewayIntent.MESSAGE_CONTENT) + .disableCache(EnumSet.allOf(CacheFlag.class)) + .setMemberCachePolicy(MemberCachePolicy.NONE) + .addEventListeners(discordChannel) + .build() + .awaitReady(); + } +} diff --git a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java new file mode 100644 index 0000000..d7bb32f --- /dev/null +++ b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java @@ -0,0 +1,89 @@ +package ai.javaclaw.channels.discord; + +import ai.javaclaw.configuration.ConfigurationManager; +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(53) +public class DiscordOnboardingProvider implements OnboardingProvider { + + static final String SESSION_TOKEN = "onboarding.discord.token"; + static final String SESSION_ALLOWED_USER = "onboarding.discord.allowed-user"; + + private static final String TOKEN_PROPERTY = "agent.channels.discord.token"; + private static final String ALLOWED_USER_PROPERTY = "agent.channels.discord.allowed-user"; + + private final Environment env; + + public DiscordOnboardingProvider(Environment env) { + this.env = env; + } + + @Override + public boolean isOptional() {return true;} + + @Override + public String getStepId() {return "discord";} + + @Override + public String getStepTitle() {return "Discord";} + + @Override + public String getTemplatePath() {return "onboarding/steps/discord";} + + @Override + public void prepareModel(Map session, Map model) { + model.put("discordToken", session.getOrDefault(SESSION_TOKEN, env.getProperty(TOKEN_PROPERTY, ""))); + model.put("discordAllowedUser", session.getOrDefault(SESSION_ALLOWED_USER, env.getProperty(ALLOWED_USER_PROPERTY, ""))); + } + + @Override + public String processStep(Map formParams, Map session) { + String token = formParams.getOrDefault("discordToken", "").trim(); + String allowedUser = normalizeUserId(formParams.get("discordAllowedUser")); + + if (token.isBlank()) { + return "Enter the Discord bot token to continue."; + } + if (allowedUser == null) { + return "Enter the Discord user ID that should be allowed to use the bot."; + } + + session.put(SESSION_TOKEN, token); + session.put(SESSION_ALLOWED_USER, allowedUser); + return null; + } + + @Override + public void saveConfiguration(Map session, ConfigurationManager configurationManager) throws IOException { + String token = (String) session.get(SESSION_TOKEN); + String allowedUser = (String) session.get(SESSION_ALLOWED_USER); + + if (token != null && allowedUser != null) { + configurationManager.updateProperties(Map.of( + TOKEN_PROPERTY, token, + ALLOWED_USER_PROPERTY, allowedUser + )); + } + } + + private static String normalizeUserId(String userId) { + if (userId == null) { + return null; + } + String normalized = userId.trim(); + if (normalized.startsWith("<@") && normalized.endsWith(">")) { + normalized = normalized.substring(2, normalized.length() - 1); + if (normalized.startsWith("!")) { + normalized = normalized.substring(1); + } + } + return normalized.isBlank() ? null : normalized; + } +} diff --git a/plugins/discord/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plugins/discord/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..3f428e8 --- /dev/null +++ b/plugins/discord/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +ai.javaclaw.channels.discord.DiscordChannelAutoConfiguration diff --git a/plugins/discord/src/main/resources/templates/onboarding/steps/discord.html.peb b/plugins/discord/src/main/resources/templates/onboarding/steps/discord.html.peb new file mode 100644 index 0000000..7716f51 --- /dev/null +++ b/plugins/discord/src/main/resources/templates/onboarding/steps/discord.html.peb @@ -0,0 +1,49 @@ +
+

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

+

Connect Discord.

+

+ Configure a Discord bot token and the single Discord user ID that is allowed to control the agent. + Direct messages are accepted, and server messages are processed when the bot is mentioned. +

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

Use the Discord numeric user ID for the only user who should be able to control the bot.

+
+ +
+

Discord app requirements

+
    +
  • Invite the bot to your server or message it directly.
  • +
  • Enable the Message Content Intent in the Discord Developer Portal.
  • +
+

Stored properties

+

agent.channels.discord.token

+

agent.channels.discord.allowed-user

+
+ +
+ Back + + {% if isOptional %}Skip{% endif %} + Saving... +
+
+
diff --git a/plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordChannelTest.java b/plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordChannelTest.java new file mode 100644 index 0000000..756aabf --- /dev/null +++ b/plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordChannelTest.java @@ -0,0 +1,154 @@ +package ai.javaclaw.channels.discord; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Mentions; +import net.dv8tion.jda.api.entities.SelfUser; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class DiscordChannelTest { + + private final Agent agent = mock(Agent.class); + + @Test + void ignoresBotMessages() { + DiscordChannel channel = channel("123"); + + channel.onMessageReceived(event(true, false, false, "123", "C1", "hello")); + + verifyNoInteractions(agent); + } + + @Test + void ignoresGuildMessagesWithoutMention() { + DiscordChannel channel = channel("123"); + + channel.onMessageReceived(event(false, false, false, "123", "C1", "hello")); + + verifyNoInteractions(agent); + } + + @Test + void rejectsUnauthorizedUsers() { + DiscordChannel channel = channel("123"); + MessageChannelUnion channelMock = messageChannel("C1"); + + channel.onMessageReceived(event(false, false, true, "999", "C1", "<@111> hello", channelMock)); + + verify(agent, never()).respondTo(anyString(), anyString()); + verify(channelMock).sendMessage("I'm sorry, I don't accept instructions from you."); + } + + @Test + void processesDirectMessages() { + DiscordChannel channel = channel("123"); + MessageChannelUnion channelMock = messageChannel("D1"); + when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); + + channel.onMessageReceived(event(false, true, false, "123", "D1", "hello", channelMock)); + + verify(agent).respondTo(eq("discord-D1"), eq("hello")); + verify(channelMock).sendMessage("hi"); + } + + @Test + void stripsMentionInGuildMessages() { + DiscordChannel channel = channel("123"); + MessageChannelUnion channelMock = messageChannel("C1"); + when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); + + channel.onMessageReceived(event(false, false, true, "123", "C1", "<@111> hello there", channelMock)); + + verify(agent).respondTo(eq("discord-C1"), eq("hello there")); + } + + @Test + void sendMessageUsesLastKnownChannel() { + DiscordChannel channel = channel("123"); + MessageChannelUnion channelMock = messageChannel("D1"); + when(agent.respondTo(anyString(), anyString())).thenReturn("ok"); + channel.onMessageReceived(event(false, true, false, "123", "D1", "hello", channelMock)); + + channel.sendMessage("background update"); + + verify(channelMock).sendMessage("background update"); + } + + @Test + void sendMessageDoesNothingWithoutKnownChannel() { + DiscordChannel channel = channel("123"); + + channel.sendMessage("hello"); + + verifyNoInteractions(agent); + } + + private DiscordChannel channel(String allowedUser) { + return new DiscordChannel(allowedUser, agent, new ChannelRegistry()); + } + + private MessageReceivedEvent event(boolean authorIsBot, + boolean directMessage, + boolean mentioned, + String authorId, + String channelId, + String content) { + return event(authorIsBot, directMessage, mentioned, authorId, channelId, content, messageChannel(channelId)); + } + + private MessageReceivedEvent event(boolean authorIsBot, + boolean directMessage, + boolean mentioned, + String authorId, + String channelId, + String content, + MessageChannelUnion channelUnion) { + MessageReceivedEvent event = mock(MessageReceivedEvent.class); + User author = mock(User.class); + Message message = mock(Message.class); + Mentions mentions = mock(Mentions.class); + JDA jda = mock(JDA.class); + SelfUser selfUser = mock(SelfUser.class); + + when(event.getAuthor()).thenReturn(author); + when(author.isBot()).thenReturn(authorIsBot); + when(author.getId()).thenReturn(authorId); + when(event.isWebhookMessage()).thenReturn(false); + when(event.isFromType(ChannelType.PRIVATE)).thenReturn(directMessage); + when(event.isFromGuild()).thenReturn(!directMessage); + when(event.getMessage()).thenReturn(message); + when(message.getContentRaw()).thenReturn(content); + when(message.getMentions()).thenReturn(mentions); + when(mentions.isMentioned(selfUser)).thenReturn(mentioned); + when(event.getJDA()).thenReturn(jda); + when(jda.getSelfUser()).thenReturn(selfUser); + when(selfUser.getAsMention()).thenReturn("<@111>"); + when(event.getChannel()).thenReturn(channelUnion); + when(channelUnion.getId()).thenReturn(channelId); + return event; + } + + private MessageChannelUnion messageChannel(String channelId) { + MessageChannelUnion channel = mock(MessageChannelUnion.class); + MessageCreateAction action = mock(MessageCreateAction.class); + when(channel.getId()).thenReturn(channelId); + when(channel.sendMessage(anyString())).thenReturn(action); + return channel; + } +} diff --git a/plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordOnboardingProviderTest.java b/plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordOnboardingProviderTest.java new file mode 100644 index 0000000..c754acd --- /dev/null +++ b/plugins/discord/src/test/java/ai/javaclaw/channels/discord/DiscordOnboardingProviderTest.java @@ -0,0 +1,119 @@ +package ai.javaclaw.channels.discord; + +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.io.IOException; +import java.util.HashMap; +import java.util.Map; + +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 DiscordOnboardingProviderTest { + + @Mock + Environment environment; + + @Mock + ConfigurationManager configurationManager; + + @Test + void stepMetadataIsCorrect() { + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + + assertThat(provider.getStepId()).isEqualTo("discord"); + assertThat(provider.getStepTitle()).isEqualTo("Discord"); + assertThat(provider.getTemplatePath()).isEqualTo("onboarding/steps/discord"); + assertThat(provider.isOptional()).isTrue(); + } + + @Test + void processStepStoresNormalizedSessionValues() { + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + Map session = new HashMap<>(); + + String result = provider.processStep(Map.of( + "discordToken", " bot-token ", + "discordAllowedUser", "<@!123456789>" + ), session); + + assertThat(result).isNull(); + assertThat(session).containsEntry(DiscordOnboardingProvider.SESSION_TOKEN, "bot-token"); + assertThat(session).containsEntry(DiscordOnboardingProvider.SESSION_ALLOWED_USER, "123456789"); + } + + @Test + void processStepReturnsErrorWhenRequiredValueIsMissing() { + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + + String result = provider.processStep(Map.of( + "discordToken", "", + "discordAllowedUser", "123456789" + ), new HashMap<>()); + + assertThat(result).isEqualTo("Enter the Discord bot token to continue."); + } + + @Test + void prepareModelUsesSessionValuesWhenPresent() { + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + Map session = Map.of( + DiscordOnboardingProvider.SESSION_TOKEN, "session-token", + DiscordOnboardingProvider.SESSION_ALLOWED_USER, "123456789" + ); + Map model = new HashMap<>(); + + provider.prepareModel(session, model); + + assertThat(model).containsEntry("discordToken", "session-token"); + assertThat(model).containsEntry("discordAllowedUser", "123456789"); + } + + @Test + void prepareModelFallsBackToEnvironmentValues() { + when(environment.getProperty("agent.channels.discord.token", "")).thenReturn("env-token"); + when(environment.getProperty("agent.channels.discord.allowed-user", "")).thenReturn("env-user"); + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + Map model = new HashMap<>(); + + provider.prepareModel(Map.of(), model); + + assertThat(model).containsEntry("discordToken", "env-token"); + assertThat(model).containsEntry("discordAllowedUser", "env-user"); + } + + @Test + void saveConfigurationWritesAllProperties() throws IOException { + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + Map session = Map.of( + DiscordOnboardingProvider.SESSION_TOKEN, "token", + DiscordOnboardingProvider.SESSION_ALLOWED_USER, "123456789" + ); + + provider.saveConfiguration(session, configurationManager); + + verify(configurationManager).updateProperties(Map.of( + "agent.channels.discord.token", "token", + "agent.channels.discord.allowed-user", "123456789" + )); + } + + @Test + void saveConfigurationDoesNothingWhenSessionIsIncomplete() throws IOException { + DiscordOnboardingProvider provider = new DiscordOnboardingProvider(environment); + + provider.saveConfiguration(Map.of( + DiscordOnboardingProvider.SESSION_TOKEN, "token" + ), configurationManager); + + verifyNoInteractions(configurationManager); + } +} diff --git a/settings.gradle b/settings.gradle index 4e8fd8a..c1cc23f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,8 +2,8 @@ rootProject.name = 'JavaClaw' include 'base' include 'plugins' +include 'plugins:discord' include 'plugins:telegram' include 'plugins:playwright' include 'plugins:brave' include 'app' - From 854343b3067510c7d043defb7ca0d5c11a754cbd Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Thu, 2 Apr 2026 01:07:29 +0300 Subject: [PATCH 2/3] Update Agent.md --- AGENTS.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b4152a0..56f8e18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ This project represents a Java version of OpenClaw. OpenClaw is an open-source, - **Agent Framework:** Spring AI Agent Utils (Anthropic agent framework) - **Database:** H2 (embedded) - **Templating:** Pebble 4.1.1 +- **Discord:** JDA 6.1.1 (Gateway / WebSocket) - **Telegram:** Telegrambots 9.4.0 (long-polling) --- @@ -28,11 +29,14 @@ This project represents a Java version of OpenClaw. OpenClaw is an open-source, root ├── base/ ← Core: agent, tasks, tools, channels, config ├── app/ ← Spring Boot entry point, onboarding UI, web routes, chat channel -└── channels/ +└── plugins/ + ├── brave/ ← Brave web search integration + ├── discord/ ← Discord Gateway channel + ├── playwright/ ← Browser automation tools └── telegram/ ← Telegram long-poll channel ``` -`app` depends on `base` + `channels:telegram`. `ChatChannel` lives inside `app/`. +`app` depends on `base` plus plugin modules. `ChatChannel` lives inside `app/`. --- @@ -112,6 +116,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. @@ -136,7 +141,7 @@ Incoming message → ChannelMessageReceivedEvent (channel name, message text) - **Web:** Search (Brave API) and smart web fetching. - **MCP:** Support for Model Context Protocol tools (via `SyncMcpToolCallbackProvider`). - **Skills:** Custom modular skills loaded from `workspace/skills/` at runtime. -- **Channels:** Telegram (implemented), Chat (implemented). +- **Channels:** Chat, Telegram, and Discord are implemented. --- @@ -146,16 +151,16 @@ Incoming message → ChannelMessageReceivedEvent (channel name, message text) - **htmx v2.0.8 (https://htmx.org/docs/):** htmx is a strong fit for this app because it keeps the interaction model server-driven: the server returns HTML fragments, not JSON, and htmx swaps them into the DOM. We are using `hx-boost` which "boosts" normal anchors and form tags to use AJAX instead (preventing reloading of css and js). This has the nice fallback that, if the user does not have javascript enabled, the site will continue to work. Both Bulma and htmx are already included in `base.html.peb`. ### Onboarding UI -Entry point: `GET /index` → `IndexController.java` (redirects to `/onboarding/`) → `OnboardingController.java`. 7-step session-based flow: +Entry point: `GET /index` → `IndexController.java` (redirects to `/onboarding/`) → `OnboardingController.java`. Core onboarding flow: 1. Welcome 2. Provider selection (Ollama / OpenAI / Anthropic) 3. Credentials (API key + model) 4. `AGENT.md` editor (system prompt customization) 5. MCP servers configuration (optional) -6. Telegram bot token + allowed username (optional) +6. Optional channel/tool plugin steps (for example Telegram and Discord) 7. Complete summary -Templates: `templates/onboarding/` (index + 7 step partials). Saves config via `ConfigurationManager.updateProperty()`. +Templates live under `templates/onboarding/`, with plugin steps contributed from their own modules. Saves config via `ConfigurationManager.updateProperty()`. --- @@ -165,7 +170,7 @@ Templates: `templates/onboarding/` (index + 7 step partials). Saves config via ` |---|---| | **Event-Driven** | `ChannelMessageReceivedEvent`, `ConfigurationChangedEvent`, JobRunr background dispatch | | **Template Method** | `AbstractTask` subclassed by `Task`, `RecurringTask` | -| **Strategy** | Multiple `Channel` implementations (Telegram, Chat) | +| **Strategy** | Multiple `Channel` implementations (Discord, Telegram, Chat) | | **Record Types** | `TaskResult`, `CheckListItem` — structured LLM response types | | **Markdown as State** | Tasks stored as `.md` files — queryable, diffable, human-readable | | **Single Agent Instance** | `DefaultAgent` wraps `ChatClient`; all prompts routed through it | @@ -176,5 +181,6 @@ Templates: `templates/onboarding/` (index + 7 step partials). Saves config via ` ## Tests - `base/src/test/` — `TaskManagerTest`: task creation, file naming, JobRunr integration (in-memory storage + background server). -- `channels/telegram/src/test/` — `TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked). -- `app/src/test/` — `OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers. \ No newline at end of file +- `plugins/discord/src/test/` — `DiscordChannelTest`, `DiscordOnboardingProviderTest`: authorized Discord flow + onboarding config handling. +- `plugins/telegram/src/test/` — `TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked). +- `app/src/test/` — `OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers. From 4849562a91afd1a4b7568204c7ace2d0f64b0938 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Thu, 2 Apr 2026 01:13:14 +0300 Subject: [PATCH 3/3] Update README.md --- README.md | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0b40da8..b93e779 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ JavaClaw is a Java-based personal AI assistant that runs on your own devices. It ## Features -- **Multi-Channel Support** — Telegram (built-in), Chat UI (WebSocket), and an extensible channel architecture for adding more platforms +- **Multi-Channel Support** — Chat UI (WebSocket), Telegram, Discord, and an extensible plugin-based channel architecture - **Task Management** — Create, schedule (one-off, delayed, or recurring via cron), and track tasks as human-readable Markdown files - **Extensible Skills** — Drop a `SKILL.md` into `workspace/skills/` and the agent picks it up at runtime - **LLM Provider Choice** — Plug in OpenAI, Anthropic, or Ollama (local); switchable during onboarding @@ -29,6 +29,7 @@ JavaClaw is a Java-based personal AI assistant that runs on your own devices. It | Database | H2 (embedded, file-backed) | | Templating | Pebble 4.1.1 | | Frontend | htmx 2.0.8 + Bulma 1.0.4 | +| Discord | JDA 6.1.1 | | Telegram | Telegrambots 9.4.0 | ## Project Structure @@ -38,6 +39,9 @@ JavaClaw/ ├── base/ # Core: agent, tasks, tools, channels, config ├── app/ # Spring Boot entry point, onboarding UI, web routes, chat channel └── plugins/ + ├── brave/ # Brave web search integration + ├── discord/ # Discord Gateway channel plugin + ├── playwright/ # Browser automation tools └── telegram/ # Telegram long-poll channel plugin ``` @@ -63,14 +67,14 @@ docker run -it -p 8080:8080 -p:8081:8081 -v "$(pwd)/workspace:/workspace" jobrun Then open [http://localhost:8080/onboarding](http://localhost:8080/onboarding) to complete the guided onboarding. -### Onboarding (7 Steps) +### Onboarding 1. **Welcome** — Introduction screen 2. **Provider** — Choose Ollama, OpenAI, or Anthropic 3. **Credentials** — Enter your API key and model name 4. **Agent Prompt** — Customize `workspace/AGENT.md` with your personal info (name, email, role, etc.) 5. **MCP Servers** — Optionally configure Model Context Protocol servers -6. **Telegram** — Optionally connect a Telegram bot (bot token + allowed username) +6. **Channel/Tool Plugins** — Optional steps such as Telegram, Discord, and other plugin-provided setup 7. **Complete** — Review and save your configuration Configuration is persisted to `app/src/main/resources/application.yaml` and takes effect immediately. @@ -103,9 +107,23 @@ Available at [http://localhost:8080/chat](http://localhost:8080/chat). Uses WebS Configure during onboarding or by setting: ```yaml -telegram: - bot-token: - allowed-username: +agent: + channels: + telegram: + token: + username: +``` + +### Discord + +Configure during onboarding or by setting: + +```yaml +agent: + channels: + discord: + token: + allowed-user: ``` ## Skills @@ -134,7 +152,7 @@ Key properties in `application.yaml`: ./gradlew test ``` -Tests cover task management (file naming, JobRunr integration), Telegram channel authorization, and the full Spring context. +Tests cover task management, Telegram and Discord channel authorization/flow, onboarding steps, and the full Spring context. ## More info?