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
49 changes: 48 additions & 1 deletion 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** — Chat UI (WebSocket), Telegram, Discord, and an extensible plugin-based channel architecture
- **Multi-Channel Support** — Chat UI (WebSocket), Telegram, Discord, WhatsApp, Microsoft Teams, 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** — Load skills from `workspace/skills/` and from `skills jar` packages on the classpath
- **LLM Provider Choice** — Plug in OpenAI, Anthropic, or Ollama (local); switchable during onboarding
Expand Down Expand Up @@ -42,6 +42,7 @@ JavaClaw/
├── brave/ # Brave web search integration
├── discord/ # Discord Gateway channel plugin
├── playwright/ # Browser automation tools
├── teams/ # Microsoft Teams webhook channel plugin
└── telegram/ # Telegram long-poll channel plugin
```

Expand Down Expand Up @@ -179,6 +180,52 @@ from your own number. `wacli` itself is taken from your `PATH`.

> **Disclaimer:** wacli uses the unofficial WhatsApp Web protocol — use at your own risk.

### Microsoft Teams

Teams delivers messages to a bot through [Azure Bot Service](https://learn.microsoft.com/azure/bot-service/):
Microsoft calls a public HTTPS webhook on JavaClaw, and JavaClaw answers through the Bot Framework
Connector API.

**1. Expose the webhook**

The webhook lives at `/api/teams/webhook`. Azure must be able to reach it over public HTTPS, so for
a self-hosted setup put a tunnel in front of JavaClaw, for example:

```bash
ngrok http 8080 # gives you https://<random>.ngrok-free.app
```

**2. Create the bot**

In the Azure portal, create an **Azure Bot** resource, set its messaging endpoint to
`https://<your-host>/api/teams/webhook`, and enable the **Microsoft Teams** channel. Note the bot's
App ID, create a client secret for it, and note your Tenant ID.

**3. Configure**

Configure during onboarding or by setting:

```yaml
agent:
channels:
teams:
enabled: true
app-id: <bot-app-id>
app-secret: <bot-client-secret>
tenant-id: <azure-tenant-id>
allowed-user-id: <your-entra-object-id>
```

`allowed-user-id` is the only Teams user the assistant responds to: either your Microsoft Entra
object ID (Azure portal → Microsoft Entra ID → Users) or your Teams user ID (`29:...`). It is
required, because the agent can run shell commands; without it every message is ignored. Every
webhook call must carry a valid Bot Framework JWT (signature, issuer, audience, expiry and
`serviceUrl` are checked), so requests that do not come from Microsoft are rejected with `401`.

The assistant only responds in personal (1:1) chats with the bot. Messages in team channels or group
chats are ignored, even from the allowed user, because replies can contain shell output or file
contents. Messages the agent sends on its own, e.g. from scheduled tasks, also only go to the 1:1 chat.


