EzyOA cho phép tích hợp thêm một nền tảng nhắn tin mới bằng cách hiện thực chuẩn OAService. Tuy nhiên, cách được khuyến nghị là kế thừa AbstractOAService để tái sử dụng các nghiệp vụ chung như định tuyến sự kiện, gửi tin hàng loạt, xử lý template, theo dõi kết quả gửi và sinh URL webhook.

Kiến trúc mở rộng

Một dịch vụ OA đóng vai trò adapter giữa EzyOA và API của nhà cung cấp bên ngoài.
flowchart LR
    Provider["Nhà cung cấp OA"] -->|Webhook| Controller["Webhook chung của EzyOA"]
    Controller --> Service["OAService mở rộng"]
    Service --> Listener["OA Listener"]
    Listener --> Business["Chatbot hoặc nghiệp vụ"]
    Business --> Service
    Service -->|HTTP API| Provider
Dịch vụ mở rộng chịu trách nhiệm:
  • Xác thực webhook.
  • Chuẩn hóa loại sự kiện.
  • Tạo dữ liệu webhook giả để kiểm thử.
  • Gửi tin nhắn qua API của nhà cung cấp.
  • Lấy thông tin người dùng OA.
  • Đồng bộ người theo dõi nếu nền tảng hỗ trợ.
  • Xử lý OAuth nếu nền tảng yêu cầu.
  • Sao chép service khi nhiều tài khoản OA cùng dùng một implementation.
Các nghiệp vụ như lưu người dùng, lưu hội thoại, chatbot và xử lý template nên được giao cho các thành phần chung của EzyOA.

Chọn cách triển khai

Có hai cách tạo dịch vụ mới.

Hiện thực trực tiếp OAService

Cách này cho phép kiểm soát hoàn toàn hành vi của service nhưng phải tự hiện thực toàn bộ contract, bao gồm cả các nghiệp vụ EzyOA đã hỗ trợ sẵn.
Chỉ nên chọn cách này khi:
  • Dịch vụ có mô hình vận hành rất khác EzyOA.
  • Không muốn sử dụng cơ chế listener của EzyOA.
  • Có yêu cầu đặc biệt về gửi hàng loạt hoặc template.
  • Cần thay thế hoàn toàn luồng xử lý mặc định.

Kế thừa AbstractOAService

Đây là cách được khuyến nghị:
public class ExampleOAService extends AbstractOAService {

    public static final String SERVICE_CODE = "example";

    public ExampleOAService() {
        this(SERVICE_CODE);
    }

    protected ExampleOAService(String code) {
        super(code);
    }
}
Khi kế thừa lớp cơ sở, service được dùng chung các nghiệp vụ:
  • Quản lý mã dịch vụ.
  • Sinh webhook URL.
  • Sinh OAuth callback URL.
  • Phân phối sự kiện tới listener.
  • Gửi tin nhắn qua transport mở rộng.
  • Gửi tin tới toàn bộ người dùng theo từng lô.
  • Gửi nội dung dựa trên template.
  • Gửi notification dựa trên template.
  • Thử gửi message trước rồi fallback sang notification.
  • Tra cứu người nhận theo tài khoản hoặc số điện thoại.
  • Lưu thời điểm và trạng thái của lần gửi gần nhất.
  • Sao chép các dependency chung sang service mới.
Nhờ đó, implementation mới chủ yếu tập trung vào API và định dạng dữ liệu của nhà cung cấp.

Các phương thức bắt buộc

Một lớp kế thừa AbstractOAService cần hiện thực các nhóm chức năng dưới đây.

Xử lý xác thực OAuth

@Override
public void handleAuthentication(
    Map<String, String> headers,
    Enumeration<String> parameterNames,
    Map<String, String> parameters,
    OAServiceSettingModel setting,
    String secretKey
) throws Exception {
    // Đổi authorization code lấy access token.
    // Kiểm tra state nếu nhà cung cấp sử dụng OAuth.
    // Lưu token thông qua service cấu hình bảo mật.
}
Nếu nền tảng không sử dụng OAuth, có thể trả về lỗi không hỗ trợ:
@Override
public void handleAuthentication(
    Map<String, String> headers,
    Enumeration<String> parameterNames,
    Map<String, String> parameters,
    OAServiceSettingModel setting,
    String secretKey
) {
    throw new UnsupportedOperationException("unsupported");
}

@Override
public String getAuthenticationCallbackUrl() {
    return "";
}
Không nên để callback URL mặc định nếu nhà cung cấp không có luồng OAuth, vì giao diện có thể khiến quản trị viên hiểu rằng chức năng này được hỗ trợ.

Tạo webhook giả để kiểm thử

EzyOA sử dụng makeUserMessageFromText để mô phỏng một webhook mà không cần gửi tin từ nền tảng thật.
@Override
public Map<String, Object> makeUserMessageFromText(
    String oaUserId,
    String textMessage
) {
    return Map.of(
        "event", "message",
        "sender", Map.of("id", oaUserId),
        "message", Map.of("text", textMessage)
    );
}
Dữ liệu trả về phải có cùng cấu trúc với payload webhook thực tế. Nhờ đó, luồng kiểm thử đi qua đúng listener và bộ trích xuất dữ liệu.
Contract còn có phiên bản nhận thêm mediaId. Mặc định, EzyOA từ chối media nếu service không ghi đè phương thức này. Chỉ cần hiện thực khi nền tảng hỗ trợ kiểm thử tin nhắn có tệp đính kèm.

Xác thực webhook

@Override
public OAVerifyResultModel verifyUserMessage(
    Map<String, String> headers,
    Enumeration<String> parameterNames,
    Map<String, String> parameters,
    String rawMessage,
    Map<String, Object> message
) {
    String signature = findHeaderIgnoreCase(
        headers,
        "X-Example-Signature"
    );

    boolean valid = verifySignature(
        rawMessage,
        signature
    );

    if (valid) {
        return OAVerifyResultModel.builder()
            .status(OAVerifyStatus.SUCCESS)
            .build();
    }

    return OAVerifyResultModel.builder()
        .status(OAVerifyStatus.FAILED)
        .errorResponse(
            OAResponseModel.builder()
                .code(401)
                .data(Map.of("signature", "invalid"))
                .build()
        )
        .build();
}
Kết quả xác thực có ba trạng thái về mặt xử lý:
  • SUCCESS: EzyOA tiếp tục xử lý sự kiện.
  • FAILED: EzyOA trả response lỗi đã cung cấp.
  • Trạng thái khác: EzyOA bỏ qua payload nhưng vẫn phản hồi thành công.
Khi nhà cung cấp sử dụng chữ ký HMAC, phải tính chữ ký trên rawMessage, không nên serialize lại message. Việc serialize lại JSON có thể thay đổi khoảng trắng hoặc thứ tự trường và làm chữ ký không khớp.
Tên header cũng nên được đọc không phân biệt chữ hoa, chữ thường vì cách biểu diễn header có thể thay đổi theo web container hoặc proxy.

Xác định loại sự kiện

AbstractOAService dùng tên sự kiện để tìm listener tương ứng:
@Override
protected String extractEventName(
    Map<String, Object> message
) {
    String event = (String) message.get("event");

    if ("message.created".equals(event)) {
        return "user_send_text";
    }
    if ("user.followed".equals(event)) {
        return "follow";
    }
    if ("user.unfollowed".equals(event)) {
        return "unfollow";
    }
    return "";
}
Tên trả về phải khớp với getEventNames() của listener.
Không nên thực hiện toàn bộ nghiệp vụ trong phương thức này. Nó chỉ nên đọc payload và trả về tên sự kiện đã chuẩn hóa.
Nếu tên sự kiện không có listener tương ứng, AbstractOAService vẫn phản hồi thành công nhưng không chạy thêm nghiệp vụ nào.

Gửi tin nhắn văn bản

