diff --git a/internal/channels/bitrix24/entity_context.go b/internal/channels/bitrix24/entity_context.go new file mode 100644 index 00000000..e2087915 --- /dev/null +++ b/internal/channels/bitrix24/entity_context.go @@ -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: + // "|||" + // Example: "facebook|34|36305652112415023|1074" + OLConnectorCode string + OLLineID string + OLExternalUID string + OLBitrixUserID string + + // Openline session state — decoded from CHAT_ENTITY_DATA_1 (10 tokens): + // "|||||||||" + // 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||COMPANY||CONTACT||DEAL|" + // 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 "|" 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||COMPANY||CONTACT||DEAL|" + // 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 "|", 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 +} diff --git a/internal/channels/bitrix24/entity_context_test.go b/internal/channels/bitrix24/entity_context_test.go new file mode 100644 index 00000000..049095d7 --- /dev/null +++ b/internal/channels/bitrix24/entity_context_test.go @@ -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") + } +} diff --git a/internal/channels/bitrix24/events.go b/internal/channels/bitrix24/events.go index 9e7b24d4..bd5d1bf1 100644 --- a/internal/channels/bitrix24/events.go +++ b/internal/channels/bitrix24/events.go @@ -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 { diff --git a/internal/channels/bitrix24/handle.go b/internal/channels/bitrix24/handle.go index 0f9b8e92..4a535682 100644 --- a/internal/channels/bitrix24/handle.go +++ b/internal/channels/bitrix24/handle.go @@ -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 diff --git a/internal/channels/bitrix24/metadata_keys.go b/internal/channels/bitrix24/metadata_keys.go index 39452a57..abe322b2 100644 --- a/internal/channels/bitrix24/metadata_keys.go +++ b/internal/channels/bitrix24/metadata_keys.go @@ -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 + // ("|||") 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||COMPANY||CONTACT||DEAL|"), or from + // CHAT_ENTITY_ID on native CRM-integrated chats (2-token + // "|"). 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