EzyOA cho phép plugin khác cài đặt một kịch bản tùy chỉnh để xử lý tin nhắn văn bản nhận từ webhook. Plugin chỉ cần hiện thực OATextMessageResponseScenario và đăng ký implementation dưới dạng singleton trong đúng runtime.
Khi webhook đến, EzyOA vẫn đảm nhiệm việc xác thực, nhận diện dịch vụ OA, tìm người gửi, lưu hội thoại và gửi phản hồi. Kịch bản của plugin chỉ chịu trách nhiệm tạo OAResponseMessageModel.

Luồng xử lý

sequenceDiagram
    participant U as Người dùng
    participant P as Nền tảng OA
    participant O as EzyOA
    participant S as Kịch bản của plugin

    U->>P: Gửi tin nhắn
    P->>O: Gọi webhook
    O->>O: Xác thực và chuẩn hóa payload
    O->>O: Tìm hoặc tạo OA user
    O->>O: Tạo kênh hội thoại
    O->>S: makeResponseMessage(...)
    S-->>O: OAResponseMessageModel
    O->>O: Lưu tin nhắn đến
    O->>P: Gửi nội dung phản hồi
    P-->>U: Hiển thị phản hồi
    O->>O: Lưu tin nhắn trả lời nếu được bật
Kịch bản chỉ được gọi khi:
  • Webhook được xác định là sự kiện người dùng gửi tin nhắn.
  • Tin nhắn có nội dung văn bản hợp lệ.
  • EzyOA đang bật phản hồi tự động.
  • Dịch vụ OA cho phép phản hồi webhook đó.
  • Tên kịch bản đang chọn khớp với getName() của một scenario đã đăng ký.

Interface kịch bản

Plugin cần hiện thực interface:
import org.youngmonkeys.ezyoa.model.OAReceivedMessageModel;
import org.youngmonkeys.ezyoa.model.OAResponseMessageModel;
import org.youngmonkeys.ezyoa.scenario.OATextMessageResponseScenario;

public class GreetingMessageResponseScenario
    implements OATextMessageResponseScenario {

    @Override
    public OAResponseMessageModel makeResponseMessage(
        String serviceCode,
        OAReceivedMessageModel receivedMessage
    ) throws Exception {
        return OAResponseMessageModel.builder()
            .message("Xin chào! Chúng tôi có thể giúp gì cho bạn?")
            .build();
    }

    @Override
    public String getName() {
        return "greeting";
    }
}
getName() trả về mã định danh của kịch bản. Giá trị này:
  • Phải ổn định giữa các lần triển khai.
  • Không nên chứa thông tin dành riêng cho một người dùng.
  • Phải trùng với kịch bản được chọn trong phần cài đặt EzyOA.
  • Không nên trùng với tên một kịch bản khác.

Dữ liệu tin nhắn nhận được

OAReceivedMessageModel cung cấp các thông tin đã được EzyOA chuẩn hóa:
Thuộc tínhKiểuÝ nghĩa
senderOaUserIdTextStringID người gửi do nền tảng OA cấp
senderOaUserIdlongID bản ghi OA user trong EzyOA
senderUserIdlongID người dùng EzyPlatform đã liên kết, có thể bằng 0
textMessageStringNội dung văn bản nhận được
channelIdlongID kênh hội thoại trong hệ thống
Ví dụ đọc dữ liệu:
@Override
public OAResponseMessageModel makeResponseMessage(
    String serviceCode,
    OAReceivedMessageModel receivedMessage
) {
    String textMessage = receivedMessage.getTextMessage();
    String oaRecipientId =
        receivedMessage.getSenderOaUserIdText();
    long userId = receivedMessage.getSenderUserId();
    long channelId = receivedMessage.getChannelId();

    // Xử lý nghiệp vụ và tạo phản hồi.
}
Không nên nhầm:
  • senderOaUserIdText là ID bên nền tảng OA.
  • senderOaUserId là khóa nội bộ của bản ghi OA user.
  • senderUserId là ID tài khoản EzyPlatform đã liên kết.

