Skip to content
Merged
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
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand All @@ -36,6 +37,7 @@ root
└── plugins/
├── telegram/ ← Telegram long-poll channel
├── brave/ ← Brave web search tool
├── discord/ ← Discord Gateway channel
└── playwright/ ← Playwright browser tool
```

Expand Down Expand Up @@ -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.

Expand All @@ -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.

---

Expand All @@ -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()`.

---

Expand All @@ -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 |
Expand All @@ -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.
- `app/src/test/` — `OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers.
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
```

Expand All @@ -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.
Expand Down Expand Up @@ -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: <your-bot-token>
allowed-username: <your-telegram-username>
agent:
channels:
telegram:
token: <your-bot-token>
username: <your-telegram-username>
```

### Discord

Configure during onboarding or by setting:

```yaml
agent:
channels:
discord:
token: <your-discord-bot-token>
allowed-user: <your-discord-user-id>
```

## Skills
Expand Down Expand Up @@ -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?

Expand Down
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -57,4 +58,4 @@ jib {
]
}
}
}
}
1 change: 1 addition & 0 deletions app/src/main/java/ai/javaclaw/chat/ChatHtml.java
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public static String conversationSelector(List<String> 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;
}
Expand Down
12 changes: 12 additions & 0 deletions plugins/discord/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading