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

  • Rediswebhook_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.