Tạo kịch bản trong module dùng chung

Nếu plugin có cả admin plugin và web plugin, nên đặt logic chung trong SDK của plugin:
package com.example.notification.scenario;

import org.youngmonkeys.ezyoa.model.OAReceivedMessageModel;
import org.youngmonkeys.ezyoa.model.OAResponseMessageModel;
import org.youngmonkeys.ezyoa.scenario.OATextMessageResponseScenario;

public class OrderMessageResponseScenario
    implements OATextMessageResponseScenario {

    public static final String NAME = "order_support";

    @Override
    public OAResponseMessageModel makeResponseMessage(
        String serviceCode,
        OAReceivedMessageModel receivedMessage
    ) throws Exception {
        String message = receivedMessage
            .getTextMessage()
            .trim();

        if ("tra cứu đơn hàng".equalsIgnoreCase(message)) {
            return OAResponseMessageModel.builder()
                .templateName("order_lookup_requested")
                .message(
                    "Vui lòng gửi mã đơn hàng cần tra cứu."
                )
                .build();
        }

        return OAResponseMessageModel.builder()
            .templateName("order_support_default")
            .message(
                "Bạn có thể nhập “tra cứu đơn hàng” để bắt đầu."
            )
            .build();
    }

    @Override
    public String getName() {
        return NAME;
    }
}
templateName không tự động dịch template thành nội dung. Trong mô hình phản hồi này, plugin đã phải cung cấp message hoàn chỉnh. templateName chủ yếu được EzyOA lưu cùng lịch sử để nhận biết loại phản hồi đã sử dụng.

Đăng ký trong web runtime

Webhook được xử lý tại web runtime. Vì vậy, kịch bản bắt buộc phải được đăng ký trong web plugin.
Tạo một lớp con và đánh dấu bằng @EzySingleton:
package com.example.notification.web.scenario;

import com.example.notification.scenario
    .OrderMessageResponseScenario;
import com.tvd12.ezyfox.bean.annotation.EzySingleton;

@EzySingleton
public class WebOrderMessageResponseScenario
    extends OrderMessageResponseScenario {}
Khi web plugin khởi động, lớp này được đưa vào singleton factory. EzyOA sẽ tự tìm tất cả singleton triển khai OATextMessageResponseScenario và lập chỉ mục theo tên scenario.
Đây là cách đăng ký được khuyến nghị. Plugin không cần gọi event và cũng không cần sửa mã nguồn EzyOA.

Đăng ký trong admin runtime

Để kịch bản xuất hiện trong giao diện cấu hình của EzyOA, nên đăng ký cùng một scenario trong admin plugin:
package com.example.notification.admin.scenario;

import com.example.notification.scenario
    .OrderMessageResponseScenario;
import com.tvd12.ezyfox.bean.annotation.EzySingleton;

@EzySingleton
public class AdminOrderMessageResponseScenario
    extends OrderMessageResponseScenario {}
Admin runtime sử dụng scenario để:
  • Liệt kê các kịch bản có thể chọn.
  • Lấy mẫu tham số cấu hình.
  • Hỗ trợ kiểm tra phản hồi trong trang quản trị.
Web runtime mới là nơi thực sự xử lý webhook. Vì vậy:
  • Chỉ có bản admin: scenario xuất hiện trong cấu hình nhưng webhook không thực thi được.
  • Chỉ có bản web: webhook có thể thực thi nếu tên được cấu hình thủ công, nhưng scenario có thể không xuất hiện trong giao diện quản trị.
  • Có cả hai: đầy đủ chức năng cấu hình và xử lý.
Hai implementation phải trả về cùng một giá trị từ getName().

Đăng ký trực tiếp bằng manager

Ngoài cơ chế singleton, plugin có thể đăng ký trực tiếp bằng service manager.
Trong web plugin:
import com.tvd12.ezyfox.bean.EzyBeanConfig;
import com.tvd12.ezyfox.bean.annotation.EzyConfigurationAfter;
import lombok.AllArgsConstructor;
import org.youngmonkeys.ezyoa.web.manager
    .WebOATextMessageResponseScenarioManager;

