Files
SYNITYandDangTinh311 3f5e4f3cc3 feat(bitrix24): decode chat entity context into metadata [B24:2794] (#1315)
Give agents direct access to the CRM entity, Openline session, task,
and workgroup binding of an inbound chat without paying an extra RPC
per turn. Everything is derived from CHAT_ENTITY_* fields Bitrix
already ships in the ONIMBOTMESSAGEADD webhook.

New metadata keys emitted alongside the existing bitrix_chat_entity_*
pair (only present when non-empty, so DMs / plain groups pay no cost):

Every chat with a title/type:
  bitrix_chat_title, bitrix_chat_type

Openline (ChatEntityType=LINES), decoded from ENTITY_ID + DATA_1:
  bitrix_ol_connector_code   (facebook / synity_zalo_oa_chat / ...)
  bitrix_ol_line_id
  bitrix_ol_external_uid
  bitrix_ol_bitrix_user_id
  bitrix_ol_line_config_id
  bitrix_ol_session_started_at
  bitrix_ol_active_crm_type / bitrix_ol_active_crm_id

CRM linkage, decoded from CHAT_ENTITY_DATA_2 for Openline
(schema "LEAD|<id>|COMPANY|<id>|CONTACT|<id>|DEAL|<id>") or from
ENTITY_ID for native CRM-integrated chats ("CONTACT|1780"):
  bitrix_crm_lead_id / company_id / contact_id / deal_id
  bitrix_crm_type + bitrix_crm_id

Single-token ENTITY_ID cases:
  bitrix_task_id      (TASKS_TASK)
  bitrix_workgroup_id (SONET_GROUP)
  bitrix_mail_id      (MAIL)

Parsing is best-effort: malformed input (short ENTITY_ID, odd DATA_1
token count) logs a WARN and falls through to a partial map rather
than crashing the handler. Unknown ChatEntityType values pass raw
without noise so future surfaces (CALENDAR, LIVECHAT, ...) don't spam
logs before the parser knows how to decode them.

Verified live against tamgiac.bitrix24.com with three connectors
(Facebook, Zalo OA, Zalo personal) plus CRM/Task/Workgroup/Mail
sample events. Table-driven test covers 13 shapes including malformed
input and the NONE/0 edge cases.

Surface parity:
  - Gateway server: parser + wire in handle.go
  - API contract: N/A because the change adds string entries to the
    already-existing metadata map without a schema.
  - Web UI: N/A because the metadata is consumed by agents, not surfaced.
  - CLI/runtime: N/A because no CLI command reads these keys.

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
2026-07-01 15:29:00 +07:00

330 lines
9.4 KiB
Go

package bitrix24
import (
"testing"
)
// TestParseEntityContext_TableDriven covers every chat surface goclaw currently
// receives from a tamgiac.bitrix24.com portal (verified via raw webhook dumps)
// plus a handful of malformed-input cases so future Bitrix schema drift does
// not crash the handler. Each row asserts on the produced metadata map — the
// unit under test is the composition of ParseEntityContext + ToMeta, matching
// what handle.go actually calls.
func TestParseEntityContext_TableDriven(t *testing.T) {
tests := []struct {
name string
params EventParams
wantOK bool
wantKeys map[string]string // subset — the row's "must have" keys
absent []string // keys that MUST NOT appear
}{
{
name: "openline facebook full payload with lead",
params: EventParams{
ChatEntityType: "LINES",
ChatEntityID: "facebook|34|36305652112415023|1074",
ChatEntityData1: "Y|LEAD|360|N|N|1018|1782879230|0|0|",
ChatEntityData2: "LEAD|360|COMPANY|0|CONTACT|0|DEAL|0",
ChatEntityData3: "N",
ChatTitle: "Tình Đặng - TEst Fanpage 1",
ChatType: "L",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatTitle: "Tình Đặng - TEst Fanpage 1",
MetaKeyChatType: "L",
MetaKeyOLConnectorCode: "facebook",
MetaKeyOLLineID: "34",
MetaKeyOLExternalUID: "36305652112415023",
MetaKeyOLBitrixUserID: "1074",
MetaKeyOLLineConfigID: "1018",
MetaKeyOLSessionStarted: "1782879230",
MetaKeyOLActiveCRMType: "LEAD",
MetaKeyOLActiveCRMID: "360",
MetaKeyCRMLeadID: "360",
},
absent: []string{
MetaKeyCRMCompanyID, MetaKeyCRMContactID, MetaKeyCRMDealID,
MetaKeyTaskID, MetaKeyWorkgroupID, MetaKeyMailID,
},
},
{
name: "openline zalo OA with contact and deal linked",
params: EventParams{
ChatEntityType: "LINES",
ChatEntityID: "synity_zalo_oa_chat|24|zalo_chat_3726955208051041740|840",
ChatEntityData1: "Y|DEAL|2054|N|N|1020|1782879518|0|0|0",
ChatEntityData2: "LEAD|0|COMPANY|0|CONTACT|1266|DEAL|2054",
ChatTitle: "Đặng Tình - ZaloOA SYNITY",
ChatType: "L",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyOLConnectorCode: "synity_zalo_oa_chat",
MetaKeyOLLineID: "24",
MetaKeyOLLineConfigID: "1020",
MetaKeyOLActiveCRMType: "DEAL",
MetaKeyOLActiveCRMID: "2054",
MetaKeyCRMContactID: "1266",
MetaKeyCRMDealID: "2054",
},
absent: []string{
MetaKeyCRMLeadID, MetaKeyCRMCompanyID, // both were 0 in DATA_2
},
},
{
name: "openline zalo personal with empty DATA_2 (no auto-CRM)",
params: EventParams{
ChatEntityType: "LINES",
ChatEntityID: "synity_zalo_personal|20|zpersonal_1623524631958449211|878",
ChatEntityData1: "N|NONE|0|N|N|1002|1782787104|0|0|0",
ChatEntityData2: "", // connector without auto-CRM config
ChatTitle: "Thân Công Huy - Zalo Synity 0964575404",
ChatType: "L",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyOLConnectorCode: "synity_zalo_personal",
MetaKeyOLLineID: "20",
MetaKeyOLLineConfigID: "1002",
},
absent: []string{
// pos 2 == "NONE" → active CRM must NOT be emitted.
MetaKeyOLActiveCRMType, MetaKeyOLActiveCRMID,
// DATA_2 empty → no CRM linkage keys at all.
MetaKeyCRMLeadID, MetaKeyCRMCompanyID,
MetaKeyCRMContactID, MetaKeyCRMDealID,
},
},
{
name: "native CRM contact chat",
params: EventParams{
ChatEntityType: "CRM",
ChatEntityID: "CONTACT|1780",
ChatType: "C",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatType: "C",
MetaKeyCRMEntityType: "CONTACT",
MetaKeyCRMEntityID: "1780",
MetaKeyCRMContactID: "1780", // mirrored for type-agnostic lookup
},
absent: []string{
MetaKeyCRMLeadID, MetaKeyCRMCompanyID, MetaKeyCRMDealID,
MetaKeyOLConnectorCode, MetaKeyTaskID,
},
},
{
name: "native CRM deal chat",
params: EventParams{
ChatEntityType: "CRM",
ChatEntityID: "DEAL|2064",
ChatType: "C",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyCRMEntityType: "DEAL",
MetaKeyCRMEntityID: "2064",
MetaKeyCRMDealID: "2064",
},
},
{
name: "task chat",
params: EventParams{
ChatEntityType: "TASKS_TASK",
ChatEntityID: "2794",
ChatTitle: "Tích hợp channel bitrix24 vào goclaw",
ChatType: "X",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatTitle: "Tích hợp channel bitrix24 vào goclaw",
MetaKeyChatType: "X",
MetaKeyTaskID: "2794",
},
absent: []string{
MetaKeyWorkgroupID, MetaKeyMailID, MetaKeyCRMEntityID,
},
},
{
name: "sonet workgroup chat",
params: EventParams{
ChatEntityType: "SONET_GROUP",
ChatEntityID: "64",
ChatTitle: "Chuyển Đổi",
ChatType: "B",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatTitle: "Chuyển Đổi",
MetaKeyChatType: "B",
MetaKeyWorkgroupID: "64",
},
absent: []string{MetaKeyTaskID, MetaKeyMailID},
},
{
name: "bitrix mail chat",
params: EventParams{
ChatEntityType: "MAIL",
ChatEntityID: "31200",
ChatType: "C",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatType: "C",
MetaKeyMailID: "31200",
},
},
{
name: "plain group with no entity binding",
params: EventParams{},
wantOK: false,
},
{
name: "plain chat with just a title (still exposed)",
params: EventParams{
ChatTitle: "Ad-hoc discussion",
ChatType: "C",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatTitle: "Ad-hoc discussion",
MetaKeyChatType: "C",
},
absent: []string{
MetaKeyOLConnectorCode, MetaKeyCRMEntityID, MetaKeyTaskID,
},
},
{
name: "malformed openline ENTITY_ID with only 3 tokens (missing bot user)",
params: EventParams{
ChatEntityType: "LINES",
ChatEntityID: "facebook|34|abc",
ChatType: "L",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyOLConnectorCode: "facebook",
MetaKeyOLLineID: "34",
MetaKeyOLExternalUID: "abc",
},
absent: []string{MetaKeyOLBitrixUserID},
},
{
name: "malformed DATA_1 with only 5 tokens (still extract active CRM)",
params: EventParams{
ChatEntityType: "LINES",
ChatEntityID: "facebook|34|x|y",
ChatEntityData1: "Y|LEAD|360|N|N",
ChatType: "L",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyOLActiveCRMType: "LEAD",
MetaKeyOLActiveCRMID: "360",
},
absent: []string{
MetaKeyOLLineConfigID, MetaKeyOLSessionStarted,
},
},
{
name: "malformed CRM ENTITY_ID with single token (log warn, skip)",
params: EventParams{
ChatEntityType: "CRM",
ChatEntityID: "12345",
ChatType: "C",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatType: "C",
},
absent: []string{
MetaKeyCRMEntityType, MetaKeyCRMEntityID,
},
},
{
name: "unknown ENTITY_TYPE still surfaces title / chat_type",
params: EventParams{
ChatEntityType: "CALENDAR_EVENT",
ChatEntityID: "999",
ChatTitle: "Standup",
ChatType: "C",
},
wantOK: true,
wantKeys: map[string]string{
MetaKeyChatTitle: "Standup",
MetaKeyChatType: "C",
},
absent: []string{MetaKeyTaskID, MetaKeyMailID},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ec, ok := ParseEntityContext(&tc.params)
if ok != tc.wantOK {
t.Fatalf("ParseEntityContext ok = %v, want %v", ok, tc.wantOK)
}
if !ok {
return
}
meta := ec.ToMeta(&tc.params)
for k, want := range tc.wantKeys {
got, present := meta[k]
if !present {
t.Errorf("meta[%q] missing, want %q", k, want)
continue
}
if got != want {
t.Errorf("meta[%q] = %q, want %q", k, got, want)
}
}
for _, k := range tc.absent {
if v, present := meta[k]; present {
t.Errorf("meta[%q] = %q, want absent", k, v)
}
}
})
}
}
// TestParseEntityContext_NilSafe guards against a nil EventParams pointer —
// callers should not do this but defence-in-depth prevents a panic if the
// bus somehow delivers a stub event.
func TestParseEntityContext_NilSafe(t *testing.T) {
ec, ok := ParseEntityContext(nil)
if ok {
t.Fatalf("ParseEntityContext(nil) ok = true, want false")
}
meta := ec.ToMeta(nil)
if len(meta) != 0 {
t.Fatalf("ToMeta(nil) returned %d keys, want 0: %v", len(meta), meta)
}
}
// TestParseEntityContext_ZeroActiveCRMIDIgnored ensures pos 3 == "0" in DATA_1
// does not leak an OL_ACTIVE_CRM_ID="0" into meta — that would look like a real
// id to downstream consumers.
func TestParseEntityContext_ZeroActiveCRMIDIgnored(t *testing.T) {
p := EventParams{
ChatEntityType: "LINES",
ChatEntityID: "facebook|34|x|y",
ChatEntityData1: "Y|LEAD|0|N|N|1018|1782879230|0|0|0",
ChatType: "L",
}
ec, ok := ParseEntityContext(&p)
if !ok {
t.Fatalf("expected ok=true")
}
meta := ec.ToMeta(&p)
if v, present := meta[MetaKeyOLActiveCRMID]; present {
t.Errorf("meta[%q] = %q, want absent (id was 0)", MetaKeyOLActiveCRMID, v)
}
// The type "LEAD" is still meaningful — "we know a Lead is in focus but
// its id has not been minted yet". Keep it.
if v := meta[MetaKeyOLActiveCRMType]; v != "LEAD" {
t.Errorf("meta[%q] = %q, want %q", MetaKeyOLActiveCRMType, v, "LEAD")
}
}