Service phải hiện thực phương thức gửi tin cấp thấp:
@Override
protected Object doSendMessage(
    long byAdminId,
    long byUserId,
    String recipientId,
    String message
) throws Exception {
    Map<String, Object> requestBody = Map.of(
        "recipient", recipientId,
        "message", Map.of("text", message)
    );

    Object response = callProviderApi(
        "/messages",
        requestBody
    );

    validateSendResponse(response);
    return response;
}
Không nên ghi đè sendMessage nếu không thực sự cần thiết. Phương thức public này đã bao bọc doSendMessage và cập nhật:
  • Thời điểm hệ thống gửi tin gần nhất.
  • Trạng thái thành công hoặc thất bại.
  • Transport được sử dụng.
  • Thông tin người kích hoạt thao tác gửi.
Nếu API trả HTTP 200 nhưng body chứa mã lỗi, implementation phải tự kiểm tra body và ném exception. EzyOA chỉ coi thao tác thành công khi phương thức kết thúc mà không có exception.

Gửi tin nhắn có media

@Override
protected Object doSendMessage(
    long byAdminId,
    long byUserId,
    String recipientId,
    String message,
    List<Map<String, Object>> medias
) throws Exception {
    Object lastResponse = null;

    if (message != null && !message.isBlank()) {
        lastResponse = doSendMessage(
            byAdminId,
            byUserId,
            recipientId,
            message
        );
    }

    if (medias != null) {
        for (Map<String, Object> media : medias) {
            String type = (String) media.get("type");
            String url = (String) media.get("url");
            String fileName = (String) media.get("fileName");

            lastResponse = sendMedia(
                recipientId,
                type,
                url,
                fileName
            );
        }
    }

    return lastResponse;
}
Một media thường có các trường:
  • type: loại nội dung như image, audio, video hoặc document.
  • url: URL tải nội dung.
  • fileName: tên file.
  • filePath: đường dẫn nội bộ nếu media đã được lưu trong hệ thống.
Implementation phải tự ánh xạ các loại media chung sang loại mà nhà cung cấp hỗ trợ. Đồng thời cần giới hạn kích thước, MIME type và nguồn URL trước khi tải hoặc gửi file.

Lấy thông tin người dùng OA

Khi nhận tin từ một người dùng chưa tồn tại, listener chung có thể yêu cầu service lấy thông tin tài khoản từ nhà cung cấp:
@Override
public SaveOAUserModel getOAUserDetails(
    String oaUserId
) throws Exception {
    Map<String, Object> profile = fetchProfile(oaUserId);

    return SaveOAUserModel.builder()
        .serviceCode(getCode())
        .oaUserId(oaUserId)
        .displayName((String) profile.get("displayName"))
        .avatarUrl((String) profile.get("avatarUrl"))
        .status("ACTIVATED")
        .build();
}
Hai trường quan trọng nhất là:
  • serviceCode: phải trả về getCode().
  • oaUserId: định danh người dùng do nhà cung cấp cấp.
Không nên giả định ID người dùng giống nhau giữa các dịch vụ OA. Cặp serviceCode và oaUserId mới là định danh đầy đủ.

Đồng bộ người theo dõi

@Override
public void fetchAllFollowers() throws Exception {
    String cursor = null;

    do {
        FollowerPage page = fetchFollowers(cursor);

        for (ProviderUser user : page.getUsers()) {
            saveOrUpdateFollower(user);
        }

        cursor = page.getNextCursor();
    } while (cursor != null);
}
Nếu nhà cung cấp không có API lấy danh sách người theo dõi, có thể để phương thức không thực hiện thao tác:
@Override
public void fetchAllFollowers() {
    // Nền tảng không hỗ trợ đồng bộ danh sách follower.
}
Không nên tự động suy ra toàn bộ người dùng từ lịch sử nếu điều đó không phù hợp với định nghĩa follower của nhà cung cấp.

Sao chép service

clone là một phần bắt buộc của contract:
@Override
public OAService clone(String newCode) {
    ExampleOAService clone = new ExampleOAService(newCode);
    cloneExampleFieldsTo(clone);
    return clone;
}

protected void cloneExampleFieldsTo(
    ExampleOAService target
) {
    cloneCommonFieldsTo(target);

    target.httpClient = this.httpClient;
    target.oaSettingService = this.oaSettingService;
    target.mediaService = this.mediaService;
}
cloneCommonFieldsTo sao chép các dependency chung do AbstractOAService quản lý. Các dependency riêng của implementation vẫn phải được sao chép thủ công.
Đây là bước dễ bị bỏ sót. Service được tạo động bằng clone không đi qua quá trình khởi tạo bean thông thường, vì vậy dependency riêng có thể là null nếu không được sao chép.