## Skills

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {
implementation project(':plugins:playwright')
implementation project(':plugins:brave')
implementation project(':plugins:whatsapp')
implementation project(':plugins:teams')

implementation 'org.springframework.ai:spring-ai-client-chat'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ telegrambots-client = { module = "org.telegram:telegrambots-client", version.ref
telegrambots-springboot-longpolling-starter = { module = "org.telegram:telegrambots-springboot-longpolling-starter", version.ref = "telegrambots" }
commonmark-java = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
nimbus-jose-jwt = { module = "com.nimbusds:nimbus-jose-jwt", version = "10.9.1" }

[bundles]

Expand Down
13 changes: 13 additions & 0 deletions plugins/teams/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
id 'java-library'
}

dependencies {
implementation project(':base')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation(libs.nimbus.jose.jwt)

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-json'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package ai.javaclaw.channels.teams;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.MalformedURLException;
import java.net.URI;
import java.time.Duration;
import java.util.Set;

/**
* Validates the Bot Framework Connector JWT sent on every incoming Teams webhook call
* (the {@code Authorization: Bearer <token>} header), per
* https://learn.microsoft.com/azure/bot-service/rest-api/bot-framework-rest-connector-authentication
* <p>
* A valid token must: (1) be signed with RS256 by a key currently published in Bot Framework's JWKS,
* (2) have issuer {@code https://api.botframework.com}, (3) have this bot's App ID as audience,
* (4) not be expired, allowing five minutes of clock skew as Microsoft recommends, and (5) carry a
* {@code serviceUrl} claim equal to the activity's {@code serviceUrl}. The last check matters because
* the bot later sends its own outbound token to that URL. The JWKS is fetched once and
* cached/refreshed automatically by the underlying {@link JWKSource}.
*/
class BotFrameworkJwtValidator {

private static final Logger LOGGER = LoggerFactory.getLogger(BotFrameworkJwtValidator.class);
static final String EXPECTED_ISSUER = "https://api.botframework.com";
static final String SERVICE_URL_CLAIM = "serviceUrl";
static final int MAX_CLOCK_SKEW_SECONDS = (int) Duration.ofMinutes(5).toSeconds();
private static final String JWKS_URL = "https://login.botframework.com/v1/.well-known/keys";

private final DefaultJWTProcessor<SecurityContext> jwtProcessor;

BotFrameworkJwtValidator(TeamsProperties properties) {
this(properties, remoteJwkSource());
}

/** Test seam: inject a local key set instead of hitting the real JWKS endpoint. */
BotFrameworkJwtValidator(TeamsProperties properties, JWKSource<SecurityContext> jwkSource) {
String appId = properties.getAppId();
// An empty accepted-audience set rejects every token, which is what we want without an App ID
Set<String> acceptedAudience = appId == null || appId.isBlank() ? Set.of() : Set.of(appId);

DefaultJWTClaimsVerifier<SecurityContext> claimsVerifier = new DefaultJWTClaimsVerifier<>(
acceptedAudience,
new JWTClaimsSet.Builder().issuer(EXPECTED_ISSUER).build(),
Set.of("exp", SERVICE_URL_CLAIM),
null);
claimsVerifier.setMaxClockSkew(MAX_CLOCK_SKEW_SECONDS);

this.jwtProcessor = new DefaultJWTProcessor<>();
this.jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));
this.jwtProcessor.setJWTClaimsSetVerifier(claimsVerifier);
}

private static JWKSource<SecurityContext> remoteJwkSource() {
try {
return JWKSourceBuilder
.create(URI.create(JWKS_URL).toURL())
.cache(Duration.ofMinutes(30).toMillis(), Duration.ofMinutes(1).toMillis())
.build();
} catch (MalformedURLException e) {
throw new IllegalStateException("Invalid Bot Framework JWKS URL: " + JWKS_URL, e);
}
}

/**
* @param bearerToken the token from the {@code Authorization} header, without the {@code Bearer } prefix
* @param serviceUrl the {@code serviceUrl} of the incoming activity
* @return true if {@code bearerToken} is a currently valid Bot Framework Connector token for this bot
* that was issued for {@code serviceUrl}
*/
boolean isValid(String bearerToken, String serviceUrl) {
try {
Comment thread
AlirezaValikhani marked this conversation as resolved.
Resolved
// Throws if the signature, algorithm, issuer, audience or expiry is wrong
JWTClaimsSet claims = jwtProcessor.process(SignedJWT.parse(bearerToken), null);

String tokenServiceUrl = claims.getStringClaim(SERVICE_URL_CLAIM);
if (serviceUrl == null || !sameUrl(tokenServiceUrl, serviceUrl)) {
LOGGER.warn("Rejected Teams webhook JWT issued for serviceUrl '{}' on an activity for '{}'",
tokenServiceUrl, serviceUrl);
return false;
}
return true;
} catch (Exception e) {
LOGGER.warn("Rejected Teams webhook JWT: {}", e.getMessage());
return false;
}
}

/** Compares two URLs, ignoring a trailing slash and letter case. */
private static boolean sameUrl(String a, String b) {
return a != null && stripTrailingSlash(a).equalsIgnoreCase(stripTrailingSlash(b));
}

private static String stripTrailingSlash(String url) {
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package ai.javaclaw.channels.teams;

import tools.jackson.databind.json.JsonMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;

/**
* Fetches and caches an OAuth2 client-credentials token for the Bot Framework
* Connector API, using the Azure AD v2 endpoint (login.microsoftonline.com).
*/
class BotFrameworkTokenProvider {

private static final Logger LOGGER = LoggerFactory.getLogger(BotFrameworkTokenProvider.class);
private static final String SCOPE = "https://api.botframework.com/.default";
private static final JsonMapper JSON = JsonMapper.builder().build();

private final TeamsProperties properties;
private final HttpClient httpClient;
private final AtomicReference<CachedToken> cached = new AtomicReference<>();

BotFrameworkTokenProvider(TeamsProperties properties, HttpClient httpClient) {
this.properties = properties;
this.httpClient = httpClient;
}

/** @return a valid bearer token, refreshing it if expired or missing */
synchronized String getToken() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synchronized together with AtomicReference (line 26) is redundant. Since the method is already synchronized, a plain field is enough.

CachedToken token = cached.get();
if (token != null && Instant.now().isBefore(token.expiresAt())) {
return token.value();
}
CachedToken fresh = fetchToken();
cached.set(fresh);
return fresh.value();
}

private CachedToken fetchToken() {
String tenantId = properties.getTenantId();
if (tenantId == null || tenantId.isBlank()) {
throw new IllegalStateException("No agent.channels.teams.tenant-id configured, cannot obtain a Bot Framework access token");
}
// URLEncoder encodes a space as '+', which is only valid in a form body, not in a path segment
String tokenUrl = "https://login.microsoftonline.com/" + encode(tenantId.trim()).replace("+", "%20")
+ "/oauth2/v2.0/token";
// Client secrets may contain '+', '&' or '=', so every value must be form-encoded
String body = "grant_type=client_credentials"
+ "&client_id=" + encode(properties.getAppId())
+ "&client_secret=" + encode(properties.getAppSecret())
+ "&scope=" + encode(SCOPE);

HttpRequest request = HttpRequest.newBuilder(URI.create(tokenUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
// The response body is left out on purpose: it may echo request details
throw new IllegalStateException(
"Bot Framework token request failed with status " + response.statusCode());
}
TokenResponse parsed = JSON.readValue(response.body(), TokenResponse.class);
return new CachedToken(parsed.accessToken(),
Instant.now().plusSeconds(Math.max(0, parsed.expiresIn() - 60)));
} catch (Exception e) {
LOGGER.error("Failed to fetch Bot Framework access token", e);
throw new IllegalStateException("Could not obtain Bot Framework access token", e);
}
}

private static String encode(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}

private record CachedToken(String value, Instant expiresAt) {
}

private record TokenResponse(
@com.fasterxml.jackson.annotation.JsonProperty("access_token") String accessToken,
@com.fasterxml.jackson.annotation.JsonProperty("expires_in") int expiresIn) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ai.javaclaw.channels.teams;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

@JsonIgnoreProperties(ignoreUnknown = true)
public record TeamsActivityPayload(
@JsonProperty("type") String type,
@JsonProperty("id") String id,
@JsonProperty("serviceUrl") String serviceUrl,
@JsonProperty("channelId") String channelId,
@JsonProperty("text") String text,
@JsonProperty("from") From from,
@JsonProperty("conversation") Conversation conversation) {

@JsonIgnoreProperties(ignoreUnknown = true)
public record From(@JsonProperty("id") String id, @JsonProperty("name") String name,
@JsonProperty("aadObjectId") String aadObjectId) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
/** {@code conversationType} is {@code "personal"}, {@code "groupChat"} or {@code "channel"}. */
public record Conversation(@JsonProperty("id") String id,
@JsonProperty("conversationType") String conversationType) {
}
}
Loading
Loading