mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
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>
This commit is contained in:
1 parent
d9a15be86c
commit
3f5e4f3cc3
5 files changed
+738
No files matched your search
@@ -0,0 +1,302 @@
|
||||
package bitrix24
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EntityContext holds the decoded Bitrix24 chat-entity binding for a single
|
||||
// inbound message. It is derived from CHAT_ENTITY_TYPE / CHAT_ENTITY_ID plus
|
||||
// the opaque CHAT_ENTITY_DATA_1 / CHAT_ENTITY_DATA_2 payloads Bitrix ships
|
||||
// alongside them.
|
||||
//
|
||||
// Purpose: give agents (and MCP tools) type-safe access to "which entity does
|
||||
// this chat represent?" without regex-splitting raw pipe-delimited strings or
|
||||
// paying an extra RPC. Zero value means the chat has no decodable binding
|
||||
// (plain group / DM), and ParseEntityContext returns ok=false in that case.
|
||||
//
|
||||
// Fields are populated on a best-effort basis: malformed input is logged at
|
||||
// WARN level but does not fail — partial data (e.g. connector code + line but
|
||||
// missing bitrix_user_id) is preferred over dropping the whole context.
|
||||
type EntityContext struct {
|
||||
// Openline (ChatEntityType == "LINES") — decoded from CHAT_ENTITY_ID:
|
||||
// "<connector_code>|<line_id>|<external_uid>|<bitrix_user_id>"
|
||||
// Example: "facebook|34|36305652112415023|1074"
|
||||
OLConnectorCode string
|
||||
OLLineID string
|
||||
OLExternalUID string
|
||||
OLBitrixUserID string
|
||||
|
||||
// Openline session state — decoded from CHAT_ENTITY_DATA_1 (10 tokens):
|
||||
// "<wait>|<activeType>|<activeID>|<silent>|<spam>|<lineCfg>|<started>|<unanswered>|<parent>|<closed>"
|
||||
// Example: "Y|DEAL|2054|N|N|1020|1782879518|0|0|0"
|
||||
OLLineConfigID string // pos 6 (line_config_id — 1002 Zalo, 1018 FB, 1020 Zalo OA)
|
||||
OLSessionStarted string // pos 7 (unix ts, stored as string to keep the raw wire value)
|
||||
OLActiveCRMType string // pos 2 (LEAD / DEAL / CONTACT / COMPANY / NONE)
|
||||
OLActiveCRMID string // pos 3 (0 = unset)
|
||||
|
||||
// CRM linkage — decoded from CHAT_ENTITY_DATA_2 (4 slots, fixed order):
|
||||
// "LEAD|<id>|COMPANY|<id>|CONTACT|<id>|DEAL|<id>"
|
||||
// A value of "0" means the connector has not linked this entity yet.
|
||||
// Empty string means Bitrix did not ship a value in the payload.
|
||||
CRMLeadID string
|
||||
CRMCompanyID string
|
||||
CRMContactID string
|
||||
CRMDealID string
|
||||
|
||||
// Native CRM-integrated chat (ChatEntityType == "CRM"). ENTITY_ID is
|
||||
// a 2-token "<TYPE>|<id>" like "CONTACT|1780" or "DEAL|2064".
|
||||
CRMType string
|
||||
CRMID string
|
||||
|
||||
// Single-token ENTITY_ID cases: TASKS_TASK / SONET_GROUP / MAIL.
|
||||
TaskID string
|
||||
WorkgroupID string
|
||||
MailID string
|
||||
}
|
||||
|
||||
// ParseEntityContext decodes an EventParams into an EntityContext. It returns
|
||||
// ok=false when the chat carries no entity binding at all (plain group / DM),
|
||||
// so callers can skip the ToMeta merge cheaply.
|
||||
//
|
||||
// The function never panics on malformed input — bad payloads log a WARN and
|
||||
// return whatever fields could be recovered.
|
||||
func ParseEntityContext(p *EventParams) (EntityContext, bool) {
|
||||
if p == nil {
|
||||
return EntityContext{}, false
|
||||
}
|
||||
if p.ChatEntityType == "" && p.ChatTitle == "" && p.ChatType == "" {
|
||||
// No binding, no title, no chat-type letter → nothing to expose.
|
||||
return EntityContext{}, false
|
||||
}
|
||||
|
||||
ec := EntityContext{}
|
||||
switch p.ChatEntityType {
|
||||
case "LINES":
|
||||
ec.fillOpenline(p)
|
||||
case "CRM":
|
||||
ec.fillCRMChat(p)
|
||||
case "TASKS_TASK":
|
||||
ec.TaskID = strings.TrimSpace(p.ChatEntityID)
|
||||
case "SONET_GROUP":
|
||||
ec.WorkgroupID = strings.TrimSpace(p.ChatEntityID)
|
||||
case "MAIL":
|
||||
ec.MailID = strings.TrimSpace(p.ChatEntityID)
|
||||
case "":
|
||||
// No entity type but title / chat_type may still exist — that is
|
||||
// fine, ToMeta will still emit those two keys via the caller.
|
||||
default:
|
||||
// Unknown but non-empty type — record raw ENTITY_ID as best effort.
|
||||
// Do not warn: Bitrix may introduce new types (CALENDAR, LIVECHAT,
|
||||
// DOCS, ...); noisy WARN spam would obscure real parse errors.
|
||||
slog.Debug("bitrix24 entity: unknown ChatEntityType — passing raw",
|
||||
"chat_entity_type", p.ChatEntityType,
|
||||
"chat_entity_id", p.ChatEntityID)
|
||||
}
|
||||
|
||||
return ec, true
|
||||
}
|
||||
|
||||
// fillOpenline decodes the Openline-specific fields off CHAT_ENTITY_ID and
|
||||
// CHAT_ENTITY_DATA_1 / DATA_2. All three are best-effort — Bitrix has been
|
||||
// seen to ship empty DATA_2 on Zalo-personal connectors that lack auto-CRM.
|
||||
func (ec *EntityContext) fillOpenline(p *EventParams) {
|
||||
// CHAT_ENTITY_ID: 4 tokens.
|
||||
if id := strings.TrimSpace(p.ChatEntityID); id != "" {
|
||||
tokens := strings.Split(id, "|")
|
||||
if len(tokens) < 4 {
|
||||
slog.Warn("bitrix24 entity: openline CHAT_ENTITY_ID has fewer than 4 tokens",
|
||||
"chat_entity_id", id,
|
||||
"tokens", len(tokens))
|
||||
}
|
||||
if len(tokens) >= 1 {
|
||||
ec.OLConnectorCode = strings.TrimSpace(tokens[0])
|
||||
}
|
||||
if len(tokens) >= 2 {
|
||||
ec.OLLineID = strings.TrimSpace(tokens[1])
|
||||
}
|
||||
if len(tokens) >= 3 {
|
||||
ec.OLExternalUID = strings.TrimSpace(tokens[2])
|
||||
}
|
||||
if len(tokens) >= 4 {
|
||||
ec.OLBitrixUserID = strings.TrimSpace(tokens[3])
|
||||
}
|
||||
}
|
||||
|
||||
// CHAT_ENTITY_DATA_1: 10 tokens (session state). Missing / short is
|
||||
// common (some connectors trail an empty final token); take what we can.
|
||||
if d1 := strings.TrimSpace(p.ChatEntityData1); d1 != "" {
|
||||
tokens := strings.Split(d1, "|")
|
||||
if len(tokens) < 10 {
|
||||
slog.Warn("bitrix24 entity: openline DATA_1 has fewer than 10 tokens",
|
||||
"data_1", d1,
|
||||
"tokens", len(tokens))
|
||||
}
|
||||
if len(tokens) >= 3 {
|
||||
activeType := strings.TrimSpace(tokens[1])
|
||||
activeID := strings.TrimSpace(tokens[2])
|
||||
// pos 2 = "NONE" when no CRM entity is focused; treat as absent.
|
||||
if !strings.EqualFold(activeType, "NONE") && activeType != "" {
|
||||
ec.OLActiveCRMType = activeType
|
||||
if activeID != "" && activeID != "0" {
|
||||
ec.OLActiveCRMID = activeID
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tokens) >= 6 {
|
||||
ec.OLLineConfigID = strings.TrimSpace(tokens[5])
|
||||
}
|
||||
if len(tokens) >= 7 {
|
||||
started := strings.TrimSpace(tokens[6])
|
||||
// Sanity check — must parse as int64. Anything else is noise.
|
||||
if _, err := strconv.ParseInt(started, 10, 64); err == nil && started != "0" {
|
||||
ec.OLSessionStarted = started
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CHAT_ENTITY_DATA_2: 4 slots × 2 tokens = 8 tokens.
|
||||
// "LEAD|<id>|COMPANY|<id>|CONTACT|<id>|DEAL|<id>"
|
||||
// Parse as label→id pairs so a future re-ordering by Bitrix would still
|
||||
// map correctly. Empty DATA_2 means "no auto-CRM configured" — silent.
|
||||
if d2 := strings.TrimSpace(p.ChatEntityData2); d2 != "" {
|
||||
tokens := strings.Split(d2, "|")
|
||||
if len(tokens)%2 != 0 {
|
||||
slog.Warn("bitrix24 entity: openline DATA_2 has odd token count",
|
||||
"data_2", d2,
|
||||
"tokens", len(tokens))
|
||||
}
|
||||
for i := 0; i+1 < len(tokens); i += 2 {
|
||||
label := strings.ToUpper(strings.TrimSpace(tokens[i]))
|
||||
id := strings.TrimSpace(tokens[i+1])
|
||||
if id == "" || id == "0" {
|
||||
continue
|
||||
}
|
||||
switch label {
|
||||
case "LEAD":
|
||||
ec.CRMLeadID = id
|
||||
case "COMPANY":
|
||||
ec.CRMCompanyID = id
|
||||
case "CONTACT":
|
||||
ec.CRMContactID = id
|
||||
case "DEAL":
|
||||
ec.CRMDealID = id
|
||||
default:
|
||||
slog.Warn("bitrix24 entity: openline DATA_2 unknown label",
|
||||
"label", label,
|
||||
"id", id,
|
||||
"data_2", d2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillCRMChat decodes ENTITY_ID for a native CRM-integrated chat. Expected
|
||||
// shape is 2 tokens "<TYPE>|<id>", e.g. "CONTACT|1780" or "DEAL|2064".
|
||||
func (ec *EntityContext) fillCRMChat(p *EventParams) {
|
||||
id := strings.TrimSpace(p.ChatEntityID)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
tokens := strings.Split(id, "|")
|
||||
if len(tokens) < 2 {
|
||||
slog.Warn("bitrix24 entity: CRM CHAT_ENTITY_ID has fewer than 2 tokens",
|
||||
"chat_entity_id", id,
|
||||
"tokens", len(tokens))
|
||||
return
|
||||
}
|
||||
ec.CRMType = strings.ToUpper(strings.TrimSpace(tokens[0]))
|
||||
ec.CRMID = strings.TrimSpace(tokens[1])
|
||||
// Convenience: also populate the per-type CRM* id fields so downstream
|
||||
// consumers can key off a single name regardless of chat surface.
|
||||
if ec.CRMID != "" && ec.CRMID != "0" {
|
||||
switch ec.CRMType {
|
||||
case "LEAD":
|
||||
ec.CRMLeadID = ec.CRMID
|
||||
case "COMPANY":
|
||||
ec.CRMCompanyID = ec.CRMID
|
||||
case "CONTACT":
|
||||
ec.CRMContactID = ec.CRMID
|
||||
case "DEAL":
|
||||
ec.CRMDealID = ec.CRMID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ToMeta materialises the EntityContext into a flat metadata map. Only
|
||||
// non-empty values are emitted so callers merging into an existing map do
|
||||
// not overwrite keys that were never populated.
|
||||
//
|
||||
// CHAT_TITLE and CHAT_TYPE are read from the EventParams passed in — they
|
||||
// belong to every chat, not just those with an ENTITY binding, so they are
|
||||
// emitted whenever present regardless of ok.
|
||||
func (ec *EntityContext) ToMeta(p *EventParams) map[string]string {
|
||||
out := make(map[string]string, 12)
|
||||
|
||||
if p != nil {
|
||||
if v := strings.TrimSpace(p.ChatTitle); v != "" {
|
||||
out[MetaKeyChatTitle] = v
|
||||
}
|
||||
if v := strings.TrimSpace(p.ChatType); v != "" {
|
||||
out[MetaKeyChatType] = v
|
||||
}
|
||||
}
|
||||
|
||||
if ec.OLConnectorCode != "" {
|
||||
out[MetaKeyOLConnectorCode] = ec.OLConnectorCode
|
||||
}
|
||||
if ec.OLLineID != "" {
|
||||
out[MetaKeyOLLineID] = ec.OLLineID
|
||||
}
|
||||
if ec.OLExternalUID != "" {
|
||||
out[MetaKeyOLExternalUID] = ec.OLExternalUID
|
||||
}
|
||||
if ec.OLBitrixUserID != "" {
|
||||
out[MetaKeyOLBitrixUserID] = ec.OLBitrixUserID
|
||||
}
|
||||
if ec.OLLineConfigID != "" {
|
||||
out[MetaKeyOLLineConfigID] = ec.OLLineConfigID
|
||||
}
|
||||
if ec.OLSessionStarted != "" {
|
||||
out[MetaKeyOLSessionStarted] = ec.OLSessionStarted
|
||||
}
|
||||
if ec.OLActiveCRMType != "" {
|
||||
out[MetaKeyOLActiveCRMType] = ec.OLActiveCRMType
|
||||
}
|
||||
if ec.OLActiveCRMID != "" {
|
||||
out[MetaKeyOLActiveCRMID] = ec.OLActiveCRMID
|
||||
}
|
||||
|
||||
if ec.CRMLeadID != "" {
|
||||
out[MetaKeyCRMLeadID] = ec.CRMLeadID
|
||||
}
|
||||
if ec.CRMCompanyID != "" {
|
||||
out[MetaKeyCRMCompanyID] = ec.CRMCompanyID
|
||||
}
|
||||
if ec.CRMContactID != "" {
|
||||
out[MetaKeyCRMContactID] = ec.CRMContactID
|
||||
}
|
||||
if ec.CRMDealID != "" {
|
||||
out[MetaKeyCRMDealID] = ec.CRMDealID
|
||||
}
|
||||
|
||||
if ec.CRMType != "" {
|
||||
out[MetaKeyCRMEntityType] = ec.CRMType
|
||||
}
|
||||
if ec.CRMID != "" {
|
||||
out[MetaKeyCRMEntityID] = ec.CRMID
|
||||
}
|
||||
|
||||
if ec.TaskID != "" {
|
||||
out[MetaKeyTaskID] = ec.TaskID
|
||||
}
|
||||
if ec.WorkgroupID != "" {
|
||||
out[MetaKeyWorkgroupID] = ec.WorkgroupID
|
||||
}
|
||||
if ec.MailID != "" {
|
||||
out[MetaKeyMailID] = ec.MailID
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,36 @@ type EventParams struct {
|
||||
ChatEntityType string
|
||||
ChatEntityID string
|
||||
|
||||
// ChatTitle mirrors data[PARAMS][CHAT_TITLE]. Bitrix pre-formats
|
||||
// human-readable names — e.g. "Thân Công Huy - Zalo Synity 0964575404"
|
||||
// for Openline connector chats, "Tích hợp channel bitrix24 vào goclaw"
|
||||
// for Task chats. Absent on 1-1 DMs. Forwarded as metadata so agents
|
||||
// can display / reason about "who am I talking to" without extra RPCs.
|
||||
ChatTitle string
|
||||
|
||||
// ChatType mirrors data[PARAMS][CHAT_TYPE]. Single-letter code that
|
||||
// classifies the chat surface more precisely than ChatEntityType:
|
||||
// P = private (1-1 DM) C = group chat / CRM chat
|
||||
// L = Open Line X = external chat (Tasks)
|
||||
// B = collab / workgroup O = open chat
|
||||
// S = system/notify N = channel
|
||||
// J = open channel T = comment thread
|
||||
// A = copilot chat
|
||||
ChatType string
|
||||
|
||||
// ChatEntityData1/2/3 are opaque Bitrix-internal payloads keyed to the
|
||||
// chat entity. For Openline sessions:
|
||||
// DATA_1 = "Y|DEAL|2054|N|N|1020|1782879518|0|0|0"
|
||||
// (10 tokens — session state incl. active CRM entity, line id, started_at)
|
||||
// DATA_2 = "LEAD|0|COMPANY|0|CONTACT|1266|DEAL|2054"
|
||||
// (4 slots — CRM linkage: LEAD, COMPANY, CONTACT, DEAL ids)
|
||||
// DATA_3 = "N" (flag, meaning TBD)
|
||||
// Kept as raw strings; decoding lives in entity_context.go so the parse
|
||||
// path stays a pure copy.
|
||||
ChatEntityData1 string
|
||||
ChatEntityData2 string
|
||||
ChatEntityData3 string
|
||||
|
||||
// FromIsConnector mirrors data[USER][IS_CONNECTOR]. In Bitrix24 Open
|
||||
// Channel sessions (MESSAGE_TYPE=L), real customers come in through a
|
||||
// connector (Zalo, FB, etc.) and IS_CONNECTOR=Y. Internal staff who join
|
||||
@@ -207,6 +237,11 @@ func parseFormEvent(v url.Values) (*Event, error) {
|
||||
ReplyToMID: formGet(v, "data", "PARAMS", "REPLY_TO_MESSAGE_ID"),
|
||||
ChatEntityType: formGet(v, "data", "PARAMS", "CHAT_ENTITY_TYPE"),
|
||||
ChatEntityID: formGet(v, "data", "PARAMS", "CHAT_ENTITY_ID"),
|
||||
ChatTitle: formGet(v, "data", "PARAMS", "CHAT_TITLE"),
|
||||
ChatType: formGet(v, "data", "PARAMS", "CHAT_TYPE"),
|
||||
ChatEntityData1: formGet(v, "data", "PARAMS", "CHAT_ENTITY_DATA_1"),
|
||||
ChatEntityData2: formGet(v, "data", "PARAMS", "CHAT_ENTITY_DATA_2"),
|
||||
ChatEntityData3: formGet(v, "data", "PARAMS", "CHAT_ENTITY_DATA_3"),
|
||||
}
|
||||
if s := formGet(v, "data", "PARAMS", "SYSTEM"); s == "Y" {
|
||||
p.SystemMessage = true
|
||||
@@ -370,6 +405,11 @@ func parseJSONEvent(body io.ReadCloser) (*Event, error) {
|
||||
ReplyToMID any `json:"REPLY_TO_MESSAGE_ID"`
|
||||
ChatEntityType string `json:"CHAT_ENTITY_TYPE"`
|
||||
ChatEntityID string `json:"CHAT_ENTITY_ID"`
|
||||
ChatTitle string `json:"CHAT_TITLE"`
|
||||
ChatType string `json:"CHAT_TYPE"`
|
||||
ChatEntityData1 string `json:"CHAT_ENTITY_DATA_1"`
|
||||
ChatEntityData2 string `json:"CHAT_ENTITY_DATA_2"`
|
||||
ChatEntityData3 string `json:"CHAT_ENTITY_DATA_3"`
|
||||
// Nested PARAMS holds UI component metadata. COMPONENT_ID=
|
||||
// HiddenMessage marks a whisper / internal-only message.
|
||||
NestedParams struct {
|
||||
@@ -432,6 +472,11 @@ func parseJSONEvent(body io.ReadCloser) (*Event, error) {
|
||||
p.ReplyToMID = asString(raw.Data.Params.ReplyToMID)
|
||||
p.ChatEntityType = raw.Data.Params.ChatEntityType
|
||||
p.ChatEntityID = raw.Data.Params.ChatEntityID
|
||||
p.ChatTitle = raw.Data.Params.ChatTitle
|
||||
p.ChatType = raw.Data.Params.ChatType
|
||||
p.ChatEntityData1 = raw.Data.Params.ChatEntityData1
|
||||
p.ChatEntityData2 = raw.Data.Params.ChatEntityData2
|
||||
p.ChatEntityData3 = raw.Data.Params.ChatEntityData3
|
||||
p.FromIsConnector = strings.EqualFold(raw.Data.User.IsConnector, "Y")
|
||||
p.IsHiddenMessage = raw.Data.Params.NestedParams.ComponentID == "HiddenMessage"
|
||||
if len(raw.Data.Params.MentionedList) > 0 {
|
||||
|
||||
@@ -339,6 +339,17 @@ func (c *Channel) handleMessage(ctx context.Context, evt *Event) {
|
||||
meta["bitrix_chat_entity_id"] = evt.Params.ChatEntityID
|
||||
}
|
||||
|
||||
// Decoded entity context — pull out the per-connector fields from the
|
||||
// raw ENTITY_ID / ENTITY_DATA_* payloads (Openline session state, CRM
|
||||
// linkage, single-token task / workgroup / mail ids) plus CHAT_TITLE
|
||||
// and CHAT_TYPE. Only non-empty keys are emitted so DMs / plain groups
|
||||
// pay no cost. See entity_context.go for parser semantics.
|
||||
if ec, ok := ParseEntityContext(&evt.Params); ok {
|
||||
for k, v := range ec.ToMeta(&evt.Params) {
|
||||
meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Collect contact for processed messages (matches Telegram pattern at
|
||||
// channels/telegram/handlers.go:617-630). Runs AFTER policy gating so
|
||||
// blocked senders aren't recorded, and BEFORE HandleMessage so the
|
||||
|
||||
@@ -41,6 +41,57 @@ const (
|
||||
// name-only, or non-connector (operator) messages — those degrade safely to the
|
||||
// group-level userID.
|
||||
MetaKeyParticipantUserID = "participant_user_id"
|
||||
|
||||
// ---- Chat entity context keys (populated from EntityContext.ToMeta) ----
|
||||
//
|
||||
// Emitted by entity_context.go on top of the raw bitrix_chat_entity_type /
|
||||
// bitrix_chat_entity_id fields already set by handle.go. Only present when
|
||||
// the source Bitrix payload carries a decodable value, so agents / tools can
|
||||
// treat "key missing" as "not applicable" without extra flags.
|
||||
|
||||
// MetaKeyChatTitle is the human-readable chat name Bitrix ships in
|
||||
// data[PARAMS][CHAT_TITLE]. Populated for every chat that has a title
|
||||
// (group chats, Open Channel sessions, tasks, workgroups). Absent on
|
||||
// 1-1 DMs where Bitrix does not attach a title.
|
||||
MetaKeyChatTitle = "bitrix_chat_title"
|
||||
|
||||
// MetaKeyChatType mirrors data[PARAMS][CHAT_TYPE] — the single-letter
|
||||
// Bitrix chat classifier ("L" openline, "C" group/CRM, "X" tasks, "B"
|
||||
// collab, "P" private, ...). More precise than ChatEntityType for
|
||||
// disambiguating surfaces.
|
||||
MetaKeyChatType = "bitrix_chat_type"
|
||||
|
||||
// Openline (LINES) session fields, decoded from CHAT_ENTITY_ID
|
||||
// ("<connector>|<line>|<external_uid>|<bitrix_user_id>") and
|
||||
// CHAT_ENTITY_DATA_1 (10-token session state).
|
||||
MetaKeyOLConnectorCode = "bitrix_ol_connector_code"
|
||||
MetaKeyOLLineID = "bitrix_ol_line_id"
|
||||
MetaKeyOLExternalUID = "bitrix_ol_external_uid"
|
||||
MetaKeyOLBitrixUserID = "bitrix_ol_bitrix_user_id"
|
||||
MetaKeyOLLineConfigID = "bitrix_ol_line_config_id"
|
||||
MetaKeyOLSessionStarted = "bitrix_ol_session_started_at"
|
||||
MetaKeyOLActiveCRMType = "bitrix_ol_active_crm_type"
|
||||
MetaKeyOLActiveCRMID = "bitrix_ol_active_crm_id"
|
||||
|
||||
// CRM linkage — sourced from CHAT_ENTITY_DATA_2 on Openline chats
|
||||
// (schema "LEAD|<id>|COMPANY|<id>|CONTACT|<id>|DEAL|<id>"), or from
|
||||
// CHAT_ENTITY_ID on native CRM-integrated chats (2-token
|
||||
// "<TYPE>|<id>"). Only the ids that are non-zero are emitted.
|
||||
MetaKeyCRMLeadID = "bitrix_crm_lead_id"
|
||||
MetaKeyCRMCompanyID = "bitrix_crm_company_id"
|
||||
MetaKeyCRMContactID = "bitrix_crm_contact_id"
|
||||
MetaKeyCRMDealID = "bitrix_crm_deal_id"
|
||||
|
||||
// Native CRM chat scalars — from CHAT_ENTITY_ID like "CONTACT|1780".
|
||||
// Kept alongside the type-specific *_id keys so agents can either look
|
||||
// up the entity id directly or dispatch on the type token.
|
||||
MetaKeyCRMEntityType = "bitrix_crm_type"
|
||||
MetaKeyCRMEntityID = "bitrix_crm_id"
|
||||
|
||||
// Single-token ENTITY_ID cases (Task / Workgroup / Mail).
|
||||
MetaKeyTaskID = "bitrix_task_id"
|
||||
MetaKeyWorkgroupID = "bitrix_workgroup_id"
|
||||
MetaKeyMailID = "bitrix_mail_id"
|
||||
)
|
||||
|
||||
// Values for MetaKeyVisibility. Stored as strings (not bool) so callers
|
||||
|
||||
Reference in new issue
Block a user