Mẫu service hoàn chỉnh

Ví dụ dưới đây thể hiện cấu trúc tối thiểu nên có. Các phần giao tiếp HTTP được để ở dạng hàm mô tả vì request thực tế phụ thuộc vào từng nhà cung cấp.
@Setter
public class ExampleOAService extends AbstractOAService {

    public static final String SERVICE_CODE = "example";

    @EzyAutoBind
    protected ExampleOASettingService oaSettingService;

    @EzyAutoBind
    protected ExampleHttpClient httpClient;

    public ExampleOAService() {
        this(SERVICE_CODE);
    }

    protected ExampleOAService(String code) {
        super(code);
    }

    @Override
    public void handleAuthentication(
        Map<String, String> headers,
        Enumeration<String> parameterNames,
        Map<String, String> parameters,
        OAServiceSettingModel setting,
        String secretKey
    ) throws Exception {
        String authorizationCode = parameters.get("code");
        String state = parameters.get("state");

        verifyState(state, setting);
        exchangeAndSaveAccessToken(
            authorizationCode,
            secretKey
        );
    }

    @Override
    public Map<String, Object> makeUserMessageFromText(
        String oaUserId,
        String textMessage
    ) {
        return Map.of(
            "event", "message.created",
            "sender", Map.of("id", oaUserId),
            "message", Map.of("text", textMessage)
        );
    }

    @Override
    public OAVerifyResultModel verifyUserMessage(
        Map<String, String> headers,
        Enumeration<String> parameterNames,
        Map<String, String> parameters,
        String rawMessage,
        Map<String, Object> message
    ) {
        boolean valid = verifyWebhookSignature(
            headers,
            rawMessage
        );

        if (valid) {
            return OAVerifyResultModel.builder()
                .status(OAVerifyStatus.SUCCESS)
                .build();
        }

        return OAVerifyResultModel.builder()
            .status(OAVerifyStatus.FAILED)
            .errorResponse(
                OAResponseModel.builder()
                    .code(401)
                    .data(Map.of("webhook", "invalid"))
                    .build()
            )
            .build();
    }

    @Override
    protected String extractEventName(
        Map<String, Object> message
    ) {
        String event = (String) message.get("event");

        if ("message.created".equals(event)) {
            return "user_send_text";
        }
        if ("user.followed".equals(event)) {
            return "follow";
        }
        if ("user.unfollowed".equals(event)) {
            return "unfollow";
        }
        return "";
    }

    @Override
    protected Object doSendMessage(
        long byAdminId,
        long byUserId,
        String recipientId,
        String message
    ) throws Exception {
        return sendTextToProvider(
            recipientId,
            message
        );
    }

    @Override
    protected Object doSendMessage(
        long byAdminId,
        long byUserId,
        String recipientId,
        String message,
        List<Map<String, Object>> medias
    ) throws Exception {
        Object result = null;

        if (message != null && !message.isBlank()) {
            result = doSendMessage(
                byAdminId,
                byUserId,
                recipientId,
                message
            );
        }

        if (medias != null) {
            for (Map<String, Object> media : medias) {
                result = sendMediaToProvider(
                    recipientId,
                    media
                );
            }
        }

        return result;
    }

    @Override
    public void fetchAllFollowers() throws Exception {
        synchronizeFollowers();
    }

    @Override
    public SaveOAUserModel getOAUserDetails(
        String oaUserId
    ) throws Exception {
        ExampleUser profile = fetchUserProfile(oaUserId);

        return SaveOAUserModel.builder()
            .serviceCode(getCode())
            .oaUserId(oaUserId)
            .displayName(profile.getDisplayName())
            .avatarUrl(profile.getAvatarUrl())
            .status("ACTIVATED")
            .build();
    }

    @Override
    public OAService clone(String newCode) {
        ExampleOAService clone =
            new ExampleOAService(newCode);

        cloneCommonFieldsTo(clone);
        clone.oaSettingService = this.oaSettingService;
        clone.httpClient = this.httpClient;

        return clone;
    }
}

