diff --git a/AGENTS.md b/AGENTS.md index dbfe843..9f936f6 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) --- @@ -36,6 +37,7 @@ root └── plugins/ ├── telegram/ ← Telegram long-poll channel ├── brave/ ← Brave web search tool + ├── discord/ ← Discord Gateway channel └── playwright/ ← Playwright browser tool ``` @@ -127,6 +129,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. @@ -151,7 +154,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. --- @@ -170,7 +173,7 @@ Entry point: `GET /index` → `IndexController.java` (redirects to `/onboarding/ 6. Plugin-contributed steps (e.g. Telegram bot token, Brave API key, Playwright) — injected by each plugin's `OnboardingProvider` 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()`. --- @@ -180,7 +183,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 | @@ -191,6 +194,7 @@ 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). +- `plugins/discord/src/test/` — `DiscordChannelTest`, `DiscordOnboardingProviderTest`: authorized Discord flow + onboarding config handling. - `plugins/telegram/src/test/` — `TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked). - `providers/anthropic/src/test/` — `AnthropicClaudeCodeBackendTest`: Claude Code OAuth token extraction. -- `app/src/test/` — `OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers. \ No newline at end of file +- `app/src/test/` — `OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers. 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? diff --git a/app/build.gradle b/app/build.gradle index 685a01b..8907aab 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,6 +11,7 @@ dependencies { implementation project(':providers:ollama') implementation project(':providers:openai') + implementation project(':plugins:discord') implementation project(':plugins:telegram') implementation project(':plugins:playwright') implementation project(':plugins:brave') @@ -57,4 +58,4 @@ jib { ] } } -} \ 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 6bdc834..8a54b0c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,7 @@ rootProject.name = 'JavaClaw' include 'base' include 'plugins' +include 'plugins:discord' include 'plugins:telegram' include 'plugins:playwright' include 'plugins:brave' @@ -11,4 +12,3 @@ include 'providers:google' include 'providers:ollama' include 'providers:openai' include 'app' -