Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,36 @@ It works seamlessly across AEM on-premise, AMS, and AEMaaCS environments.

## Table of Contents

- [Key Features](#key-features)
- [AEM Content Manager (ACM)](#aem-content-manager-acm)
- [Table of Contents](#table-of-contents)
- [Key Features](#key-features)
- [All-in-one Solution](#all-in-one-solution)
- [New Approach](#new-approach)
- [Content Management](#content-management)
- [Permissions Management](#permissions-management)
- [Data Imports & Exports](#data-imports--exports)
- [Installation](#installation)
- [Compatibility](#compatibility)
- [Documentation](#documentation)
- [Data Imports \& Exports](#data-imports--exports)
- [Installation](#installation)
- [Compatibility](#compatibility)
- [Documentation](#documentation)
- [Usage](#usage)
- [Console](#console)
- [Content Scripts](#content-scripts)
- [Minimal Example](#minimal-example)
- [Arguments Example](#arguments-example)
- [ACL Example](#acl-example)
- [Repo Example](#repo-example)
- [History](#history)
- [Extension Scripts](#extension-scripts)
- [Content scripts](#content-scripts)
- [Minimal example](#minimal-example)
- [Arguments example](#arguments-example)
- [ACL example](#acl-example)
- [Repo example](#repo-example)
- [History](#history)
- [Extension scripts](#extension-scripts)
- [Example extension script](#example-extension-script)
- [Snippets](#snippets)
- [Example snippet](#example-snippet)
- [Mocks](#mocks)
- [Development](#development)
- [Authors](#authors)
- [Contributing](#contributing)
- [License](#license)
- [Notifications](#notifications)
- [Development](#development)
- [Releasing](#releasing)
- [Authors](#authors)
- [Contributing](#contributing)
- [License](#license)

## Key Features

Expand Down Expand Up @@ -403,6 +409,29 @@ This feature is disabled by default, but you can enable it in the [OSGi configur
<img src="docs/screenshot-scripts-mock-tab.png" width="720" alt="ACM Mocks - List">
<img src="docs/screenshot-scripts-mock-code.png" width="720" alt="ACM Mocks - Code">

### Notifications

ACM offers a flexible notification service supporting multiple channels, including Slack and Microsoft Teams, with no additional coding required.

To receive notifications about automatic code executions, simply configure a notifier with a unique ID (`acm`) in the OSGi configuration.

For Slack integration, create a file at *ui.config/src/main/content/jcr_root/apps/{project}/osgiconfig/config/dev.vml.es.acm.core.notification.slack.SlackFactory.config* with the following content:

```ini
enabled=B"true"
id="acm"
webhookUrl="https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYYYY/ZZZZZZZZZZZZZZZZZZZZZZZZ"
timeoutMillis=I"5000"
```
To customize notifications triggered by the [executor service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Executor.java#L32), use its OSGi configuration. This allows you to control which script executions should trigger notifications and which should be excluded, providing fine-grained management over notification behavior.

The notification service is a general-purpose feature that can be used for any kind of messaging, not just notifications related to ACM code execution. You can also define multiple notifiers with different IDs to target various channels or teams. In your Groovy scripts or project-specific OSGi bundles, use the `notifier` [service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/notification/NotificationManager.java) to send messages to a specific notifier or the default one:

```groovy
notifier.sendMessageTo("acme", "ACME Project Notifications", "An important event occurred.")
notifier.sendMessage("ACME Project Notifications", "Let's start the day with a coffee!") // uses the 'default' notifier
```

## Development

1. All-in-one command (incremental building and deployment of 'all' distribution, both backend & frontend)
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class AcmConstants {

public static final String CODE = "acm";

public static final String NOTIFIER_ID = "acm";

public static final String SETTINGS_ROOT = "/conf/acm/settings";

public static final String VAR_ROOT = "/var/acm";
Expand Down
19 changes: 11 additions & 8 deletions core/src/main/java/dev/vml/es/acm/core/code/Executor.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import dev.vml.es.acm.core.notification.NotificationManager;
import dev.vml.es.acm.core.osgi.InstanceInfo;
import dev.vml.es.acm.core.osgi.OsgiContext;
import dev.vml.es.acm.core.util.DateUtils;
import dev.vml.es.acm.core.util.ResolverUtils;
import dev.vml.es.acm.core.util.StringUtil;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;
Expand Down Expand Up @@ -66,6 +66,9 @@ public class Executor {
description = "Enables notifications for completed executions.")
boolean notificationEnabled() default true;

@AttributeDefinition(name = "Notification Notifier ID")
String notificationNotifierId() default AcmConstants.NOTIFIER_ID;

@AttributeDefinition(
name = "Notification Executable IDs",
description = "Allow to control with regular expressions which executables should be notified about.")
Expand Down Expand Up @@ -214,7 +217,7 @@ private void handleHistory(ExecutionContext context, ImmediateExecution executio
private void handleNotifications(ExecutionContext context, ImmediateExecution execution) {
String executableId = execution.getExecutable().getId();
if (!config.notificationEnabled()
|| !notifier.isConfigured()
|| !notifier.isConfigured(config.notificationNotifierId())
|| Arrays.stream(config.notificationExecutableIds())
.noneMatch(regex -> Pattern.matches(regex, executableId))) {
return;
Expand All @@ -236,8 +239,8 @@ private void handleNotifications(ExecutionContext context, ImmediateExecution ex

Map<String, Object> fields = new LinkedHashMap<>();
fields.put("Status", execution.getStatus().name().toLowerCase());
fields.put("Time", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
fields.put("Duration", execution.getDuration() + "ms");
fields.put("Time", DateUtils.humanFormat().format(new Date()));
fields.put("Duration", StringUtil.formatDuration(execution.getDuration()));

InstanceInfo instanceInfo = context.getCodeContext().getOsgiContext().getInstanceInfo();
InstanceSettings instanceSettings = new InstanceSettings(instanceInfo);
Expand All @@ -249,12 +252,12 @@ private void handleNotifications(ExecutionContext context, ImmediateExecution ex
fields.put("Instance", instanceDesc);

int detailsMaxLength = config.notificationDetailsLength();
String output = StringUtils.defaultIfBlank(execution.getOutput(), "(empty)");
String error = StringUtils.defaultIfBlank(execution.getError(), "(empty)");
fields.put("Output", detailsMaxLength < 0 ? output : StringUtil.abbreviateStart(output, detailsMaxLength));
String output = StringUtil.markdownCode(execution.getOutput(), "(none)");
String error = StringUtil.markdownCode(execution.getError(), "(none)");
fields.put("Output", detailsMaxLength < 0 ? output : StringUtil.abbreviateStart(output, detailsMaxLength, "[...] "));
fields.put("Error", detailsMaxLength < 0 ? error : StringUtils.abbreviate(error, detailsMaxLength));

notifier.sendMessage(title, text, fields);
notifier.sendMessageTo(config.notificationNotifierId(), title, text, fields);
}

public Description describe(ExecutionContext context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,56 +33,55 @@ public class NotificationManager {
service = SlackFactory.class)
private final Collection<SlackFactory> slackFactories = new CopyOnWriteArrayList<>();

// === Multi-notifier ===

public boolean isConfigured() {
return hasAnyDefaultNotifier();
return isConfigured(NotifierFactory.ID_DEFAULT);
}

public boolean isConfigured(String notifierId) {
return isSlackConfigured(notifierId) || isTeamsConfigured(notifierId);
}

public void sendMessage(String text) {
sendMessage(null, text, Collections.emptyMap());
sendMessageTo(NotifierFactory.ID_DEFAULT, text);
}

public void sendMessage(String title, String text) {
sendMessage(title, text, Collections.emptyMap());
sendMessageTo(NotifierFactory.ID_DEFAULT, title, text);
}

public void sendMessage(String title, String text, Map<String, Object> fields) {
if (!hasAnyDefaultNotifier()) {
throw new NotificationException(
String.format("Notifier '%s' (Slack or Teams) not configured!", NotifierFactory.ID_DEFAULT));
}
findSlackDefault()
.ifPresent(slack ->
slack.sendPayload(buildSlackMessage(title, text, fields).build()));
findTeamsDefault()
.ifPresent(teams ->
teams.sendPayload(buildTeamsMessage(title, text, fields).build()));
sendMessageTo(NotifierFactory.ID_DEFAULT, title, text, fields);
}

private boolean hasAnyDefaultNotifier() {
return findSlackDefault().isPresent() || findTeamsDefault().isPresent();
public void sendMessageTo(String notifierId, String text) {
sendMessageTo(notifierId, null, text, Collections.emptyMap());
}

// === Teams ===

public boolean isTeamsConfigured() {
return findTeamsDefault().isPresent();
public void sendMessageTo(String notifierId, String title, String text) {
sendMessageTo(notifierId, title, text, Collections.emptyMap());
}

public void sendTeamsMessage(String text) {
sendTeamsMessage(null, text, Collections.emptyMap());
public void sendMessageTo(String notifierId, String title, String text, Map<String, Object> fields) {
Optional<Slack> slackOpt = findSlackById(notifierId);
Optional<Teams> teamsOpt = findTeamsById(notifierId);
if (!slackOpt.isPresent() && !teamsOpt.isPresent()) {
throw new NotificationException(
String.format("Notifier '%s' not configured for Slack or Teams!", notifierId));
}
slackOpt.ifPresent(slack -> slack.sendPayload(
buildSlackPayload().message(title, text, fields).build()));
teamsOpt.ifPresent(teams -> teams.sendPayload(
buildTeamsPayload().message(title, text, fields).build()));
}

public void sendTeamsMessage(String title, String text) {
sendTeamsMessage(title, text, Collections.emptyMap());
// === Teams ===

public boolean isTeamsConfigured() {
return isTeamsConfigured(NotifierFactory.ID_DEFAULT);
}

public void sendTeamsMessage(String title, String text, Map<String, Object> fields) {
Teams teamsDefault = findTeamsDefault()
.orElseThrow(() -> new NotificationException(
String.format("Teams notifier '%s' not configured!", NotifierFactory.ID_DEFAULT)));
teamsDefault.sendPayload(buildTeamsMessage(title, text, fields).build());
public boolean isTeamsConfigured(String notifierId) {
return findTeamsById(notifierId).isPresent();
}

public Stream<Teams> findTeams() {
Expand Down Expand Up @@ -110,43 +109,18 @@ public Teams getTeamsDefault() {
String.format("Teams notifier '%s' not configured!", NotifierFactory.ID_DEFAULT)));
}

public TeamsPayload.Builder buildTeamsMessage(String title, String text, Map<String, Object> fields) {
TeamsPayload.Builder payload = buildTeamsPayload();
if (StringUtils.isNotBlank(title)) {
payload.title(title);
}
if (StringUtils.isNotBlank(text)) {
payload.text(text);
}
if (fields != null && !fields.isEmpty()) {
payload.facts(fields);
}
return payload;
}

public TeamsPayload.Builder buildTeamsPayload() {
return new TeamsPayload.Builder();
}

// ===[ Slack ]===

public boolean isSlackConfigured() {
return findSlackDefault().isPresent();
}

public void sendSlackMessage(String text) {
sendSlackMessage(null, text, Collections.emptyMap());
}

public void sendSlackMessage(String title, String text) {
sendSlackMessage(title, text, Collections.emptyMap());
return isSlackConfigured(NotifierFactory.ID_DEFAULT);
}

public void sendSlackMessage(String title, String text, Map<String, Object> fields) {
Slack slackDefault = findSlackDefault()
.orElseThrow(() -> new NotificationException(
String.format("Slack notifier '%s' not configured!", NotifierFactory.ID_DEFAULT)));
slackDefault.sendPayload(buildSlackMessage(title, text, fields).build());
public boolean isSlackConfigured(String notifierId) {
return findSlackById(notifierId).isPresent();
}

public Stream<Slack> findSlack() {
Expand Down Expand Up @@ -174,23 +148,6 @@ public Slack getSlackDefault() {
String.format("Slack notifier '%s' not configured!", NotifierFactory.ID_DEFAULT)));
}

public SlackPayload.Builder buildSlackMessage(String title, String text, Map<String, Object> fields) {
SlackPayload.Builder payload = buildSlackPayload();
if (StringUtils.isNotBlank(title)) {
payload.header(title);
}
if (StringUtils.isNotBlank(title) && StringUtils.isNotBlank(text)) {
payload.divider();
}
if (StringUtils.isNotBlank(text)) {
payload.sectionMarkdown(text);
}
if (fields != null && !fields.isEmpty()) {
payload.fieldsMarkdown(fields);
}
return payload;
}

public SlackPayload.Builder buildSlackPayload() {
return new SlackPayload.Builder();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.io.Closeable;
import java.io.Serializable;
import java.util.Map;

public interface Notifier<P extends Serializable> extends Closeable {

Expand All @@ -26,4 +27,9 @@ public interface Notifier<P extends Serializable> extends Closeable {
* Send a payload to notification service in structured format.
*/
void sendPayload(P payload);

/**
* Send a message to notification service in structured format.
*/
void sendMessage(String title, String text, Map<String, Object> fields);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import java.util.Optional;
import java.util.function.Supplier;
import org.apache.commons.lang3.StringUtils;
import org.osgi.framework.Constants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -16,18 +15,16 @@ public abstract class NotifierFactory<N extends Notifier<? extends Serializable>

private static final Logger LOG = LoggerFactory.getLogger(NotifierFactory.class);

private static final String PID_DEFAULT = "default";

private String configPid;
private String configId;

private N notifier;

protected void create(Map<String, Object> props, Supplier<N> supplier) {
this.configPid = getConfigPid(props);
this.configId = getConfigId(props);
try {
this.notifier = supplier.get();
} catch (Exception e) {
LOG.error("Cannot create notifier for PID '{}'!", configPid, e);
LOG.error("Cannot create notifier for ID '{}'!", configId, e);
}
}

Expand All @@ -36,16 +33,15 @@ protected void destroy(Map<String, Object> props) {
try {
this.notifier.close();
} catch (IOException e) {
LOG.error("Cannot clean up notifier for PID '{}'!", configPid, e);
LOG.error("Cannot clean up notifier for ID '{}'!", configId, e);
}
this.notifier = null;
this.configPid = null;
this.configId = null;
}
}

private String getConfigPid(Map<String, Object> props) {
String pid = (String) props.getOrDefault(Constants.SERVICE_PID, PID_DEFAULT);
return StringUtils.substringAfter(pid, "~");
private String getConfigId(Map<String, Object> props) {
return StringUtils.defaultIfBlank((String) props.get("id"), ID_DEFAULT);
}

public N getNotifier() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import dev.vml.es.acm.core.notification.Notifier;
import dev.vml.es.acm.core.util.JsonUtils;
import java.io.IOException;
import java.util.Map;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.client.config.RequestConfig;
Expand Down Expand Up @@ -55,6 +57,12 @@ public boolean isEnabled() {
return enabled;
}

@Override
public void sendMessage(String title, String text, Map<String, Object> fields) {
SlackPayload payload = SlackPayload.builder().message(title, text, fields).build();
sendPayload(payload);
}

@Override
public void sendPayload(SlackPayload payload) {
try {
Expand Down
Loading