Skip to main content

Handoff to Human Agents (Mbox Agent Handoff)

Overview​

"Mbox" is the live chat system that lets human agents take over a conversation with a LINE user in place of the bot. Under the hood it is Chatwoot. This feature is the logic that decides whether a given message should be answered by the bot or escalated to a person — and it has to live in webhook-go because the decision must be made before the message reaches the bot.

The core of the mechanism is the Redis key agent_mode:{lineOaId}:{userId}. Its presence means the user is currently talking to a human agent, and during that time the bot must stay completely silent, meaning nothing is published to the line_webhook queue.

All of this only runs when webhook_config.mboxEnabled == "1". When disabled, events flow through the normal default path.

Business Flow​

A. Message events​

Only the first message event is considered, and its message.text is trimmed and lowercased.

  1. Derive lineOaId from the config and userId from event.source.userId.
  2. Read agent_mode with HGETALL; a non-empty hash means the user is in agent mode. A read error is logged and halts the whole pipeline.
  3. Parse two keyword sets from the config — mboxExitKeywords and mboxAgentKeywords. Both are JSON arrays of strings, lowercased on load. If parsing fails, the value falls back to a comma-separated list with whitespace trimmed and empty entries dropped.

Case A1 — the user is already in agent mode

  1. If the message matches an exit keyword, publish to the mbox_handoff queue with type: "exit". worker-go then closes the Chatwoot conversation and deletes the agent_mode key.
  2. Otherwise, forward the message to mboxLineWebhookUrl (see Forwarding Webhooks to Customer Systems) and write lastActivity as an epoch timestamp in milliseconds into agent_mode, with no TTL.
  3. Either way, the handler returns immediately without publishing to line_webhook, which is what keeps the bot quiet.

Case A2 — the user is not yet in agent mode

  1. If the message matches an agent keyword (for example "talk to an agent"), the handoff path begins:
    • When mboxDepartmentPickerEnabled is not "1", publish with type: "handoff" and the standard mboxConfig.
    • When mboxDepartmentPickerEnabled is "1", publish with type: "department_picker" and an mboxConfig that includes a departmentPicker block containing enabled, headerText, generalLabel, and departments. The departments list is parsed from the JSON string in mboxDepartments; a parse failure yields an empty array.
    • Then return, bypassing the bot.
  2. If no keyword matches, the event falls through to the normal path and is published to line_webhook for the bot to handle.

B. Postback events — department selection​

  1. Read postback.data from the first postback event.
  2. If it starts with mbox_team:, take the value after the colon as teamId.
  3. If source.userId is present, publish to mbox_handoff with type: "handoff" plus the teamId, then return.
  4. If there is no userId, the handler does not return; it falls through to the appt_cancel: check (see Cancelling Appointments via Postback).

Shape of the mbox_handoff payload​

{
"type": "handoff | department_picker | exit",
"lineOaId": 123,
"userId": "LINE user id",
"teamId": "optional, mbox_team only",
"webhookPayload": { "webhookId": "...", "headers": {}, "body": {} },
"mboxConfig": {
"enabled": true,
"baseUrl": "...",
"apiToken": "...",
"accountId": "...",
"inboxId": "...",
"lineWebhookUrl": "...",
"timeoutMinutes": 30,
"warningMinutes": 25,
"greetingMessage": "...",
"warningMessage": "...",
"endMessage": "...",
"timeoutMessage": "...",
"exitKeywords": [],
"agentKeywords": [],
"departmentPicker": {}
}
}

:::warning Unintentional inconsistency (parity note) The handoff branch triggered by an agent keyword sends BodyRaw, the original bytes, along with the payload. The exit and mbox_team branches do not set BodyRaw, so their outgoing body is re-marshalled from the map with reordered keys, which invalidates the signature in those two cases (see Forwarding the Raw Body and Signature). :::

Key Files & Functions​

All the code lives in internal/line/service.go — there is no separate package — and is reached through the POST /api/line/:id endpoint (see LINE Webhook Gateway).

FunctionResponsibility
Service.ProcessLineThe main dispatcher, covering both the message and postback stages
buildMboxConfigMaps the Redis hash into a config struct
Service.deptPickerConfigSpreads mboxConfig and attaches the departmentPicker block
parseKeywordsConverts a JSON array into a lowercased string slice, with a comma-separated fallback
contains, messageText, postbackData, sourceUserID, splitSecondHelpers
Types mboxHandoffPayload, mboxConfig, webhookPayloadPayload structures

Connections to Other Services​

  • Redis — webhook_config supplies all mbox settings and agent_mode holds session state (see Redis Cache for Webhook Config).
  • RabbitMQ — the mbox_handoff queue, configured via RABBITMQ_QUEUE_MBOX_HANDOFF.
  • worker-go — consumes mbox_handoff, creating and closing Chatwoot conversations, creating and deleting the agent_mode key, sending the greeting, warning, end, and timeout messages, and managing session timeouts.
  • Chatwoot (Mbox) — an external system. Its credentials (baseUrl, apiToken, accountId, inboxId) travel with every payload; this service never calls the Chatwoot API itself.
  • The return path — Chatwoot calls back into POST /api/mbox/callback/:oaHash (see Receiving Chatwoot Callbacks).
  • cms-api — the per-OA mbox settings screen, covering keywords, messages, and departments, lives in the line-oa-management module.