EzyChat có thể sử dụng kết nối socket để chuyển một workflow từ backend tới Chrome extension EzyConnector. Extension nhận workflow, thực thi tuần tự các thao tác trên trình duyệt rồi gửi kết quả ngược về backend.
Một workflow có thể bao gồm các hành động như:
  • Mở hoặc tìm một tab.
  • Chuyển tab hiện tại tới URL khác.
  • Chờ một phần tử xuất hiện.
  • Điền dữ liệu vào input hoặc textarea.
  • Nhấn nút hoặc gửi phím Enter.
  • Tải tệp từ URL rồi gán vào file input.
  • Chờ một khoảng thời gian.
  • Dừng workflow khi phát hiện trạng thái lỗi trên trang.
Cần lưu ý rằng “nhiệm vụ bất kỳ” ở đây không có nghĩa extension thực thi JavaScript tùy ý do server gửi xuống. EzyConnector chỉ thực thi các command đã được đăng ký sẵn. Đây là ranh giới an toàn quan trọng của thiết kế.

Kiến trúc tổng quát

Luồng xử lý gồm ba thành phần:
sequenceDiagram
    participant Backend as EzyChat backend
    participant Socket as EzyChat socket application
    participant Extension as EzyConnector
    participant Browser as Trang web trên Chrome

    Extension->>Socket: Kết nối và đăng nhập bằng access token
    Backend->>Socket: pushConnectorRequest
    Socket->>Extension: connectorRequest
    Extension->>Browser: Thực thi các command
    Browser-->>Extension: Kết quả từng command
    Extension->>Socket: connectorResponse
    Socket-->>Backend: Kết quả workflow
EzyChat backend không kết nối trực tiếp với Chrome extension. Backend gửi yêu cầu tới socket application, còn socket application định tuyến yêu cầu tới phiên đăng nhập của quản trị viên tương ứng.
Kết quả cũng đi theo chiều ngược lại qua socket application. requestId được dùng để ghép kết quả với request đang chờ ở backend.

Chuẩn bị EzyConnector

EzyConnector là Chrome extension sử dụng Manifest V3. Sau khi cài extension, người dùng đăng nhập bằng:
  • URL trang quản trị.
  • Tài khoản quản trị.
  • Mật khẩu.
Extension thực hiện đăng nhập HTTP để lấy access token, sau đó lấy WebSocket URL từ hệ thống và mở kết nối đến:
  • Zone: chat
  • Application: ezychat
Trong bước đăng nhập socket, access token được gửi qua trường adminAccessToken.
Khi truy cập application thành công, extension chuyển sang trạng thái connected và có thể nhận workflow.
Do service worker của Manifest V3 có thể bị Chrome tạm dừng khi không hoạt động, extension sử dụng alarm định kỳ để thử kết nối lại. Socket client cũng được cấu hình reconnect khi kết nối bị gián đoạn.
Trước khi gửi workflow, backend có thể kiểm tra xem quản trị viên đích có phiên socket đang hoạt động hay không. Nếu không có extension tương ứng đang trực tuyến, workflow không thể được chuyển tới trình duyệt và lời gọi phía backend có thể hết thời gian chờ.

Cấu trúc một workflow

Dữ liệu nghiệp vụ mà EzyChat backend gửi tới socket có dạng:
{
  "adminId": 123,
  "type": "workflow.execute",
  "payload": {
    "version": 1,
    "commands": [
      {
        "name": "tab.ensure",
        "args": {
          "url": "https://example.com/tasks",
          "urlPattern": "https://example.com/*",
          "active": true
        },
        "saveAs": "taskTab"
      },
      {
        "name": "dom.fill",
        "args": {
          "tabId": "",
          "selector": "textarea[name="content"]",
          "value": "Nội dung cần gửi"
        }
      },
      {
        "name": "dom.click",
        "args": {
          "tabId": "",
          "selector": "button[type="submit"]"
        }
      }
    ]
  }
}
Các trường chính:
TrườngÝ nghĩa
adminIdQuản trị viên đang chạy EzyConnector và sẽ nhận workflow
typeLoại nhiệm vụ; hiện extension hỗ trợ workflow.execute
payload.versionPhiên bản giao thức workflow; hiện tại là 1
payload.commandsDanh sách command được thực thi tuần tự
nameTên command
argsTham số của command
saveAsTên dùng để lưu kết quả command và tham chiếu ở bước sau
Khi chuyển tiếp xuống extension, socket application tạo thông điệp:
{
  "id": "1a",
  "type": "workflow.execute",
  "payload": {
    "version": 1,
    "commands": []
  }
}
Trong đó id là mã request do backend sinh ra. Extension phải giữ nguyên mã này khi trả kết quả.