@EzyConfigurationAfter
@AllArgsConstructor
public class WebOaScenarioConfig implements EzyBeanConfig {

    private final WebOATextMessageResponseScenarioManager
        scenarioManager;

    @Override
    public void config() {
        scenarioManager.addOaMessageResponseScenario(
            new OrderMessageResponseScenario()
        );
    }
}
Trong admin plugin:
import com.tvd12.ezyfox.bean.EzyBeanConfig;
import com.tvd12.ezyfox.bean.annotation.EzyConfigurationAfter;
import lombok.AllArgsConstructor;
import org.youngmonkeys.ezyoa.admin.manager
    .AdminOATextMessageResponseScenarioManager;

@EzyConfigurationAfter
@AllArgsConstructor
public class AdminOaScenarioConfig implements EzyBeanConfig {

    private final AdminOATextMessageResponseScenarioManager
        scenarioManager;

    @Override
    public void config() {
        scenarioManager.addOaMessageResponseScenario(
            new OrderMessageResponseScenario()
        );
    }
}
Cách đăng ký trực tiếp hữu ích khi:
  • Scenario được tạo theo cấu hình động.
  • Plugin cần chọn implementation lúc khởi động.
  • Scenario không thể được khai báo trực tiếp thành singleton.
Nếu scenario cần dependency injection, đăng ký bằng @EzySingleton thường đơn giản và an toàn hơn so với tự gọi constructor.
Khi hai scenario có cùng tên, scenario được thêm sau sẽ thay thế scenario trước trong manager. Vì vậy tên scenario cần có tính duy nhất.

Inject service nghiệp vụ vào scenario

Scenario có thể sử dụng các service của plugin để tra cứu dữ liệu và tạo câu trả lời.
import lombok.AllArgsConstructor;
import org.youngmonkeys.ezyoa.model.OAReceivedMessageModel;
import org.youngmonkeys.ezyoa.model.OAResponseMessageModel;
import org.youngmonkeys.ezyoa.scenario.OATextMessageResponseScenario;

@AllArgsConstructor
public class OrderMessageResponseScenario
    implements OATextMessageResponseScenario {

    private final OrderService orderService;

    @Override
    public OAResponseMessageModel makeResponseMessage(
        String serviceCode,
        OAReceivedMessageModel receivedMessage
    ) {
        String orderCode = receivedMessage
            .getTextMessage()
            .trim();

        OrderModel order = orderService
            .getOrderByCode(orderCode);

        if (order == null) {
            return OAResponseMessageModel.builder()
                .templateName("order_not_found")
                .message(
                    "Không tìm thấy đơn hàng " + orderCode + "."
                )
                .build();
        }

        return OAResponseMessageModel.builder()
            .templateName("order_status")
            .message(
                "Đơn hàng " + order.getCode()
                    + " đang ở trạng thái "
                    + order.getDisplayStatus() + "."
            )
            .build();
    }

    @Override
    public String getName() {
        return "order_status";
    }
}
Sau đó khai báo implementation riêng cho từng runtime:
@EzySingleton
public class WebOrderMessageResponseScenario
    extends OrderMessageResponseScenario {

    public WebOrderMessageResponseScenario(
        WebOrderService orderService
    ) {
        super(orderService);
    }
}
@EzySingleton
public class AdminOrderMessageResponseScenario
    extends OrderMessageResponseScenario {

    public AdminOrderMessageResponseScenario(
        AdminOrderService orderService
    ) {
        super(orderService);
    }
}
Không nên gọi API chậm hoặc thực hiện tác vụ nền kéo dài ngay trong scenario. Scenario đang nằm trên đường xử lý webhook; thời gian tạo phản hồi quá lâu có thể làm nhà cung cấp OA hết thời gian chờ và gửi lại webhook.

Phản hồi theo từng dịch vụ OA