Đăng ký service vào runtime

EzyOA có runtime riêng cho trang quản trị và web. Service cần xuất hiện trong cả hai nếu muốn:
  • Hiển thị và thao tác trong trang quản trị.
  • Nhận webhook tại web runtime.
  • Gửi tin từ cả luồng quản trị lẫn luồng tự động.
Tạo lớp đăng ký cho web runtime:
@Service
public class WebExampleOAService
    extends ExampleOAService {
}
Tạo lớp đăng ký tương ứng cho module quản trị:
@Service
public class AdminExampleOAService
    extends ExampleOAService {
}
Trình quản lý của EzyOA tự tìm các bean hiện thực OAService và lập chỉ mục theo getCode(). Vì AbstractOAService đã hiện thực getCode() dựa trên giá trị truyền vào constructor, service không cần ghi đè phương thức này.
Mã dịch vụ phải:
  • Không rỗng.
  • Ổn định sau khi triển khai.
  • Không trùng với service khác.
  • Được dùng nhất quán trong listener và các sender mở rộng.
  • Tránh chỉ khác nhau ở chữ hoa và chữ thường.
EzyOA hỗ trợ tra cứu mã service không phân biệt hoa thường, vì vậy hai implementation có mã chỉ khác kiểu chữ có thể gây hành vi không xác định.

Cài đặt listener nhận tin nhắn

AbstractOAService chỉ định tuyến sự kiện. Để xử lý nội dung tin nhắn, cần đăng ký một OAListener.
Nếu payload có cấu trúc mới hoàn toàn, có thể kế thừa listener nhận tin chung và hiện thực phần trích xuất:
public class ExampleOAUserSendMessageListener
    extends OAUserSendMessageListener {

    private final String serviceCode;

    public ExampleOAUserSendMessageListener() {
        this(ExampleOAService.SERVICE_CODE);
    }

    protected ExampleOAUserSendMessageListener(
        String serviceCode
    ) {
        this.serviceCode = serviceCode;
    }

    @SuppressWarnings("unchecked")
    @Override
    protected String extractSenderId(
        Map<String, Object> message
    ) {
        Map<String, Object> sender =
            (Map<String, Object>) message.get("sender");

        return String.valueOf(sender.get("id"));
    }

    @SuppressWarnings("unchecked")
    @Override
    protected String extractTextMessage(
        Map<String, Object> message
    ) {
        Map<String, Object> messageData =
            (Map<String, Object>) message.get("message");

        return (String) messageData.get("text");
    }

    @Override
    public String getServiceCode() {
        return serviceCode;
    }

    @Override
    public String[] getEventNames() {
        return new String[]{"user_send_text"};
    }

    @Override
    public OAListener clone(String newServiceCode) {
        ExampleOAUserSendMessageListener clone =
            new ExampleOAUserSendMessageListener(
                newServiceCode
            );

        copyCommonFieldsTo(clone);
        return clone;
    }
}
Listener nhận tin chung của EzyOA có thể tái sử dụng các nghiệp vụ:
  • Tạo người dùng OA khi nhận tin lần đầu.
  • Tạo hoặc lấy kênh hội thoại.
  • Lưu nội dung và media.
  • Chạy kịch bản phản hồi.
  • Gọi chatbot.
  • Gửi câu trả lời.
  • Lưu tin nhắn phản hồi.
Tên sự kiện mà service trả về phải trùng với sự kiện listener đăng ký:
extractEventName(message) = "user_send_text"
getEventNames()            = ["user_send_text"]
Nếu hai giá trị không khớp, webhook vẫn có thể nhận phản hồi HTTP thành công nhưng tin nhắn sẽ không được xử lý.
Listener cũng cần được đăng ký thành bean trong web runtime. Có thể đăng ký thêm ở module quản trị nếu cần sử dụng chức năng gửi webhook giả tại trang quản trị.

Xác minh webhook subscription