Gửi workflow từ EzyChat backend

Ở phía Java, nên đóng gói workflow thành một EzyObject, sau đó gửi qua connector manager của EzyChat.
Ví dụ dưới đây mở một trang, nhập dữ liệu rồi bấm nút gửi:
EzyArray commands = EzyEntityFactory.newArray();

commands.add(
    EzyEntityFactory.newObjectBuilder()
        .append("name", "tab.ensure")
        .append(
            "args",
            EzyEntityFactory.newObjectBuilder()
                .append("url", "https://example.com/tasks")
                .append("urlPattern", "https://example.com/*")
                .append("active", true)
                .build()
        )
        .append("saveAs", "taskTab")
        .build()
);

commands.add(
    EzyEntityFactory.newObjectBuilder()
        .append("name", "dom.wait")
        .append(
            "args",
            EzyEntityFactory.newObjectBuilder()
                .append("tabId", "")
                .append("selector", "textarea[name="content"]")
                .append("timeoutMs", 10000)
                .build()
        )
        .build()
);

commands.add(
    EzyEntityFactory.newObjectBuilder()
        .append("name", "dom.fill")
        .append(
            "args",
            EzyEntityFactory.newObjectBuilder()
                .append("tabId", "")
                .append("selector", "textarea[name="content"]")
                .append("value", "Nội dung cần gửi")
                .build()
        )
        .build()
);

commands.add(
    EzyEntityFactory.newObjectBuilder()
        .append("name", "dom.click")
        .append(
            "args",
            EzyEntityFactory.newObjectBuilder()
                .append("tabId", "")
                .append("selector", "button[type="submit"]")
                .build()
        )
        .build()
);

EzyObject connectorRequest =
    EzyEntityFactory.newObjectBuilder()
        .append("adminId", adminId)
        .append("type", "workflow.execute")
        .append(
            "payload",
            EzyEntityFactory.newObjectBuilder()
                .append("version", 1)
                .append("commands", commands)
                .build()
        )
        .build();

Map<String, Object> result =
    connectorManager.pushConnectorRequest(
        connectorRequest,
        true,
        15
    );
Ba tham số của lời gọi cuối có ý nghĩa:
  • connectorRequest: nội dung workflow.
  • true: cho phép chia request thành nhiều fragment.
  • 15: thời gian tối đa chờ extension phản hồi, tính bằng giây.
Nên dùng chế độ fragment khi workflow có nội dung dài, nhiều selector, nhiều command hoặc danh sách URL tệp. Backend hiện chia dữ liệu đã serialize thành các fragment 16 KiB trước khi gửi.
Nếu payload nhỏ, có thể gửi không phân mảnh:
Map<String, Object> result =
    connectorManager.pushConnectorRequest(
        connectorRequest,
        15
    );

Định tuyến request qua socket

Backend gửi request bằng command socket:
pushConnectorRequest
Socket application thực hiện các bước:
  1. Xác thực người gửi là tài khoản connector được phép phát workflow.
  2. Kiểm tra requestId.
  3. Ghép các fragment nếu request được gửi phân mảnh.
  4. Kiểm tra adminId và type.
  5. Tìm phiên socket của quản trị viên tương ứng.
  6. Chuyển yêu cầu tới extension bằng command:
connectorRequest
Thông điệp nhận bởi extension có dạng:
{
  "id": "1a",
  "type": "workflow.execute",
  "payload": {
    "version": 1,
    "commands": [...]
  }
}
Việc tách command phía backend và command phía extension giúp socket application đóng vai trò router. Extension không cần biết request ban đầu được tạo bởi API, scheduled job hay một service nghiệp vụ khác.

Extension thực thi workflow như thế nào?

