diff --git a/app/build.gradle b/app/build.gradle index 9f83ff7..c828a42 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index 7b50702..5db6eff 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -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 diff --git a/plugins/teams/build.gradle b/plugins/teams/build.gradle new file mode 100644 index 0000000..02b14e3 --- /dev/null +++ b/plugins/teams/build.gradle @@ -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' +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/BotFrameworkJwtValidator.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/BotFrameworkJwtValidator.java new file mode 100644 index 0000000..d7cc7cb --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/BotFrameworkJwtValidator.java @@ -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 } header), per + * https://learn.microsoft.com/azure/bot-service/rest-api/bot-framework-rest-connector-authentication + *

+ * 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 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 jwtProcessor) { + this.properties = properties; + this.jwtProcessor = jwtProcessor; + } + + private static JWTProcessor defaultProcessor() { + try { + JWKSource jwkSource = JWKSourceBuilder + .create(URI.create(JWKS_URL).toURL()) + .cache(Duration.ofMinutes(30).toMillis(), Duration.ofMinutes(1).toMillis()) + .build(); + DefaultJWTProcessor 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; + } + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/BotFrameworkTokenProvider.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/BotFrameworkTokenProvider.java new file mode 100644 index 0000000..8e0d74d --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/BotFrameworkTokenProvider.java @@ -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 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 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) { + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsActivityPayload.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsActivityPayload.java new file mode 100644 index 0000000..0f731d9 --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsActivityPayload.java @@ -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) { + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannel.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannel.java new file mode 100644 index 0000000..8446dac --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannel.java @@ -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 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 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); + } + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannelAutoConfiguration.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannelAutoConfiguration.java new file mode 100644 index 0000000..bd7f237 --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannelAutoConfiguration.java @@ -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 + ); + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannelMessageReceivedEvent.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannelMessageReceivedEvent.java new file mode 100644 index 0000000..81756f7 --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsChannelMessageReceivedEvent.java @@ -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; + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsConversationReference.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsConversationReference.java new file mode 100644 index 0000000..c592723 --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsConversationReference.java @@ -0,0 +1,8 @@ +package ai.javaclaw.channels.teams; + +/** + * The minimum needed to POST a proactive reply back through the Bot Framework + * Connector API: where to send it (serviceUrl) and which conversation it belongs to. + */ +public record TeamsConversationReference(String serviceUrl, String conversationId) { +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsOnboardingProvider.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsOnboardingProvider.java new file mode 100644 index 0000000..521e852 --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsOnboardingProvider.java @@ -0,0 +1,85 @@ +package ai.javaclaw.channels.teams; + +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.LinkedHashMap; +import java.util.Map; + +@Component +@Order(57) +public class TeamsOnboardingProvider implements OnboardingProvider { + + static final String SESSION_APP_ID = "onboarding.teams.app-id"; + static final String SESSION_APP_SECRET = "onboarding.teams.app-secret"; + static final String SESSION_TENANT_ID = "onboarding.teams.tenant-id"; + + private static final String ENABLED_PROPERTY = "agent.channels.teams.enabled"; + private static final String APP_ID_PROPERTY = "agent.channels.teams.app-id"; + private static final String APP_SECRET_PROPERTY = "agent.channels.teams.app-secret"; + private static final String TENANT_ID_PROPERTY = "agent.channels.teams.tenant-id"; + + private final Environment env; + + public TeamsOnboardingProvider(Environment env) { + this.env = env; + } + + @Override + public boolean isOptional() { return true; } + + @Override + public String getStepId() { return "teams"; } + + @Override + public String getStepTitle() { return "Microsoft Teams"; } + + @Override + public String getTemplatePath() { return "onboarding/steps/teams"; } + + @Override + public void prepareModel(Map session, Map model) { + model.put("teamsAppId", session.getOrDefault(SESSION_APP_ID, env.getProperty(APP_ID_PROPERTY, ""))); + model.put("teamsTenantId", session.getOrDefault(SESSION_TENANT_ID, env.getProperty(TENANT_ID_PROPERTY, ""))); + } + + @Override + public String processStep(Map formParams, Map session) { + String appId = trimToNull(formParams.get("teamsAppId")); + String appSecret = trimToNull(formParams.get("teamsAppSecret")); + String tenantId = trimToNull(formParams.get("teamsTenantId")); + + if (appId == null || appSecret == null || tenantId == null) { + return "App ID, App Secret and Tenant ID are all required."; + } + + session.put(SESSION_APP_ID, appId); + session.put(SESSION_APP_SECRET, appSecret); + session.put(SESSION_TENANT_ID, tenantId); + return null; + } + + @Override + public void saveConfiguration(Map session, ConfigurationManager configurationManager) throws IOException { + String appId = (String) session.get(SESSION_APP_ID); + if (appId == null) { + return; + } + Map properties = new LinkedHashMap<>(); + properties.put(ENABLED_PROPERTY, true); + properties.put(APP_ID_PROPERTY, appId); + properties.put(APP_SECRET_PROPERTY, session.get(SESSION_APP_SECRET)); + properties.put(TENANT_ID_PROPERTY, session.get(SESSION_TENANT_ID)); + configurationManager.updateProperties(properties); + } + + private static String trimToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isBlank() ? null : trimmed; + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsProperties.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsProperties.java new file mode 100644 index 0000000..c7d9817 --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsProperties.java @@ -0,0 +1,34 @@ +package ai.javaclaw.channels.teams; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "agent.channels.teams") +public class TeamsProperties { + + private boolean enabled; + private String appId; + private String appSecret; + private String tenantId; + + /** + * Optional: Azure AD object ID of the single Teams user the assistant should respond to. + * When blank, the first user who messages the bot is accepted (not recommended for + * anything but local testing). + */ + private String allowedUserId; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getAppSecret() { return appSecret; } + public void setAppSecret(String appSecret) { this.appSecret = appSecret; } + + public String getTenantId() { return tenantId; } + public void setTenantId(String tenantId) { this.tenantId = tenantId; } + + public String getAllowedUserId() { return allowedUserId; } + public void setAllowedUserId(String allowedUserId) { this.allowedUserId = allowedUserId; } +} \ No newline at end of file diff --git a/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsWebhookController.java b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsWebhookController.java new file mode 100644 index 0000000..dd6178a --- /dev/null +++ b/plugins/teams/src/main/java/ai/javaclaw/channels/teams/TeamsWebhookController.java @@ -0,0 +1,97 @@ +package ai.javaclaw.channels.teams; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@RestController +@ConditionalOnProperty(prefix = "agent.channels.teams", name = "enabled", havingValue = "true") +public class TeamsWebhookController { + + private static final Logger LOGGER = LoggerFactory.getLogger(TeamsWebhookController.class); + + private final TeamsProperties properties; + private final ChannelRegistry channelRegistry; + private final Agent agent; + private final TeamsChannel channel; + private final BotFrameworkJwtValidator jwtValidator; + private final Executor executor; + + public TeamsWebhookController(TeamsProperties properties, ChannelRegistry channelRegistry, + Agent agent, TeamsChannel channel, BotFrameworkJwtValidator jwtValidator) { + this.properties = properties; + this.channelRegistry = channelRegistry; + this.agent = agent; + this.channel = channel; + this.jwtValidator = jwtValidator; + this.executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "teams-webhook-worker"); + t.setDaemon(true); + return t; + }); + } + + @PostMapping("/api/teams/webhook") + public ResponseEntity webhook(@RequestHeader(value = "Authorization", required = false) String authHeader, + @RequestBody(required = false) TeamsActivityPayload activity) { + if (!isRequestAuthorized(authHeader)) { + return ResponseEntity.status(401).build(); + } + if (activity == null || !"message".equals(activity.type())) { + return ResponseEntity.ok().build(); + } + if (!isAllowedUser(activity.from())) { + LOGGER.warn("Ignoring Teams message from unauthorized user '{}'", + activity.from() == null ? null : activity.from().id()); + return ResponseEntity.ok().build(); + } + String text = activity.text(); + if (text == null || text.isBlank()) { + return ResponseEntity.ok().build(); + } + + String conversationId = activity.conversation() == null ? null : activity.conversation().id(); + channel.updateConversationReference(new TeamsConversationReference(activity.serviceUrl(), conversationId)); + + channelRegistry.publishMessageReceivedEvent( + new TeamsChannelMessageReceivedEvent(channel.getName(), text, conversationId)); + executor.execute(() -> handleMessage(conversationId, text)); + return ResponseEntity.ok().build(); + } + + private void handleMessage(String conversationId, String text) { + try { + String response = agent.respondTo(conversationId, text); + channel.sendMessage(response); + } catch (RuntimeException e) { + LOGGER.error("Failed to handle Teams message for conversation '{}'", conversationId, e); + } + } + + private boolean isAllowedUser(TeamsActivityPayload.From from) { + String allowed = properties.getAllowedUserId(); + if (allowed == null || allowed.isBlank()) { + return true; // not restricted — fine for local testing, not for production + } + return from != null && allowed.trim().equals(from.id()); + } + + private boolean isRequestAuthorized(String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + LOGGER.warn("Rejected Teams webhook call with missing/invalid Authorization header"); + return false; + } + String token = authHeader.substring("Bearer ".length()).trim(); + return jwtValidator.isValid(token); + } +} \ No newline at end of file diff --git a/plugins/teams/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plugins/teams/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..a6f534f --- /dev/null +++ b/plugins/teams/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +ai.javaclaw.channels.teams.TeamsChannelAutoConfiguration \ No newline at end of file diff --git a/plugins/teams/src/main/resources/templates/onboarding/steps/teams.html.peb b/plugins/teams/src/main/resources/templates/onboarding/steps/teams.html.peb new file mode 100644 index 0000000..4c126b6 --- /dev/null +++ b/plugins/teams/src/main/resources/templates/onboarding/steps/teams.html.peb @@ -0,0 +1,53 @@ +

+

Step {{ currentStepNumber }} of {{ totalSteps }}

+

Connect Microsoft Teams.

+

+ Register a bot in Azure Bot Service and provide its App ID, App Secret and your Azure AD + Tenant ID. These are written to application.yaml and used to authenticate against the + Bot Framework Connector API when sending replies back to Teams. +

+ + {% if error %} +
+
{{ error }}
+
+ {% endif %} + +
+
+ +
+ +
+
+ +
+ +
+ +
+

Required every time you save this step — it is never pre-filled back into the form.

+
+ +
+ +
+ +
+
+ +
+

Stored properties

+

agent.channels.teams.app-id

+

agent.channels.teams.app-secret

+

agent.channels.teams.tenant-id

+
+ +
+ Back + + {% if isOptional %}Skip{% endif %} + Saving... +
+
+
\ No newline at end of file diff --git a/plugins/teams/src/test/java/ai/javaclaw/channels/teams/BotFrameworkJwtValidatorTest.java b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/BotFrameworkJwtValidatorTest.java new file mode 100644 index 0000000..3a6294d --- /dev/null +++ b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/BotFrameworkJwtValidatorTest.java @@ -0,0 +1,109 @@ +package ai.javaclaw.channels.teams; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.BadJWTException; +import com.nimbusds.jwt.proc.JWTProcessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BotFrameworkJwtValidatorTest { + + private static final String APP_ID = "11111111-1111-1111-1111-111111111111"; + + @Mock + JWTProcessor jwtProcessor; + + BotFrameworkJwtValidator validator; + String token; + + @BeforeEach + void setUp() throws Exception { + TeamsProperties properties = new TeamsProperties(); + properties.setAppId(APP_ID); + validator = new BotFrameworkJwtValidator(properties, jwtProcessor); + token = wellFormedButUnverifiedJwt(); // structurally valid; signature check is mocked below + } + + private static String wellFormedButUnverifiedJwt() throws JOSEException { + RSAKey rsaKey = new RSAKeyGenerator(2048).generate(); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), new JWTClaimsSet.Builder().build()); + jwt.sign(new RSASSASigner(rsaKey)); + return jwt.serialize(); + } + + private JWTClaimsSet claims(String issuer, String audience, Date expiry) { + return new JWTClaimsSet.Builder() + .issuer(issuer) + .audience(audience) + .expirationTime(expiry) + .build(); + } + + @Test + void acceptsTokenWithCorrectIssuerAudienceAndExpiry() throws Exception { + when(jwtProcessor.process(any(SignedJWT.class), isNull())).thenReturn( + claims("https://api.botframework.com", APP_ID, future())); + + assertThat(validator.isValid(token)).isTrue(); + } + + @Test + void rejectsTokenWithWrongAudience() throws Exception { + when(jwtProcessor.process(any(SignedJWT.class), isNull())).thenReturn( + claims("https://api.botframework.com", "someone-elses-app-id", future())); + + assertThat(validator.isValid(token)).isFalse(); + } + + @Test + void rejectsTokenWithWrongIssuer() throws Exception { + when(jwtProcessor.process(any(SignedJWT.class), isNull())).thenReturn( + claims("https://evil.example.com", APP_ID, future())); + + assertThat(validator.isValid(token)).isFalse(); + } + + @Test + void rejectsExpiredToken() throws Exception { + when(jwtProcessor.process(any(SignedJWT.class), isNull())).thenReturn( + claims("https://api.botframework.com", APP_ID, past())); + + assertThat(validator.isValid(token)).isFalse(); + } + + @Test + void rejectsTokenWhoseSignatureFailsVerification() throws Exception { + when(jwtProcessor.process(any(SignedJWT.class), isNull())) + .thenThrow(new BadJWTException("signature verification failed")); + + assertThat(validator.isValid(token)).isFalse(); + } + + @Test + void rejectsMalformedToken() { + // "not-a-jwt" fails at SignedJWT.parse(...) itself, before jwtProcessor is ever touched + assertThat(validator.isValid("not-a-jwt")).isFalse(); + } + + private static Date future() { return new Date(System.currentTimeMillis() + 3_600_000); } + private static Date past() { return new Date(System.currentTimeMillis() - 3_600_000); } +} \ No newline at end of file diff --git a/plugins/teams/src/test/java/ai/javaclaw/channels/teams/BotFrameworkTokenProviderTest.java b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/BotFrameworkTokenProviderTest.java new file mode 100644 index 0000000..658efcb --- /dev/null +++ b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/BotFrameworkTokenProviderTest.java @@ -0,0 +1,77 @@ +package ai.javaclaw.channels.teams; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BotFrameworkTokenProviderTest { + + @Mock + private HttpClient httpClient; + + @Mock + private HttpResponse httpResponse; + + private TeamsProperties properties() { + TeamsProperties props = new TeamsProperties(); + props.setAppId("app-1"); + props.setAppSecret("secret-1"); + props.setTenantId("tenant-1"); + return props; + } + + @Test + void fetchesAndCachesToken() throws Exception { + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()).thenReturn("{\"access_token\":\"abc\",\"expires_in\":3600}"); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + + BotFrameworkTokenProvider provider = new BotFrameworkTokenProvider(properties(), httpClient); + + assertThat(provider.getToken()).isEqualTo("abc"); + assertThat(provider.getToken()).isEqualTo("abc"); + verify(httpClient, times(1)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + + @Test + void refetchesTokenAfterExpiry() throws Exception { + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()) + .thenReturn("{\"access_token\":\"abc\",\"expires_in\":0}") + .thenReturn("{\"access_token\":\"def\",\"expires_in\":3600}"); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + + BotFrameworkTokenProvider provider = new BotFrameworkTokenProvider(properties(), httpClient); + + assertThat(provider.getToken()).isEqualTo("abc"); + assertThat(provider.getToken()).isEqualTo("def"); + verify(httpClient, times(2)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + + @Test + void throwsWhenTokenEndpointFails() throws Exception { + when(httpResponse.statusCode()).thenReturn(401); + when(httpResponse.body()).thenReturn("invalid_client"); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + + BotFrameworkTokenProvider provider = new BotFrameworkTokenProvider(properties(), httpClient); + + assertThatThrownBy(provider::getToken).isInstanceOf(IllegalStateException.class); + } +} \ No newline at end of file diff --git a/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsActivityPayloadTest.java b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsActivityPayloadTest.java new file mode 100644 index 0000000..7cd4deb --- /dev/null +++ b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsActivityPayloadTest.java @@ -0,0 +1,35 @@ +package ai.javaclaw.channels.teams; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +class TeamsActivityPayloadTest { + + private final JsonMapper mapper = JsonMapper.builder().build(); + + @Test + void bindsRealBotFrameworkActivityFormat() { + String json = """ + { + "type": "message", + "id": "abc123", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "text": "hello there", + "from": { "id": "29:user-object-id", "name": "Alice" }, + "conversation": { "id": "19:conv-id@thread.v2" }, + "recipient": { "id": "28:bot-id" } + } + """; + + TeamsActivityPayload payload = mapper.readValue(json, TeamsActivityPayload.class); + + assertThat(payload.type()).isEqualTo("message"); + assertThat(payload.text()).isEqualTo("hello there"); + assertThat(payload.serviceUrl()).isEqualTo("https://smba.trafficmanager.net/amer/"); + assertThat(payload.from().id()).isEqualTo("29:user-object-id"); + assertThat(payload.conversation().id()).isEqualTo("19:conv-id@thread.v2"); + } +} \ No newline at end of file diff --git a/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsChannelTest.java b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsChannelTest.java new file mode 100644 index 0000000..e6c4a7a --- /dev/null +++ b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsChannelTest.java @@ -0,0 +1,100 @@ +package ai.javaclaw.channels.teams; + +import ai.javaclaw.channels.ChannelRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TeamsChannelTest { + + @Mock + private BotFrameworkTokenProvider tokenProvider; + + @Mock + private HttpClient httpClient; + + @Mock + private HttpResponse httpResponse; + + @Mock + private ChannelRegistry channelRegistry; + + private TeamsChannel channel; + + @BeforeEach + void setUp() { + channel = new TeamsChannel(tokenProvider, httpClient, channelRegistry); + } + + @Test + void registersItselfAsChannelOnConstruction() { + verify(channelRegistry).registerChannel(channel); + assertThat(channel.getName()).isEqualTo("teams"); + } + + @Test + void sendMessageDoesNothingWhenNoConversationKnownYet() { + // No activity has been received yet, so there is no known conversation to reply to + channel.sendMessage("hello"); + + verifyNoInteractions(httpClient); + } + + @Test + void sendMessageDoesNothingForBlankMessage() { + channel.updateConversationReference( + new TeamsConversationReference("https://smba.trafficmanager.net/amer/", "conv-1")); + + channel.sendMessage(" "); + + verifyNoInteractions(httpClient); + } + + @Test + void sendMessagePostsActivityToConnectorApiWithBearerToken() throws Exception { + when(tokenProvider.getToken()).thenReturn("tok-123"); + when(httpResponse.statusCode()).thenReturn(200); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + + channel.updateConversationReference( + new TeamsConversationReference("https://smba.trafficmanager.net/amer", "conv-1")); + + channel.sendMessage("hi there"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class)); + HttpRequest sent = captor.getValue(); + + assertThat(sent.uri().toString()) + .isEqualTo("https://smba.trafficmanager.net/amer/v3/conversations/conv-1/activities"); + assertThat(sent.headers().firstValue("Authorization")).contains("Bearer tok-123"); + } + + @Test + void sendMessageDoesNotThrowWhenConnectorApiReturnsError() throws Exception { + when(tokenProvider.getToken()).thenReturn("tok-123"); + when(httpResponse.statusCode()).thenReturn(500); + when(httpResponse.body()).thenReturn("boom"); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + + channel.updateConversationReference(new TeamsConversationReference("https://smba.example/", "conv-1")); + + channel.sendMessage("hi there"); // must be handled internally (logged), never thrown + } +} \ No newline at end of file diff --git a/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsOnboardingProviderTest.java b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsOnboardingProviderTest.java new file mode 100644 index 0000000..edce56f --- /dev/null +++ b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsOnboardingProviderTest.java @@ -0,0 +1,124 @@ +package ai.javaclaw.channels.teams; + +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.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 TeamsOnboardingProviderTest { + + @Mock + Environment environment; + + @Mock + ConfigurationManager configurationManager; + + @Test + void stepMetadataIsCorrect() { + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + + assertThat(provider.getStepId()).isEqualTo("teams"); + assertThat(provider.getStepTitle()).isEqualTo("Microsoft Teams"); + assertThat(provider.getTemplatePath()).isEqualTo("onboarding/steps/teams"); + assertThat(provider.isOptional()).isTrue(); + } + + @Test + void processStepStoresTrimmedSessionValues() { + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + Map session = new HashMap<>(); + + String result = provider.processStep(Map.of( + "teamsAppId", " app-1 ", + "teamsAppSecret", " secret-1 ", + "teamsTenantId", " tenant-1 " + ), session); + + assertThat(result).isNull(); + assertThat(session).containsEntry(TeamsOnboardingProvider.SESSION_APP_ID, "app-1"); + assertThat(session).containsEntry(TeamsOnboardingProvider.SESSION_APP_SECRET, "secret-1"); + assertThat(session).containsEntry(TeamsOnboardingProvider.SESSION_TENANT_ID, "tenant-1"); + } + + @Test + void processStepReturnsErrorWhenAnyFieldIsMissing() { + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + + String result = provider.processStep(Map.of( + "teamsAppId", "app-1", + "teamsAppSecret", "", + "teamsTenantId", "tenant-1" + ), new HashMap<>()); + + assertThat(result).isEqualTo("App ID, App Secret and Tenant ID are all required."); + } + + @Test + void prepareModelUsesSessionValuesWhenPresentAndNeverExposesSecret() { + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + Map session = Map.of( + TeamsOnboardingProvider.SESSION_APP_ID, "session-app-id", + TeamsOnboardingProvider.SESSION_APP_SECRET, "session-secret", + TeamsOnboardingProvider.SESSION_TENANT_ID, "session-tenant-id" + ); + Map model = new HashMap<>(); + + provider.prepareModel(session, model); + + assertThat(model).containsEntry("teamsAppId", "session-app-id"); + assertThat(model).containsEntry("teamsTenantId", "session-tenant-id"); + assertThat(model).doesNotContainKey("teamsAppSecret"); + } + + @Test + void prepareModelFallsBackToEnvironmentValues() { + when(environment.getProperty("agent.channels.teams.app-id", "")).thenReturn("env-app-id"); + when(environment.getProperty("agent.channels.teams.tenant-id", "")).thenReturn("env-tenant-id"); + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + Map model = new HashMap<>(); + + provider.prepareModel(Map.of(), model); + + assertThat(model).containsEntry("teamsAppId", "env-app-id"); + assertThat(model).containsEntry("teamsTenantId", "env-tenant-id"); + } + + @Test + void saveConfigurationWritesAllPropertiesIncludingEnabledFlag() throws Exception { + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + Map session = Map.of( + TeamsOnboardingProvider.SESSION_APP_ID, "app-1", + TeamsOnboardingProvider.SESSION_APP_SECRET, "secret-1", + TeamsOnboardingProvider.SESSION_TENANT_ID, "tenant-1" + ); + + provider.saveConfiguration(session, configurationManager); + + verify(configurationManager).updateProperties(Map.of( + "agent.channels.teams.enabled", true, + "agent.channels.teams.app-id", "app-1", + "agent.channels.teams.app-secret", "secret-1", + "agent.channels.teams.tenant-id", "tenant-1" + )); + } + + @Test + void saveConfigurationDoesNothingWhenAppIdMissing() throws Exception { + TeamsOnboardingProvider provider = new TeamsOnboardingProvider(environment); + + provider.saveConfiguration(new HashMap<>(), configurationManager); + + verifyNoInteractions(configurationManager); + } +} \ No newline at end of file diff --git a/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsWebhookControllerTest.java b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsWebhookControllerTest.java new file mode 100644 index 0000000..c4a590d --- /dev/null +++ b/plugins/teams/src/test/java/ai/javaclaw/channels/teams/TeamsWebhookControllerTest.java @@ -0,0 +1,131 @@ +package ai.javaclaw.channels.teams; + +import ai.javaclaw.agent.Agent; +import ai.javaclaw.channels.ChannelRegistry; +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.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TeamsWebhookControllerTest { + + private static final long WAIT_FOR_BACKGROUND_WORK_MILLIS = 1_000; + + @Mock + private ChannelRegistry channelRegistry; + + @Mock + private Agent agent; + + @Mock + private TeamsChannel channel; + + @Mock + private BotFrameworkJwtValidator jwtValidator; // ← جدید + + private TeamsWebhookController controller(String allowedUserId) { + TeamsProperties properties = new TeamsProperties(); + properties.setAllowedUserId(allowedUserId); + // اکثر تست‌ها "Bearer t" می‌فرستن؛ فقط همون‌هایی که واقعاً بهش می‌رسن این stub رو مصرف می‌کنن، + // برای همین lenient لازمه تا تستی که اصلاً auth رد نمی‌شه (بدون Bearer) با + // UnnecessaryStubbingException fail نشه. + lenient().when(jwtValidator.isValid(anyString())).thenReturn(true); + return new TeamsWebhookController(properties, channelRegistry, agent, channel, jwtValidator); + } + + @Test + void rejectsRequestsWithoutBearerToken() { + ResponseEntity response = + controller(null).webhook(null, activity("message", "u1", "hello", "conv-1")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(agent, channel, channelRegistry, jwtValidator); + } + + @Test + void rejectsRequestWithInvalidJwt() { + TeamsProperties properties = new TeamsProperties(); + when(jwtValidator.isValid("bad-token")).thenReturn(false); + TeamsWebhookController controller = + new TeamsWebhookController(properties, channelRegistry, agent, channel, jwtValidator); + + ResponseEntity response = + controller.webhook("Bearer bad-token", activity("message", "u1", "hello", "conv-1")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(agent, channel, channelRegistry); + } + + @Test + void acceptsAnEmptyBody() { + ResponseEntity response = controller(null).webhook("Bearer t", null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verifyNoInteractions(agent, channel, channelRegistry); + } + + @Test + void ignoresNonMessageActivities() { + controller(null).webhook("Bearer t", activity("conversationUpdate", "u1", null, "conv-1")); + + verifyNoInteractions(agent, channel, channelRegistry); + } + + @Test + void ignoresActivitiesWithoutText() { + controller(null).webhook("Bearer t", activity("message", "u1", null, "conv-1")); + + verifyNoInteractions(agent, channel, channelRegistry); + } + + @Test + void ignoresMessagesFromUnauthorizedUser() { + controller("allowed_user").webhook("Bearer t", activity("message", "other_user", "hello", "conv-1")); + + verify(agent, never()).respondTo(anyString(), anyString()); + verifyNoInteractions(channel); + } + + @Test + void acceptsMessagesFromTheConfiguredAllowedUser() { + when(agent.respondTo("conv-1", "hello")).thenReturn("hi!"); + + controller("allowed_user").webhook("Bearer t", activity("message", "allowed_user", "hello", "conv-1")); + + verify(agent, timeout(WAIT_FOR_BACKGROUND_WORK_MILLIS)).respondTo("conv-1", "hello"); + } + + @Test + void handsTheMessageToTheAgentAndRepliesThroughTheChannel() { + when(agent.respondTo("conv-1", "hello")).thenReturn("hi!"); + + ResponseEntity response = + controller(null).webhook("Bearer t", activity("message", "u1", "hello", "conv-1")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(channel).updateConversationReference( + new TeamsConversationReference("https://smba.example/", "conv-1")); + verify(channelRegistry).publishMessageReceivedEvent(any(TeamsChannelMessageReceivedEvent.class)); + verify(agent, timeout(WAIT_FOR_BACKGROUND_WORK_MILLIS)).respondTo("conv-1", "hello"); + verify(channel, timeout(WAIT_FOR_BACKGROUND_WORK_MILLIS)).sendMessage("hi!"); + } + + private TeamsActivityPayload activity(String type, String fromId, String text, String conversationId) { + return new TeamsActivityPayload(type, "activity-1", "https://smba.example/", "msteams", text, + new TeamsActivityPayload.From(fromId, "Some User"), + new TeamsActivityPayload.Conversation(conversationId)); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 6ea34bf..93509b3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,7 @@ include 'plugins:telegram' include 'plugins:playwright' include 'plugins:brave' include 'plugins:whatsapp' +include 'plugins:teams' include 'providers' include 'providers:anthropic' include 'providers:google'