Một số nền tảng gửi GET tới webhook để xác minh endpoint. Khi đó cần ghi đè:
@Override
public OAResponseModel verifyWebhookSubscription(
    Map<String, String> parameters
) {
    String mode = parameters.get("mode");
    String token = parameters.get("verify_token");
    String challenge = parameters.get("challenge");

    if ("subscribe".equals(mode)
        && getWebhookVerifyToken().equals(token)
    ) {
        return OAResponseModel.builder()
            .code(200)
            .data(challenge)
            .build();
    }

    return OAResponseModel.builder()
        .code(403)
        .data("forbidden")
        .build();
}
Có thể cung cấp token qua:
@Override
public String getWebhookVerifyToken() {
    return loadWebhookVerifyToken(getCode());
}
Nếu nhà cung cấp không có bước xác minh bằng GET, không cần ghi đè. Phương thức mặc định báo không hỗ trợ.

OAuth và URL tích hợp

AbstractOAService tự sinh:
<web-url>/api/v1/oa/<service-code>/webhook
<web-url>/oa/<service-code>/auth-callback
Để hiển thị URL bắt đầu OAuth, ghi đè:
@Override
public String getOauthUrl() {
    return buildAuthorizationUrl(
        getAuthenticationCallbackUrl()
    );
}
Khi xây dựng OAuth URL cần:
  • Dùng HTTPS.
  • Tạo và kiểm tra state.
  • Encode đầy đủ query parameter.
  • Sử dụng đúng callback URL mà EzyOA hiển thị.
  • Không đưa client secret vào URL.
  • Lưu token bằng kiểu dữ liệu bảo mật.
  • Hỗ trợ refresh token nếu nhà cung cấp yêu cầu.

Notification và template riêng của nhà cung cấp

Nếu nền tảng có API notification dựa trên template, ghi đè:
@Override
public Object sendTemplatedNotification(
    long byAdminId,
    long byUserId,
    String transportType,
    String recipientId,
    String oaTemplateId,
    Map<String, Object> parameters
) throws Exception {
    return callNotificationApi(
        recipientId,
        oaTemplateId,
        parameters
    );
}
Phần ánh xạ từ content template của EzyPlatform sang template OA đã được AbstractOAService xử lý. Implementation mới chỉ cần gửi:
  • ID người nhận.
  • ID template của nhà cung cấp.
  • Bộ tham số đã được chuẩn bị.
  • Thông tin transport nếu có nhiều đường gửi.
Nếu nền tảng không hỗ trợ notification template, nên ném exception rõ ràng thay vì trả về thành công giả.

Typing indicator

Nếu nhà cung cấp hỗ trợ trạng thái đang nhập:
@Override
public void sendTypingIndicator(
    String recipientId
) throws Exception {
    callSenderActionApi(
        recipientId,
        "typing_on"
    );
}

@Override
public long getTypingIndicatorIntervalInMillis() {
    return 5_000L;
}
Giá trị interval bằng 0 nghĩa là không gửi typing indicator. Không nên đặt khoảng thời gian quá ngắn vì có thể vượt rate limit của nhà cung cấp.

Upload media

Service có thể hỗ trợ upload media được lưu trong hệ thống:
@Override
public OAUploadMediaResultModel uploadMediaById(
    long byAdminId,
    long byUserId,
    String transportType,
    long mediaId
) throws Exception {
    ProviderMedia response = uploadMedia(mediaId);

    return OAUploadMediaResultModel.builder()
        .mediaId(response.getId())
        .build();
}
Nếu không ghi đè, implementation mặc định trả về một kết quả rỗng. Vì vậy phía gọi không nên xem object khác null là bằng chứng upload đã thành công; cần kiểm tra dữ liệu kết quả cụ thể.

Hỗ trợ nhiều tài khoản cùng loại

Một implementation có thể phục vụ nhiều tài khoản OA cùng nhà cung cấp.
Ví dụ, example là service gốc. Trong trang quản trị có thể tạo:
  • example_sales
  • example_support
  • example_internal
Mỗi cấu hình mới chọn example làm dịch vụ xử lý. Khi được truy cập lần đầu, EzyOA:
  1. Tìm service gốc.
  2. Gọi clone(newCode).
  3. Sao chép listener.
  4. Sao chép extension sender.
  5. Sao chép template message sender.
  6. Sao chép template notification sender.
  7. Lưu service đã clone để tái sử dụng.