Service worker của EzyConnector đăng ký handler cho connectorRequest.
Khi nhận được thông điệp hợp lệ, extension:
  1. Kiểm tra request có id và type.
  2. Chuyển request tới dispatcher.
  3. Dispatcher chỉ chấp nhận type = workflow.execute.
  4. Workflow executor kiểm tra version và danh sách command.
  5. Các command được thực thi tuần tự theo đúng thứ tự.
  6. Kết quả từng command được lưu lại.
  7. Nếu tất cả command thành công, extension trả kết quả thành công.
  8. Nếu một command phát sinh lỗi, workflow dừng ngay và trả thông báo lỗi.
Extension giới hạn tối đa 100 command trong một workflow.

Các command hiện được hỗ trợ

Quản lý tab

tab.ensure tìm một tab phù hợp, tạo tab nếu chưa tồn tại hoặc chuyển tab hiện tại tới URL yêu cầu.
{
  "name": "tab.ensure",
  "args": {
    "url": "https://example.com/tasks",
    "urlPattern": "https://example.com/*",
    "active": true,
    "timeoutMs": 15000
  },
  "saveAs": "page"
}
Kết quả chứa thông tin tương tự:
{
  "id": 42,
  "windowId": 7,
  "url": "https://example.com/tasks"
}
Các command quản lý tab khác:
  • tab.create
  • tab.update
  • tab.reload
  • tab.remove

Chờ phần tử

{
  "name": "dom.wait",
  "args": {
    "tabId": "",
    "selector": "#task-form",
    "timeoutMs": 10000
  }
}
Command này lặp lại việc tìm selector cho đến khi phần tử xuất hiện hoặc hết thời gian chờ.

Nhập dữ liệu

{
  "name": "dom.fill",
  "args": {
    "tabId": "",
    "selector": "textarea[name="content"]",
    "value": "Xin chào",
    "minBeforeTypeDelayMs": 100,
    "maxBeforeTypeDelayMs": 300,
    "minCharacterDelayMs": 40,
    "maxCharacterDelayMs": 100
  }
}
Ngoài việc gán giá trị, extension phát các DOM event như input, change, keydown và keyup. Điều này giúp thao tác tương thích tốt hơn với các trang sử dụng framework frontend.

Nhấn phần tử hoặc gửi phím

{
  "name": "dom.click",
  "args": {
    "tabId": "",
    "selector": "button[type="submit"]"
  }
}
Hoặc:
{
  "name": "dom.keypress",
  "args": {
    "tabId": "",
    "selector": "textarea[name="content"]",
    "key": "Enter"
  }
}

Chèn độ trễ

{
  "name": "delay",
  "args": {
    "tabId": "",
    "minDurationMs": 350,
    "maxDurationMs": 900
  }
}
Có thể sử dụng durationMs cho thời gian cố định hoặc cặp minDurationMs và maxDurationMs cho thời gian ngẫu nhiên.
Nên truyền tabId để timer chạy trong tab. Timer của service worker có thể bị Chrome trì hoãn khi worker không hoạt động.

Tải và upload tệp

{
  "name": "dom.uploadRemoteFiles",
  "args": {
    "tabId": "",
    "selector": "input[type="file"]",
    "triggerSelector": ".upload-button",
    "urls": [
      "https://cdn.example.com/image.jpg"
    ],
    "timeoutMs": 10000
  }
}
Extension tải tệp, chuyển tệp thành đối tượng File, gán vào file input rồi phát input và change.
Các giới hạn hiện tại:
  • URL tệp phải dùng HTTPS.
  • HTTP chỉ được chấp nhận với localhost.
  • Mỗi tệp không được vượt quá 10 MiB.

Chờ một trong nhiều trạng thái

dom.waitAny phù hợp khi trang có thể trả về trạng thái thành công hoặc lỗi:
{
  "name": "dom.waitAny",
  "args": {
    "tabId": "",
    "timeoutMs": 15000,
    "waitForChangeMs": 500,
    "stableMs": 300,
    "conditions": [
      {
        "selector": ".notification-success"
      },
      {
        "selector": ".notification-error",
        "errorMessage": "Trang web từ chối yêu cầu"
      }
    ]
  }
}
Nếu điều kiện có errorMessage được thỏa mãn, workflow kết thúc với lỗi.
Ngoài ra, dom.assertTextAbsent có thể dùng để dừng workflow nếu một vùng trên trang chứa nội dung không mong muốn.

Tham chiếu kết quả giữa các command

Khi một command có:
{
  "saveAs": "page"
}
kết quả của command được lưu dưới tên page. Command tiếp theo có thể tham chiếu một trường bằng cú pháp:

