BDK Core Spring Boot Starter
The Symphony BDK for Java provides a Starter module that aims to ease bot developments within a Spring Boot application.
Features
- Configure bot environment through
application.yaml
- Subscribe to Real Time Events from anywhere
- Provide injectable services
- Ease activities creation
- Provide
@Slash
annotation to register a slash command
Installation
The following listing shows the pom.xml
file that has to be created when using Maven:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>bdk-core-spring-boot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>bdk-core-spring-boot</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.finos.symphony.bdk</groupId>
<artifactId>symphony-bdk-bom</artifactId>
<version>2.12.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.finos.symphony.bdk</groupId>
<artifactId>symphony-bdk-core-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
// integration test dependency
<dependency>
<groupId>org.finos.symphony.bdk</groupId>
<artifactId>symphony-bdk-test-spring-boot</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.4.RELEASE</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
The following listing shows the build.gradle
file that has to be created when using Gradle:
plugins {
id 'java-library'
id 'org.springframework.boot' version '2.3.4.RELEASE'
}
dependencies {
implementation platform('org.finos.symphony.bdk:symphony-bdk-bom:2.12.0-SNAPSHOT')
implementation 'org.finos.symphony.bdk:symphony-bdk-core-spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter'
// integration test dependency
testImplementation 'org.finos.symphony.bdk:symphony-bdk-test-spring-boot'
}
Create a Simple Bot Application
As a first step, you have to initialize your bot environment through the Spring Boot src/main/resources/application.yaml
file:
bdk:
host: acme.symphony.com
bot:
username: bot-username
privateKey:
path: /path/to/rsa/privatekey.pem
logging:
level:
com.symphony: debug # in development mode, it is strongly recommended to set the BDK logging level at DEBUG
You can notice here that the
bdk
property inherits from theBdkConfig
class.
As required by Spring Boot, you have to create an src/main/java/com/example/bot/BotApplication.java
class:
@SpringBootApplication
public class BotApplication {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Now you can create a component for a simple bot application, as the following listing (from src/main/java/com/example/bot/HelloBot.java
) shows:
@Component
public class HelloBot {
@Autowired
private MessageService messageService;
@EventListener
public void onMessageSent(RealTimeEvent<? extends V4MessageSent> event) {
log.info("event was triggered at {}", ((EventPayload) event.getSource()).getEventTimestamp());
this.messageService.send(event.getSource().getMessage().getStream(), "Hello!");
}
}
You can finally run your Spring Boot application and verify that your bot always replies with Hello!
. It also worth noting that the event timestamp is only accessible from EventPayload
type, you need simply cast the source event to it, and call getEventTimestamp()
method to read the value, as you can see from the example here.
OBO (On behalf of) usecases
It is possible to run an application with no bot service account configured in order to accommodate OBO usecases only. For instance the following configuration is valid:
bdk:
host: acme.symphony.com
app:
appId: app-id
privateKey:
path: /path/to/rsa/privatekey.pem
This will cause all features related to the datafeed loop such as Real Time Events, activities, slash commands, etc. to be deactivated. However, service beans with OBO-enabled endpoints will be available and can be used as following:
@Component
public class OboUsecase {
@Autowired
private MessageService messageService;
@Autowired
private OboAuthenticator oboAuthenticator;
public void doStuff() {
final AuthSession oboSession = oboAuthenticator.authenticateByUsername("user.name");
final V4Message message = messageService.obo(oboSession).send("stream.id", "Hello from OBO"); // works
messageService.send("stream.id", "Hello world"); // fails with an IllegalStateException
}
}
Any attempt to use a non-OBO service endpoint will fail with an IllegalStateException.
Subscribe to Real Time Events
The Core Starter uses Spring Events to deliver Real Time Events.
You can subscribe to any Real Time Event from anywhere in your application by creating a handler method that has to respect two conditions:
- be annotated with @EventListener
- have
com.symphony.bdk.spring.events.RealTimeEvent<? extends T>
parameter
The listener methods will be called with events from the datafeed loop or the datahose loop (or both) depending on your configuration:
bdk:
datafeed:
enabled: true # optional, defaults to true
datahose:
enabled: true # optional, defaults to false
If both datafeed and datahose are enabled, application will fail at startup. So please make sure datafeed is disabled when using datahose.
Here’s the list of Real Time Events you can subscribe:
@Component
public class RealTimeEvents {
@EventListener
public void onMessageSent(RealTimeEvent<? extends V4MessageSent> event) {}
@EventListener
public void onSharedPost(RealTimeEvent<? extends V4SharedPost> event) {}
@EventListener
public void onInstantMessageCreated(RealTimeEvent<? extends V4InstantMessageCreated> event) {}
@EventListener
public void onRoomCreated(RealTimeEvent<? extends V4RoomCreated> event) {}
@EventListener
public void onRoomUpdated(RealTimeEvent<? extends V4RoomUpdated> event) {}
@EventListener
public void onRoomDeactivated(RealTimeEvent<? extends V4RoomDeactivated> event) {}
@EventListener
public void onRoomReactivated(RealTimeEvent<? extends V4RoomReactivated> event) {}
@EventListener
public void onUserRequestedToJoinRoom(RealTimeEvent<? extends V4UserRequestedToJoinRoom> event) {}
@EventListener
public void onUserJoinedRoom(RealTimeEvent<? extends V4UserJoinedRoom> event) {}
@EventListener
public void onUserLeftRoom(RealTimeEvent<? extends V4UserLeftRoom> event) {}
@EventListener
public void onRoomMemberPromotedToOwner(RealTimeEvent<? extends V4RoomMemberPromotedToOwner> event) {}
@EventListener
public void onRoomMemberDemotedFromOwner(RealTimeEvent<? extends V4RoomMemberDemotedFromOwner> event) {}
@EventListener
public void onConnectionRequested(RealTimeEvent<? extends V4ConnectionRequested> event) {}
@EventListener
public void onConnectionAccepted(RealTimeEvent<? extends V4ConnectionAccepted> event) {}
@EventListener
public void onMessageSuppressed(RealTimeEvent<? extends V4MessageSuppressed> event) {}
@EventListener
public void onSymphonyElementsAction(RealTimeEvent<? extends V4SymphonyElementsAction> event) {}
}
By default, the RealTimeEvents are going to be processed asynchronously in the listeners, in case this is not the preferred behavior, one can deactivate it by updating the application.yaml file as
bdk:
datafeed:
event:
async: false # optional, defaults to true
The same applies for bdk.datahose
configuration.
Inject Services
The Core Starter injects services within the Spring application context:
@Service
public class CoreServices {
@Autowired
private MessageService messageService;
@Autowired
private StreamService streamService;
@Autowired
private UserService userService;
@Autowired
private DatafeedService datafeedService;
@Autowired
private SessionService sessionService;
@Autowired
private ActivityRegistry activityRegistry;
}
Unlike subscribing to real time events, using slash commands or activities, solely injecting services does not require the datafeed loop to run. If you want to disable the datafeed loop, you can update your application.yaml
file as follows:
bdk:
datafeed:
enabled: false
Disabling the datafeed loop will prevent the use of real time event listeners, of slash commands and activities.
Slash Command
You can easily register a slash command using the @Slash
annotation. Note that the CommandContext
is mandatory to successfully register your command. If not defined, a warn
message will appear in your application log. Note also that only beans with scope singleton will be scanned.
@Component
public class SlashHello {
@Slash("/hello")
public void onHello(CommandContext commandContext) {
log.info("On /hello command sent at {}", commandContext.getEventTimestamp());
}
@Slash(value = "/hello", mentionBot = false)
public void onHelloNoMention(CommandContext commandContext) {
log.info("On /hello command (bot has not been mentioned)");
}
}
By default, the @Slash
annotation is configured to require bot mention in order to trigger the command. You can override this value using @Slash#mentionBot
annotation parameter.
You can also use slash commands with arguments. To do so, the field value
of the @Slash
annotation must have a valid format as explained in the Activity API section. If the slash command pattern is valid, you will have to specify all slash arguments as method parameter with the same name and type. If slash command pattern or method signature is incorrect, a warn
message will appear in your application log and the slash command will not be registered. Note that the event timestamp is accessible from the commandContext
using getEventTimestamp()
method.
For instance:
@Component
public class SlashHello {
@Slash("/hello {arg") // will not be registered: invalid pattern
public void onHelloInvalidPattern(CommandContext commandContext, String arg) {
log.info("On /hello command");
}
@Slash("/hello {arg1}{arg2}") // will not be registered: invalid pattern
public void onHelloInvalidPatternTwoArgs(CommandContext commandContext, String arg1, String arg2) {
log.info("On /hello command");
}
@Slash("/hello {arg1} {arg2}") // will be registered: valid pattern and valid signature
public void onHelloValidPatternTwoArgs(CommandContext commandContext, String arg1, String arg2) {
log.info("On /hello command");
}
@Slash("/hello {arg1} {arg2}") // will not be registered: valid pattern but missing argument
public void onHelloValidPatternTwoArgs(CommandContext commandContext, String arg1) {
log.info("On /hello command");
}
@Slash("/hello {arg1} {@arg2}") // will not be registered: valid pattern but mismatching type for arg2
public void onHelloValidPatternTwoArgs(CommandContext commandContext, String arg1, String arg2) {
log.info("On /hello command");
}
@Slash("/hello {arg1} {@arg2} {#arg3} {$arg4}") // will be registered: valid pattern and correct signature
public void onHelloValidPatternTwoArgs(CommandContext commandContext, String arg1, Mention arg2, Hashtag arg3, Cashtag arg4) {
log.info("On /hello command");
}
}
Slash commands are not registered to the datahose loop even when enabled.
Asynchronous slash Command
By default, @Slash
annotation is configured to be synchronous. If the process takes time, the next incoming commands will be blocked and enqueued till the process is released. If this is a concern, Slash command can be configured to be asynchronous by setting the async
option to the annotation.
@Slf4j
@Component
public class AsyncActivity {
@Autowired
private MessageService messageService;
@Slash(value = "/async", asynchronous = true)
public void async(CommandContext context) throws InterruptedException {
this.messageService.send(context.getStreamId(),
"I will simulate a heavy process that takes time but this should not block next commands");
sleep(30000);
this.messageService.send(context.getStreamId(), "Heavy async process is done");
}
}
Activities
For more details about activities, please read the Activity API reference documentation
Any service or component class that extends FormReplyActivity
or CommandActivity
will be automatically registered within the ActivityRegistry.
Activities are not registered to the datahose loop even when enabled.
Example of a CommandActivity
in Spring Boot
The following example has been described in section Activity API documentation. Note here that with Spring Boot you simply have to annotate your CommandActivity
class with @Component
to make it automatically registered in the ActivityRegistry
,
@Slf4j
@Component
public class HelloCommandActivity extends CommandActivity<CommandContext> {
@Override
protected ActivityMatcher<CommandContext> matcher() {
return c -> c.getTextContent().contains("hello");
}
@Override
protected void onActivity(CommandContext context) {
log.info("Hello command triggered by user {}", context.getInitiator().getUser().getDisplayName());
}
@Override
protected ActivityInfo info() {
return new ActivityInfo().type(ActivityType.COMMAND).name("Hello Command");
}
}
Example of a FormReplyActivity
in Spring Boot
The following example demonstrates how to send an Elements form on @BotMention /gif
slash command. The Elements form located in src/main/resources/templates/gif.ftl
contains:
<messageML>
<h2>Gif Generator</h2>
<form id="gif-category-form">
<text-field name="category" placeholder="Enter a Gif category..."/>
<button name="submit" type="action">Submit</button>
<button type="reset">Reset Data</button>
</form>
</messageML>
@Slf4j
@Component
public class GifFormActivity extends FormReplyActivity<FormReplyContext> {
@Autowired
private MessageService messageService;
@Slash("/gif")
public void displayGifForm(CommandContext context) throws TemplateException {
final Template template = bdk.messages().templates().newTemplateFromClasspath("/templates/gif.ftl");
this.messageService.send(context.getStreamId(), Message.builder().template(template, Collections.emptyMap()));
}
@Override
public ActivityMatcher<FormReplyContext> matcher() {
return context -> "gif-category-form".equals(context.getFormId())
&& "submit".equals(context.getFormValue("action"))
&& StringUtils.isNotEmpty(context.getFormValue("category"));
}
@Override
public void onActivity(FormReplyContext context) {
log.info("Gif category is \"{}\"", context.getFormValue("category"));
}
@Override
protected ActivityInfo info() {
return new ActivityInfo().type(ActivityType.FORM)
.name("Gif Display category form command")
.description("\"Form handler for the Gif Category form\"");
}
}
Integration Test
You can then create the integration test to guarantee the Bot application is working as design, like you can see in the example below. For more details, please refer to test module.
@SymphonyBdkSpringBootTest(properties = {"bot.id=1", "bot.username=my-bot", "bot.display-name=my bot"})
public class SimpleSpringAppIntegrationTest {
private final V4User initiator = new V4User().displayName("user").userId(2L);
private final V4Stream stream = new V4Stream().streamId("my-room");
@Test
void echo_command_replyWithMessage(@Autowired MessageService messageService, @Autowired UserV2 botInfo) {
// (1) given
when(messageService.send(anyString(), any(Message.class))).thenReturn(mock(V4Message.class));
// (2) when
pushMessageToDF(initiator, stream, "/echo arg", botInfo);
// (3) then
verify(messageService).send(eq("my-room"), eq("Received argument: arg"));
}
}