Mỗi service clone sử dụng mã riêng để đọc token, webhook secret và cấu hình riêng.
Vì vậy, không được lưu token của một tài khoản trong field mutable của service gốc. Nên tra cứu token theo getCode() tại thời điểm gửi request.

Luồng webhook hoàn chỉnh

sequenceDiagram
    participant P as Nhà cung cấp OA
    participant W as Web runtime
    participant S as OAService
    participant L as OA Listener
    participant B as Nghiệp vụ

    P->>W: POST webhook
    W->>W: Tìm dịch vụ đang hoạt động
    W->>S: verifyUserMessage(...)
    alt Xác thực thất bại
        S-->>W: FAILED và error response
        W-->>P: Trả lỗi
    else Bỏ qua sự kiện
        S-->>W: Trạng thái bỏ qua
        W-->>P: 200 OK
    else Xác thực thành công
        S-->>W: SUCCESS
        W->>S: handleUserMessage(message)
        S->>S: extractEventName(message)
        S->>L: onMessageReceived(...)
        L->>B: Lưu tin hoặc chạy kịch bản
        B-->>L: Kết quả
        S-->>W: 200 OK
        W-->>P: Phản hồi webhook
    end
Webhook chỉ được xử lý khi:
  • Tìm thấy OAService.
  • Có cấu hình dịch vụ tương ứng.
  • Dịch vụ đang ở trạng thái hoạt động.
  • Payload là JSON hợp lệ.
  • Bước xác thực webhook cho phép xử lý.

Checklist triển khai

Trước khi đưa service mới vào sử dụng, cần kiểm tra:
  • Service kế thừa AbstractOAService.
  • Mã service là duy nhất và ổn định.
  • Có constructor mặc định và constructor nhận mã service.
  • Service được đăng ký ở cả admin và web runtime.
  • verifyUserMessage kiểm tra đúng chữ ký hoặc secret.
  • Chữ ký được tính từ raw request body.
  • extractEventName trả đúng tên mà listener đăng ký.
  • makeUserMessageFromText tạo payload giống webhook thật.
  • Cả gửi text và gửi media đều xử lý lỗi response.
  • getOAUserDetails trả đúng serviceCode và oaUserId.
  • clone gọi cloneCommonFieldsTo.
  • clone sao chép tất cả dependency riêng.
  • Listener và sender mở rộng cũng hỗ trợ clone nếu cần.
  • Token được tra cứu theo getCode().
  • Không ghi token hoặc secret vào log.
  • Các API gọi ra ngoài có timeout.
  • Có xử lý rate limit và token hết hạn.
  • Webhook trả response trong thời gian nhà cung cấp yêu cầu.

Khi nào không nên dùng AbstractOAService

Có thể hiện thực trực tiếp OAService nếu cần thay đổi hoàn toàn:
  • Cơ chế phân phối sự kiện.
  • Chiến lược gửi hàng loạt.
  • Quy trình xử lý content template.
  • Cơ chế tìm người nhận.
  • Theo dõi trạng thái gửi.
  • Mô hình clone service.
Tuy nhiên, implementation trực tiếp phải tự cung cấp toàn bộ phương thức trong contract. Điều này làm tăng đáng kể khối lượng code và nguy cơ hành vi không nhất quán với các dịch vụ OA có sẵn.
Trong hầu hết trường hợp, nên kế thừa AbstractOAService và chỉ tùy biến các điểm giao tiếp với nhà cung cấp.

Kết luận

Một dịch vụ OA mở rộng tốt nên hoạt động như một adapter mỏng: xác thực request, chuyển payload thành sự kiện chuẩn, gọi API gửi tin và lấy thông tin người dùng. Các nghiệp vụ chung như lưu hội thoại, chatbot, template, gửi hàng loạt và theo dõi trạng thái nên được giao cho AbstractOAService và hệ thống listener của EzyOA.
Cách tiếp cận này giúp service mới có hành vi nhất quán với Zalo OA, Messenger và Telegram Bot, đồng thời dễ bảo trì và có thể tái sử dụng cho nhiều tài khoản cùng nhà cung cấp.