Ví dụ:
{
  "name": "dom.click",
  "args": {
    "tabId": "",
    "selector": "#submit"
  }
}
Tham chiếu có thể là toàn bộ giá trị:
{
  "tabId": ""
}
hoặc được chèn vào một chuỗi:
{
  "value": "Tab hiện tại là "
}
Tên saveAs phải bắt đầu bằng chữ cái và chỉ chứa chữ cái, chữ số hoặc dấu gạch dưới.
Nếu không khai báo saveAs, kết quả vẫn được lưu bằng chỉ số của command, nhưng đặt tên rõ ràng giúp workflow dễ đọc và ít phụ thuộc vào thứ tự hơn.

Nhận kết quả tại backend

Khi workflow hoàn thành, extension gửi:
connectorResponse
Kết quả thành công:
{
  "id": "1a",
  "ok": true,
  "data": {
    "page": {
      "id": 42,
      "windowId": 7,
      "url": "https://example.com/tasks"
    },
    "1": {
      "found": true
    },
    "2": {
      "filled": true
    },
    "3": {
      "clicked": true
    }
  }
}
Kết quả thất bại:
{
  "id": "1a",
  "ok": false,
  "error": "Element not found: button[type="submit"]"
}
Socket application sử dụng id để chuyển kết quả về đúng request backend đang chờ. Phía Java nhận kết quả dưới dạng Map<String, Object>:
Map<String, Object> result =
    connectorManager.pushConnectorRequest(
        connectorRequest,
        true,
        15
    );

boolean successful =
    Boolean.TRUE.equals(result.get("ok"));

if (!successful) {
    String error = String.valueOf(result.get("error"));
    // Xử lý lỗi nghiệp vụ hoặc ghi log.
}
ok = true chỉ cho biết extension đã thực thi toàn bộ workflow mà không phát sinh exception. Nó không tự động chứng minh nghiệp vụ trên website đã thành công.
Ví dụ, thao tác bấm nút có thể hoàn thành nhưng website vẫn trả về lỗi. Vì vậy workflow quan trọng nên có thêm bước dom.waitAny hoặc một command xác minh trạng thái cuối.

Ví dụ workflow hoàn chỉnh

Workflow sau mở một trang quản trị công việc, nhập tiêu đề và nội dung, gửi biểu mẫu rồi chờ kết quả:
{
  "adminId": 123,
  "type": "workflow.execute",
  "payload": {
    "version": 1,
    "commands": [
      {
        "name": "tab.ensure",
        "args": {
          "url": "https://example.com/tasks/new",
          "urlPattern": "https://example.com/tasks/*",
          "active": true
        },
        "saveAs": "taskPage"
      },
      {
        "name": "dom.wait",
        "args": {
          "tabId": "",
          "selector": "form#task-form",
          "timeoutMs": 10000
        }
      },
      {
        "name": "dom.fill",
        "args": {
          "tabId": "",
          "selector": "input[name="title"]",
          "value": "Kiểm tra dữ liệu"
        }
      },
      {
        "name": "dom.fill",
        "args": {
          "tabId": "",
          "selector": "textarea[name="description"]",
          "value": "Nhiệm vụ được gửi từ EzyChat"
        }
      },
      {
        "name": "dom.click",
        "args": {
          "tabId": "",
          "selector": "button[type="submit"]"
        }
      },
      {
        "name": "dom.waitAny",
        "args": {
          "tabId": "",
          "timeoutMs": 15000,
          "waitForChangeMs": 500,
          "stableMs": 300,
          "conditions": [
            {
              "selector": ".alert-success"
            },
            {
              "selector": ".alert-danger",
              "errorMessage": "Không thể tạo nhiệm vụ"
            }
          ]
        },
        "saveAs": "submission"
      }
    ]
  }
}

Bảo mật