Một scenario có thể được dùng chung cho nhiều dịch vụ. Tham số serviceCode cho biết webhook thuộc dịch vụ nào:
@Override
public OAResponseMessageModel makeResponseMessage(
    String serviceCode,
    OAReceivedMessageModel receivedMessage
) {
    String response;

    switch (serviceCode.toLowerCase()) {
        case "zalo":
            response = "Cảm ơn bạn đã liên hệ Zalo OA.";
            break;

        case "telegram":
            response = "Cảm ơn bạn đã liên hệ Telegram Bot.";
            break;

        default:
            response = "Cảm ơn bạn đã liên hệ.";
    }

    return OAResponseMessageModel.builder()
        .message(response)
        .build();
}
Nếu không thực sự cần khác biệt giữa các nền tảng, nên giữ logic chung để giảm số nhánh phải bảo trì.

Không gửi phản hồi

Trả về null khi plugin muốn EzyOA tiếp nhận và lưu tin nhắn nhưng không gửi câu trả lời:
@Override
public OAResponseMessageModel makeResponseMessage(
    String serviceCode,
    OAReceivedMessageModel receivedMessage
) {
    String message = receivedMessage.getTextMessage();

    if (!message.startsWith("/")) {
        return null;
    }

    return OAResponseMessageModel.builder()
        .message("Đã nhận lệnh: " + message)
        .build();
}
Nếu trả về một model không có cả nội dung lẫn media, EzyOA cũng không gửi phản hồi:
return OAResponseMessageModel.builder().build();
Trả về null thể hiện ý định bỏ qua rõ ràng hơn.

Gửi phản hồi kèm media

Scenario có thể trả về nội dung cùng danh sách media:
import org.youngmonkeys.ezyoa.model.OAResponseMediaModel;
import org.youngmonkeys.ezyoa.model.OAResponseMessageModel;

import java.util.Collections;

return OAResponseMessageModel.builder()
    .templateName("payment_qr")
    .message("Bạn có thể thanh toán bằng mã QR sau:")
    .medias(
        Collections.singletonList(
            OAResponseMediaModel.builder()
                .type("image")
                .fileName("payment-qr.png")
                .filePath("images/payment-qr.png")
                .build()
        )
    )
    .build();
Một media có thể chứa:
Thuộc tínhÝ nghĩa
typeLoại media, chẳng hạn image
urlURL của media
fileNameTên file
filePathĐường dẫn file trong hệ thống
EzyOA chuyển các thuộc tính này sang OAService.sendMessage. Việc trường nào được hỗ trợ phụ thuộc vào từng dịch vụ OA.
Nếu danh sách media không rỗng, EzyOA gọi phiên bản sendMessage có media, kể cả khi nội dung văn bản rỗng.

Khai báo mẫu tham số cấu hình

Scenario có thể cung cấp mẫu tham số bằng getParametersTemplate():
import java.util.LinkedHashMap;
import java.util.Map;

@Override
public Map<String, Object> getParametersTemplate() {
    Map<String, Object> template = new LinkedHashMap<>();
    template.put("welcomeMessage", "");
    template.put("supportUrl", "");
    return template;
}
Admin runtime sử dụng map này để tạo cấu trúc tham số ban đầu khi quản trị viên chọn scenario.
Đây chỉ là mẫu cấu hình, không phải giá trị cấu hình thực tế của từng OA. Nếu scenario cần đọc giá trị đã lưu cho một dịch vụ, nó phải sử dụng service cấu hình OA với serviceCode hiện tại.
Không nên đặt secret, access token hoặc API key vào mẫu tham số scenario.

Chọn kịch bản trong EzyOA

Sau khi cài plugin:
  1. Mở phần cài đặt EzyOA.
  2. Bật chức năng phản hồi tự động.
  3. Mở phần cấu hình kịch bản.
  4. Chọn tên scenario do plugin cung cấp.
  5. Nếu scenario có tham số, cấu hình tham số cho từng dịch vụ OA.
  6. Lưu thay đổi.
  7. Gửi một tin nhắn thật đến OA để kiểm tra.
Tên hiển thị trong danh sách chính là giá trị do getName() trả về.
Lựa chọn kịch bản phản hồi văn bản là cấu hình chung. Nếu cùng một scenario phục vụ nhiều OA, scenario có thể dùng serviceCode để đọc cấu hình riêng của từng dịch vụ.

