Skip to content

Commit 208dd5a

Browse files
AlirezaValikhaniclaudearefbehboudi
authored
Feat/add teams channel plugin (#96)
* feat: Add teams plugin to the project * feat: add tests, JWT auth and docs for Teams channel plugin * fix: register Teams webhook controller and correct AutoConfiguration resource path * chore: Remove dummy Teams config from application.yaml Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bsjwgb1YHouoDYHkhrHFN4 * fix: Address review feedback on Teams channel plugin - Reply to the conversation each message came from instead of the last one - Reject all messages when no allowed user is configured, and ask for it in onboarding - Match the allowed user against the Entra object ID as well as the Teams user ID - Verify JWT claims with Nimbus (5 min clock skew) and check the serviceUrl claim - Ignore messages without a conversation id and prefix agent conversation ids with teams- - Stop logging message content, remove the duplicate webhook controller bean - Move the nimbus-jose-jwt version to the version catalog, add trailing newlines Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bsjwgb1YHouoDYHkhrHFN4 * docs: Document Microsoft Teams channel setup in README Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bsjwgb1YHouoDYHkhrHFN4 * fix: Encode token request, restrict Teams to personal chats, report agent errors --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Aref Behboudi <behboodiaref@gmail.com>
1 parent d0600b4 commit 208dd5a

23 files changed

Lines changed: 1648 additions & 1 deletion

‎README.md‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ JavaClaw is a Java-based personal AI assistant that runs on your own devices. It
66
77
## Features
88

9-
- **Multi-Channel Support** — Chat UI (WebSocket), Telegram, Discord, and an extensible plugin-based channel architecture
9+
- **Multi-Channel Support** — Chat UI (WebSocket), Telegram, Discord, WhatsApp, Microsoft Teams, and an extensible plugin-based channel architecture
1010
- **Task Management** — Create, schedule (one-off, delayed, or recurring via cron), and track tasks as human-readable Markdown files
1111
- **Extensible Skills** — Load skills from `workspace/skills/` and from `skills jar` packages on the classpath
1212
- **LLM Provider Choice** — Plug in OpenAI, Anthropic, your Claude Pro/Max subscription (via the official Claude Code CLI), or Ollama (local); switchable during onboarding
@@ -42,6 +42,7 @@ JavaClaw/
4242
├── brave/ # Brave web search integration
4343
├── discord/ # Discord Gateway channel plugin
4444
├── playwright/ # Browser automation tools
45+
├── teams/ # Microsoft Teams webhook channel plugin
4546
└── telegram/ # Telegram long-poll channel plugin
4647
```
4748

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

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

183+
### Microsoft Teams
184+
185+
Teams delivers messages to a bot through [Azure Bot Service](https://learn.microsoft.com/azure/bot-service/):
186+
Microsoft calls a public HTTPS webhook on JavaClaw, and JavaClaw answers through the Bot Framework
187+
Connector API.
188+
189+
**1. Expose the webhook**
190+
191+
The webhook lives at `/api/teams/webhook`. Azure must be able to reach it over public HTTPS, so for
192+
a self-hosted setup put a tunnel in front of JavaClaw, for example:
193+
194+
```bash
195+
ngrok http 8080 # gives you https://<random>.ngrok-free.app
196+
```
197+
198+
**2. Create the bot**
199+
200+
In the Azure portal, create an **Azure Bot** resource, set its messaging endpoint to
201+
`https://<your-host>/api/teams/webhook`, and enable the **Microsoft Teams** channel. Note the bot's
202+
App ID, create a client secret for it, and note your Tenant ID.
203+
204+
**3. Configure**
205+
206+
Configure during onboarding or by setting:
207+
208+
```yaml
209+
agent:
210+
channels:
211+
teams:
212+
enabled: true
213+
app-id: <bot-app-id>
214+
app-secret: <bot-client-secret>
215+
tenant-id: <azure-tenant-id>
216+
allowed-user-id: <your-entra-object-id>
217+
```
218+
219+
`allowed-user-id` is the only Teams user the assistant responds to: either your Microsoft Entra
220+
object ID (Azure portal → Microsoft Entra ID → Users) or your Teams user ID (`29:...`). It is
221+
required, because the agent can run shell commands; without it every message is ignored. Every
222+
webhook call must carry a valid Bot Framework JWT (signature, issuer, audience, expiry and
223+
`serviceUrl` are checked), so requests that do not come from Microsoft are rejected with `401`.
224+
225+
The assistant only responds in personal (1:1) chats with the bot. Messages in team channels or group
226+
chats are ignored, even from the allowed user, because replies can contain shell output or file
227+
contents. Messages the agent sends on its own, e.g. from scheduled tasks, also only go to the 1:1 chat.
228+
182229

183230
## Skills
184231

‎app/build.gradle‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies {
1717
implementation project(':plugins:playwright')
1818
implementation project(':plugins:brave')
1919
implementation project(':plugins:whatsapp')
20+
implementation project(':plugins:teams')
2021

2122
implementation project(':ui')
2223

‎gradle/libs.versions.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ telegrambots-client = { module = "org.telegram:telegrambots-client", version.ref
2727
telegrambots-springboot-longpolling-starter = { module = "org.telegram:telegrambots-springboot-longpolling-starter", version.ref = "telegrambots" }
2828
commonmark-java = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
2929
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
30+
nimbus-jose-jwt = { module = "com.nimbusds:nimbus-jose-jwt", version = "10.9.1" }
3031

3132
[bundles]
3233

‎plugins/teams/build.gradle‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
plugins {
2+
id 'java-library'
3+
}
4+
5+
dependencies {
6+
implementation project(':base')
7+
implementation 'org.springframework.boot:spring-boot-starter'
8+
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
9+
implementation(libs.nimbus.jose.jwt)
10+
11+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
12+
testImplementation 'org.springframework.boot:spring-boot-starter-json'
13+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package ai.javaclaw.channels.teams;
2+
3+
import com.nimbusds.jose.JWSAlgorithm;
4+
import com.nimbusds.jose.jwk.source.JWKSource;
5+
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
6+
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
7+
import com.nimbusds.jose.proc.SecurityContext;
8+
import com.nimbusds.jwt.JWTClaimsSet;
9+
import com.nimbusds.jwt.SignedJWT;
10+
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
11+
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
15+
import java.net.MalformedURLException;
16+
import java.net.URI;
17+
import java.time.Duration;
18+
import java.util.Set;
19+
20+
/**
21+
* Validates the Bot Framework Connector JWT sent on every incoming Teams webhook call
22+
* (the {@code Authorization: Bearer <token>} header), per
23+
* https://learn.microsoft.com/azure/bot-service/rest-api/bot-framework-rest-connector-authentication
24+
* <p>
25+
* A valid token must: (1) be signed with RS256 by a key currently published in Bot Framework's JWKS,
26+
* (2) have issuer {@code https://api.botframework.com}, (3) have this bot's App ID as audience,
27+
* (4) not be expired, allowing five minutes of clock skew as Microsoft recommends, and (5) carry a
28+
* {@code serviceUrl} claim equal to the activity's {@code serviceUrl}. The last check matters because
29+
* the bot later sends its own outbound token to that URL. The JWKS is fetched once and
30+
* cached/refreshed automatically by the underlying {@link JWKSource}.
31+
*/
32+
class BotFrameworkJwtValidator {
33+
34+
private static final Logger LOGGER = LoggerFactory.getLogger(BotFrameworkJwtValidator.class);
35+
static final String EXPECTED_ISSUER = "https://api.botframework.com";
36+
static final String SERVICE_URL_CLAIM = "serviceUrl";
37+
static final int MAX_CLOCK_SKEW_SECONDS = (int) Duration.ofMinutes(5).toSeconds();
38+
private static final String JWKS_URL = "https://login.botframework.com/v1/.well-known/keys";
39+
40+
private final DefaultJWTProcessor<SecurityContext> jwtProcessor;
41+
42+
BotFrameworkJwtValidator(TeamsProperties properties) {
43+
this(properties, remoteJwkSource());
44+
}
45+
46+
/** Test seam: inject a local key set instead of hitting the real JWKS endpoint. */
47+
BotFrameworkJwtValidator(TeamsProperties properties, JWKSource<SecurityContext> jwkSource) {
48+
String appId = properties.getAppId();
49+
// An empty accepted-audience set rejects every token, which is what we want without an App ID
50+
Set<String> acceptedAudience = appId == null || appId.isBlank() ? Set.of() : Set.of(appId);
51+
52+
DefaultJWTClaimsVerifier<SecurityContext> claimsVerifier = new DefaultJWTClaimsVerifier<>(
53+
acceptedAudience,
54+
new JWTClaimsSet.Builder().issuer(EXPECTED_ISSUER).build(),
55+
Set.of("exp", SERVICE_URL_CLAIM),
56+
null);
57+
claimsVerifier.setMaxClockSkew(MAX_CLOCK_SKEW_SECONDS);
58+
59+
this.jwtProcessor = new DefaultJWTProcessor<>();
60+
this.jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));
61+
this.jwtProcessor.setJWTClaimsSetVerifier(claimsVerifier);
62+
}
63+
64+
private static JWKSource<SecurityContext> remoteJwkSource() {
65+
try {
66+
return JWKSourceBuilder
67+
.create(URI.create(JWKS_URL).toURL())
68+
.cache(Duration.ofMinutes(30).toMillis(), Duration.ofMinutes(1).toMillis())
69+
.build();
70+
} catch (MalformedURLException e) {
71+
throw new IllegalStateException("Invalid Bot Framework JWKS URL: " + JWKS_URL, e);
72+
}
73+
}
74+
75+
/**
76+
* @param bearerToken the token from the {@code Authorization} header, without the {@code Bearer } prefix
77+
* @param serviceUrl the {@code serviceUrl} of the incoming activity
78+
* @return true if {@code bearerToken} is a currently valid Bot Framework Connector token for this bot
79+
* that was issued for {@code serviceUrl}
80+
*/
81+
boolean isValid(String bearerToken, String serviceUrl) {
82+
try {
83+
// Throws if the signature, algorithm, issuer, audience or expiry is wrong
84+
JWTClaimsSet claims = jwtProcessor.process(SignedJWT.parse(bearerToken), null);
85+
86+
String tokenServiceUrl = claims.getStringClaim(SERVICE_URL_CLAIM);
87+
if (serviceUrl == null || !sameUrl(tokenServiceUrl, serviceUrl)) {
88+
LOGGER.warn("Rejected Teams webhook JWT issued for serviceUrl '{}' on an activity for '{}'",
89+
tokenServiceUrl, serviceUrl);
90+
return false;
91+
}
92+
return true;
93+
} catch (Exception e) {
94+
LOGGER.warn("Rejected Teams webhook JWT: {}", e.getMessage());
95+
return false;
96+
}
97+
}
98+
99+
/** Compares two URLs, ignoring a trailing slash and letter case. */
100+
private static boolean sameUrl(String a, String b) {
101+
return a != null && stripTrailingSlash(a).equalsIgnoreCase(stripTrailingSlash(b));
102+
}
103+
104+
private static String stripTrailingSlash(String url) {
105+
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
106+
}
107+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package ai.javaclaw.channels.teams;
2+
3+
import tools.jackson.databind.json.JsonMapper;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
7+
import java.net.URI;
8+
import java.net.URLEncoder;
9+
import java.net.http.HttpClient;
10+
import java.net.http.HttpRequest;
11+
import java.net.http.HttpResponse;
12+
import java.nio.charset.StandardCharsets;
13+
import java.time.Instant;
14+
import java.util.concurrent.atomic.AtomicReference;
15+
16+
/**
17+
* Fetches and caches an OAuth2 client-credentials token for the Bot Framework
18+
* Connector API, using the Azure AD v2 endpoint (login.microsoftonline.com).
19+
*/
20+
class BotFrameworkTokenProvider {
21+
22+
private static final Logger LOGGER = LoggerFactory.getLogger(BotFrameworkTokenProvider.class);
23+
private static final String SCOPE = "https://api.botframework.com/.default";
24+
private static final JsonMapper JSON = JsonMapper.builder().build();
25+
26+
private final TeamsProperties properties;
27+
private final HttpClient httpClient;
28+
private final AtomicReference<CachedToken> cached = new AtomicReference<>();
29+
30+
BotFrameworkTokenProvider(TeamsProperties properties, HttpClient httpClient) {
31+
this.properties = properties;
32+
this.httpClient = httpClient;
33+
}
34+
35+
/** @return a valid bearer token, refreshing it if expired or missing */
36+
synchronized String getToken() {
37+
CachedToken token = cached.get();
38+
if (token != null && Instant.now().isBefore(token.expiresAt())) {
39+
return token.value();
40+
}
41+
CachedToken fresh = fetchToken();
42+
cached.set(fresh);
43+
return fresh.value();
44+
}
45+
46+
private CachedToken fetchToken() {
47+
String tenantId = properties.getTenantId();
48+
if (tenantId == null || tenantId.isBlank()) {
49+
throw new IllegalStateException("No agent.channels.teams.tenant-id configured, cannot obtain a Bot Framework access token");
50+
}
51+
// URLEncoder encodes a space as '+', which is only valid in a form body, not in a path segment
52+
String tokenUrl = "https://login.microsoftonline.com/" + encode(tenantId.trim()).replace("+", "%20")
53+
+ "/oauth2/v2.0/token";
54+
// Client secrets may contain '+', '&' or '=', so every value must be form-encoded
55+
String body = "grant_type=client_credentials"
56+
+ "&client_id=" + encode(properties.getAppId())
57+
+ "&client_secret=" + encode(properties.getAppSecret())
58+
+ "&scope=" + encode(SCOPE);
59+
60+
HttpRequest request = HttpRequest.newBuilder(URI.create(tokenUrl))
61+
.header("Content-Type", "application/x-www-form-urlencoded")
62+
.POST(HttpRequest.BodyPublishers.ofString(body))
63+
.build();
64+
try {
65+
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
66+
if (response.statusCode() != 200) {
67+
// The response body is left out on purpose: it may echo request details
68+
throw new IllegalStateException(
69+
"Bot Framework token request failed with status " + response.statusCode());
70+
}
71+
TokenResponse parsed = JSON.readValue(response.body(), TokenResponse.class);
72+
return new CachedToken(parsed.accessToken(),
73+
Instant.now().plusSeconds(Math.max(0, parsed.expiresIn() - 60)));
74+
} catch (Exception e) {
75+
LOGGER.error("Failed to fetch Bot Framework access token", e);
76+
throw new IllegalStateException("Could not obtain Bot Framework access token", e);
77+
}
78+
}
79+
80+
private static String encode(String value) {
81+
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
82+
}
83+
84+
private record CachedToken(String value, Instant expiresAt) {
85+
}
86+
87+
private record TokenResponse(
88+
@com.fasterxml.jackson.annotation.JsonProperty("access_token") String accessToken,
89+
@com.fasterxml.jackson.annotation.JsonProperty("expires_in") int expiresIn) {
90+
}
91+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package ai.javaclaw.channels.teams;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
public record TeamsActivityPayload(
8+
@JsonProperty("type") String type,
9+
@JsonProperty("id") String id,
10+
@JsonProperty("serviceUrl") String serviceUrl,
11+
@JsonProperty("channelId") String channelId,
12+
@JsonProperty("text") String text,
13+
@JsonProperty("from") From from,
14+
@JsonProperty("conversation") Conversation conversation) {
15+
16+
@JsonIgnoreProperties(ignoreUnknown = true)
17+
public record From(@JsonProperty("id") String id, @JsonProperty("name") String name,
18+
@JsonProperty("aadObjectId") String aadObjectId) {
19+
}
20+
21+
@JsonIgnoreProperties(ignoreUnknown = true)
22+
/** {@code conversationType} is {@code "personal"}, {@code "groupChat"} or {@code "channel"}. */
23+
public record Conversation(@JsonProperty("id") String id,
24+
@JsonProperty("conversationType") String conversationType) {
25+
}
26+
}

0 commit comments

Comments
 (0)