Cơ chế này cho phép extension thao tác trực tiếp trên các website mà người dùng đang đăng nhập, vì vậy cần xem workflow là dữ liệu có đặc quyền cao.
Các biện pháp quan trọng gồm:
  • Chỉ cho tài khoản connector chuyên dụng gửi workflow.
  • Định tuyến workflow theo adminId, không nhận danh tính đích từ extension.
  • Chỉ hỗ trợ các command nằm trong registry.
  • Không cung cấp command thực thi JavaScript tùy ý.
  • Luôn sử dụng HTTPS và WSS ngoài môi trường localhost.
  • Kiểm tra URL và selector ở backend trước khi tạo workflow.
  • Không đưa access token, mật khẩu hoặc dữ liệu nhạy cảm vào log.
  • Giới hạn số command, timeout, kích thước fragment và kích thước tệp.
  • Chỉ cài extension từ nguồn tin cậy.
  • Thu hẹp host_permissions nếu môi trường triển khai chỉ làm việc với một số domain cố định.
Extension hiện chạy các thao tác DOM trong MAIN world. Vì vậy command có thể tương tác với DOM và JavaScript context của trang. Khả năng này cần thiết cho automation nhưng cũng làm tăng mức độ đặc quyền của extension.

Xử lý lỗi và timeout

Một workflow có thể thất bại tại nhiều lớp:
LớpVí dụ
BackendDữ liệu đầu vào không hợp lệ
Socket applicationNgười gửi không phải connector hợp lệ
Định tuyếnKhông tìm thấy phiên extension của adminId
Kết nốiExtension mất kết nối trước khi nhận request
Dispatchertype không được hỗ trợ
WorkflowSai version hoặc quá 100 command
CommandSai tên command hoặc thiếu tham số
Trình duyệtKhông tìm thấy tab hoặc phần tử
Website đíchDOM thay đổi, phiên đăng nhập hết hạn
Phản hồiBackend hết timeout trước khi workflow hoàn thành
Timeout phía backend nên lớn hơn tổng thời gian tối đa của workflow. Ví dụ, nếu workflow có hai bước chờ 10 giây và một bước chờ kết quả 15 giây, timeout 15 giây ở backend là không đủ trong trường hợp xấu nhất.
Nên tính timeout theo công thức gần đúng:
timeout backend
  > tổng timeout của các command
  + tổng delay
  + thời gian tải trang
  + khoảng dự phòng cho mạng và socket
Workflow nên được thiết kế có giới hạn rõ ràng. Tránh các bước chờ vô hạn hoặc chuỗi delay dài vì backend đang giữ một future để chờ phản hồi.

Mở rộng để thực thi nhiệm vụ mới

Nếu nhiệm vụ mới có thể biểu diễn bằng các command hiện có, chỉ cần tạo workflow mới ở backend. Không cần phát hành lại extension.
Ví dụ:
  • Gửi biểu mẫu: tab.ensure → dom.fill → dom.click.
  • Gửi tin nhắn: tab.ensure → dom.wait → dom.fill → dom.keypress.
  • Upload ảnh: tab.ensure → dom.uploadRemoteFiles.
  • Kiểm tra trạng thái: dom.waitAny hoặc dom.assertTextAbsent.
Nếu nhiệm vụ cần một khả năng hoàn toàn mới, cần bổ sung command handler vào extension. Quy trình phù hợp là:
  1. Định nghĩa tên command và cấu trúc args.
  2. Thêm handler vào command registry.
  3. Kiểm tra chặt chẽ kiểu dữ liệu và giới hạn tham số.
  4. Trả về kết quả có cấu trúc.
  5. Bảo đảm handler phát sinh exception khi thao tác thất bại.
  6. Chỉ sau đó mới tạo workflow sử dụng command mới ở backend.
Không nên thêm một command tổng quát kiểu javascript.execute. Các command hẹp như dom.scroll, tab.capture hoặc cookie.read dễ kiểm soát quyền, kiểm tra dữ liệu và ghi log an toàn hơn.

Kết luận

EzyChat và EzyConnector sử dụng mô hình request–response có định danh để biến socket thành một kênh điều khiển trình duyệt:
  • Backend mô tả nhiệm vụ bằng workflow.
  • Socket application xác thực và định tuyến theo quản trị viên.
  • Extension thực thi các command tuần tự.
  • Kết quả hoặc lỗi được trả về đúng request ban đầu.
Ưu điểm chính của thiết kế này là logic nghiệp vụ và selector nằm ở backend, trong khi extension giữ vai trò một runtime automation tổng quát. Phần lớn nhiệm vụ mới có thể được triển khai bằng cách tạo workflow mới mà không cần sửa extension, miễn là nhiệm vụ đó có thể ghép từ các command đã được đăng ký.