Skip to content
Closed
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
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
7 changes: 7 additions & 0 deletions app/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ agent:
workspace: file:./workspace/
skills:
paths: classpath:/META-INF/skills
channels:
teams:
enabled: true
app-id: "00000000-0000-0000-0000-000000000001"
app-secret: "dummy-secret"
tenant-id: "00000000-0000-0000-0000-000000000002"
# allowed-user-id: ""
jobrunr:
background-job-server:
enabled: true
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 'com.nimbusds:nimbus-jose-jwt:10.9.1'

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,88 @@
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.DefaultJWTProcessor;
import com.nimbusds.jwt.proc.JWTProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

/**
* 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 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, and (4) not be expired. The JWKS itself is fetched once and cached/refreshed
* automatically by the underlying {@link JWKSource}.
*/
class BotFrameworkJwtValidator {

private static final Logger LOGGER = LoggerFactory.getLogger(BotFrameworkJwtValidator.class);
private static final String EXPECTED_ISSUER = "https://api.botframework.com";
private static final String JWKS_URL = "https://login.botframework.com/v1/.well-known/keys";

private final TeamsProperties properties;
private final JWTProcessor<SecurityContext> jwtProcessor;

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

/** Test seam: inject a stub/mocked processor instead of hitting the real JWKS endpoint. */
BotFrameworkJwtValidator(TeamsProperties properties, JWTProcessor<SecurityContext> jwtProcessor) {
this.properties = properties;
this.jwtProcessor = jwtProcessor;
}

private static JWTProcessor<SecurityContext> defaultProcessor() {
try {
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
.create(URI.create(JWKS_URL).toURL())
.cache(Duration.ofMinutes(30).toMillis(), Duration.ofMinutes(1).toMillis())
.build();
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));
return processor;
} catch (MalformedURLException e) {
throw new IllegalStateException("Invalid Bot Framework JWKS URL: " + JWKS_URL, e);
}
}

/** @return true if {@code bearerToken} is a currently valid Bot Framework Connector token for this bot */
boolean isValid(String bearerToken) {
try {
SignedJWT jwt = SignedJWT.parse(bearerToken);
JWTClaimsSet claims = jwtProcessor.process(jwt, null); // throws if signature/algorithm is invalid

if (!EXPECTED_ISSUER.equals(claims.getIssuer())) {
LOGGER.warn("Rejected Teams webhook JWT with unexpected issuer '{}'", claims.getIssuer());
return false;
}
if (properties.getAppId() == null || !claims.getAudience().contains(properties.getAppId())) {
LOGGER.warn("Rejected Teams webhook JWT with unexpected audience {}", claims.getAudience());
return false;
}
Date expiration = claims.getExpirationTime();
if (expiration == null || expiration.before(new Date())) {
LOGGER.warn("Rejected expired Teams webhook JWT");
return false;
}
return true;
} catch (Exception e) {
LOGGER.warn("Rejected Teams webhook JWT: {}", e.getMessage());
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
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() {
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 tokenUrl = "https://login.microsoftonline.com/" + properties.getTenantId()
+ "/oauth2/v2.0/token";
String body = "grant_type=client_credentials"
+ "&client_id=" + properties.getAppId()
+ "&client_secret=" + properties.getAppSecret()
+ "&scope=" + 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) {
throw new IllegalStateException(
"Bot Framework token request failed with status " + response.statusCode()
+ ": " + response.body());
}
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 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,23 @@
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) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
public record Conversation(@JsonProperty("id") String id) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package ai.javaclaw.channels.teams;

import ai.javaclaw.channels.Channel;
import ai.javaclaw.channels.ChannelRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tools.jackson.databind.json.JsonMapper;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

public class TeamsChannel implements Channel {

private static final Logger LOGGER = LoggerFactory.getLogger(TeamsChannel.class);
private static final JsonMapper JSON = JsonMapper.builder().build();

static final String CHANNEL_ID = "teams";

private final BotFrameworkTokenProvider tokenProvider;
private final HttpClient httpClient;
private final AtomicReference<TeamsConversationReference> conversationReference =
new AtomicReference<>();

public TeamsChannel(BotFrameworkTokenProvider tokenProvider, ChannelRegistry channelRegistry) {
this(tokenProvider, HttpClient.newHttpClient(), channelRegistry);
}

TeamsChannel(BotFrameworkTokenProvider tokenProvider, HttpClient httpClient, ChannelRegistry channelRegistry) {
this.tokenProvider = tokenProvider;
this.httpClient = httpClient;
channelRegistry.registerChannel(this);
LOGGER.info("Started Microsoft Teams channel");
}

@Override
public String getName() {
return CHANNEL_ID;
}

/** Called by the webhook controller whenever a message arrives from the allowed user. */
void updateConversationReference(TeamsConversationReference reference) {
conversationReference.set(reference);
}

@Override
public void sendMessage(String message) {
if (message == null || message.isBlank()) {
return;
}
TeamsConversationReference reference = conversationReference.get();
if (reference == null) {
LOGGER.warn("No known Teams conversation yet, cannot send message '{}'", message);
return;
}

String url = reference.serviceUrl()
+ (reference.serviceUrl().endsWith("/") ? "" : "/")
+ "v3/conversations/" + reference.conversationId() + "/activities";

try {
String body = JSON.writeValueAsString(Map.of("type", "message", "text", message));
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + tokenProvider.getToken())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
LOGGER.error("Teams send failed with status {}: {}", response.statusCode(), response.body());
}
} catch (Exception e) {
LOGGER.error("Failed to send Teams message '{}'", message, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package ai.javaclaw.channels.teams;

import ai.javaclaw.channels.ChannelRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import ai.javaclaw.agent.Agent;

import java.net.http.HttpClient;

@AutoConfiguration
@EnableConfigurationProperties(TeamsProperties.class)
public class TeamsChannelAutoConfiguration {

@Bean
@ConditionalOnMissingBean
public BotFrameworkTokenProvider botFrameworkTokenProvider(TeamsProperties properties) {
return new BotFrameworkTokenProvider(properties, HttpClient.newHttpClient());
}

@Bean
@ConditionalOnMissingBean
public BotFrameworkJwtValidator botFrameworkJwtValidator(TeamsProperties properties) {
return new BotFrameworkJwtValidator(properties);
}

@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "agent.channels.teams", name = "enabled", havingValue = "true")
public TeamsChannel teamsChannel(BotFrameworkTokenProvider tokenProvider, ChannelRegistry channelRegistry) {
return new TeamsChannel(tokenProvider, channelRegistry);
}

@Bean
@ConditionalOnProperty(
prefix = "agent.channels.teams",
name = "enabled",
havingValue = "true"
)
public TeamsWebhookController teamsWebhookController(
TeamsProperties properties,
ChannelRegistry channelRegistry,
Agent agent,
TeamsChannel channel,
BotFrameworkJwtValidator jwtValidator) {

return new TeamsWebhookController(
properties,
channelRegistry,
agent,
channel,
jwtValidator
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package ai.javaclaw.channels.teams;

import ai.javaclaw.channels.ChannelMessageReceivedEvent;

public class TeamsChannelMessageReceivedEvent extends ChannelMessageReceivedEvent {

private final String conversationId;

public TeamsChannelMessageReceivedEvent(String channel, String message, String conversationId) {
super(channel, message);
this.conversationId = conversationId;
}

public String getConversationId() {
return conversationId;
}
}
Loading
Loading