Cách EzyOA sử dụng kết quả

Sau khi scenario trả về OAResponseMessageModel, EzyOA xử lý như sau:
  • Lấy templateName, message và medias.
  • Lưu tin nhắn webhook vào lịch sử.
  • Nếu có nội dung văn bản và không có media, gọi OAService.sendMessage(recipientId, message).
  • Nếu có media, gọi overload gửi tin có media.
  • Nếu gửi thành công và tính năng lưu phản hồi đang bật, lưu câu trả lời vào cùng kênh hội thoại.
  • Nếu scenario trả về null, chỉ lưu tin nhắn đến và không gửi phản hồi.
EzyOA gửi phản hồi bằng chính OAService đã tiếp nhận webhook. Plugin không cần tự lấy service hoặc tự gọi sendMessage bên trong scenario.
Việc tự gửi trong makeResponseMessage rồi vẫn trả về một response có thể làm người dùng nhận hai tin nhắn.

Xử lý exception

makeResponseMessage cho phép ném Exception:
@Override
public OAResponseMessageModel makeResponseMessage(
    String serviceCode,
    OAReceivedMessageModel receivedMessage
) throws Exception {
    return externalService.createResponse(
        receivedMessage.getTextMessage()
    );
}
Nếu exception thoát khỏi scenario, luồng xử lý webhook có thể thất bại. Tùy nền tảng, webhook có thể được gửi lại và gây xử lý lặp.
Với lỗi có thể phục hồi, nên xử lý ngay trong scenario:
@Override
public OAResponseMessageModel makeResponseMessage(
    String serviceCode,
    OAReceivedMessageModel receivedMessage
) {
    try {
        return responseService.makeResponse(
            receivedMessage.getTextMessage()
        );
    } catch (Exception e) {
        logger.warn(
            "Không thể tạo phản hồi OA, service: {}",
            serviceCode,
            e
        );

        return OAResponseMessageModel.builder()
            .message(
                "Hệ thống đang bận. Vui lòng thử lại sau."
            )
            .build();
    }
}
Không nên bắt mọi lỗi rồi âm thầm trả về null nếu lỗi đó cần được theo dõi vận hành.

Kiểm tra tích hợp

Nên kiểm tra các trường hợp sau:
  • Scenario xuất hiện trong giao diện quản trị.
  • Scenario đã được chọn và lưu thành công.
  • Phản hồi tự động đang bật.
  • Tin nhắn văn bản tạo đúng câu trả lời.
  • Tin nhắn không khớp điều kiện không tạo phản hồi.
  • serviceCode đúng với OA nhận webhook.
  • OA user chưa liên kết vẫn được xử lý an toàn.
  • Media được gửi đúng trên từng nền tảng.
  • Tin nhắn đến và phản hồi được lưu đúng kênh.
  • Exception không làm phát sinh vòng lặp retry ngoài ý muốn.
  • Cùng một webhook không gây tác dụng phụ nghiệp vụ lặp lại.

Giới hạn cần lưu ý

Kịch bản phản hồi văn bản mặc định có các giới hạn:
  • Chỉ chạy cho webhook có nội dung văn bản hợp lệ.
  • Media đến có thể được lưu nhưng không được truyền vào OAReceivedMessageModel.
  • Chỉ một tên scenario phản hồi văn bản được chọn tại một thời điểm.
  • Scenario chạy đồng bộ trong luồng xử lý webhook.
  • Scenario không tự chống xử lý trùng webhook.
  • EzyOA không tự giới hạn thời gian thực thi logic của plugin.
  • templateName chỉ là metadata nếu plugin tự tạo nội dung phản hồi.
  • Khả năng gửi media phụ thuộc vào từng OAService.
Kịch bản nên tập trung vào việc quyết định phản hồi và tạo dữ liệu trả về. Những tác vụ lâu, gửi email, đồng bộ dữ liệu lớn hoặc xử lý nền nên được chuyển sang một worker hoặc hàng đợi riêng.