mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
Bitrix24 ships the quoted message inline on reply events via REPLY_MESSAGE,
but the channel parsed a non-existent REPLY_TO_MESSAGE_ID field, so the agent
never saw what a user was replying to. Recognize replies and feed their
context to the agent (Hybrid design):
- events.go: parse data[PARAMS][REPLY_MESSAGE][ID|AUTHOR_ID|MESSAGE] (form+json)
with PARAMS[REPLY_ID] fallback into EventParams.ReplyMessage; drop the dead
ReplyToMID field + bitrix_reply_to_mid meta.
- reply_context.go (new): resolveReplyContext prepends a short note and, for
media-only originals, re-injects the quoted file(s) through the same download
pipeline as a direct upload.
text quote -> sanitize BBCode + truncate, no API call.
media quote -> im.dialog.messages.get (works for TYPE=bot bots, unlike the
blocked imbot.v2.Chat.Message.get) to recover the attachment, bounded by
media_max_mb; note-only fallback on any fetch/download failure.
- handle.go: wire resolveReplyContext into message assembly (prefix + extra media).
Covers text, image, audio, file, video quotes. Non-reply events are a no-op.
Adds unit tests for parse (form+json), sanitize, file-id extraction, result
parsing, the messages.get fetch, and both resolve branches.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
671 lines
23 KiB
Go
671 lines
23 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Event types emitted by Bitrix24 for imbot handlers.
|
|
// Source: https://apidocs.bitrix24.com/api-reference/imbot/index.html
|
|
const (
|
|
EventMessageAdd = "ONIMBOTMESSAGEADD"
|
|
EventMessageUpdate = "ONIMBOTMESSAGEUPDATE"
|
|
EventMessageDelete = "ONIMBOTMESSAGEDELETE"
|
|
EventJoinChat = "ONIMBOTJOINCHAT"
|
|
EventBotDelete = "ONIMBOTDELETE"
|
|
EventAppUninstall = "ONAPPUNINSTALL"
|
|
)
|
|
|
|
// Event is the normalised shape of a Bitrix24 outbound webhook call.
|
|
// Bitrix posts BOTH application/x-www-form-urlencoded with square-bracket
|
|
// indexing (most common) and JSON (rare); this struct is the canonical output
|
|
// of ParseEvent regardless of wire format.
|
|
type Event struct {
|
|
Type string
|
|
Auth EventAuth
|
|
Params EventParams
|
|
Ts time.Time
|
|
// Raw keeps a copy of the form values for debugging. Only populated for
|
|
// form-urlencoded inputs; nil for JSON.
|
|
Raw url.Values
|
|
}
|
|
|
|
// EventAuth mirrors the `auth[*]` section of a Bitrix24 event POST.
|
|
// These fields are what we validate against the stored portal state to
|
|
// reject spoofed webhooks — AppToken is the stable per-install secret,
|
|
// MemberID is the stable portal id (stable across domain renames).
|
|
type EventAuth struct {
|
|
Domain string
|
|
AppToken string
|
|
AccessToken string
|
|
RefreshToken string
|
|
MemberID string
|
|
ExpiresIn int
|
|
Scope string
|
|
ServerEndpoint string
|
|
ClientEndpoint string
|
|
Status string
|
|
}
|
|
|
|
// EventParams covers the `data[PARAMS]` section plus resolved bot/user ids.
|
|
// BotID is lifted from `data[BOT][<botID>][BOT_ID]` since multiple bots may
|
|
// coexist on a portal and the payload tags which bot the event targets.
|
|
type EventParams struct {
|
|
MessageID string
|
|
BotID int
|
|
DialogID string // "chatNN" for group chats, numeric user id for DMs
|
|
ChatID string // data[PARAMS][CHAT_ID] (numeric, DM side may be 0)
|
|
FromUserID string
|
|
ToUserID string
|
|
Message string // stripped text — Bitrix removes @mentions on group chats
|
|
MessageOriginal string // raw BBCode (`[USER=<id>]…[/USER]`); group chat only, "" on DMs
|
|
MessageType string // "private" | "chat"
|
|
SystemMessage bool
|
|
// ReplyMessage is the quoted/replied-to message Bitrix24 ships inline on
|
|
// ONIMBOTMESSAGEADD when the user replies to an earlier message. nil when
|
|
// the event is not a reply. See EventReplyMessage for field semantics.
|
|
ReplyMessage *EventReplyMessage
|
|
Files []EventFile
|
|
// MentionedList is the structured map data[PARAMS][MENTIONED_LIST][<id>]=<id>
|
|
// Bitrix24 emits on group messages. Highest-authority mention source —
|
|
// no regex / Unicode edge cases. Absent (nil) on DMs.
|
|
MentionedList map[string]string
|
|
|
|
// ChatEntityType + ChatEntityID expose the entity binding for chats that
|
|
// belong to a Bitrix24 module (CRM Deal/Lead/Contact, Tasks task,
|
|
// Workgroup, Open Channel session). Examples:
|
|
//
|
|
// CRM Deal: ChatEntityType="CRM" ChatEntityID="DEAL|2064"
|
|
// CRM Lead: ChatEntityType="CRM" ChatEntityID="LEAD|123"
|
|
// Task: ChatEntityType="TASKS_TASK" ChatEntityID="2704"
|
|
// Plain: ChatEntityType="" ChatEntityID=""
|
|
//
|
|
// Forwarded to the agent via metadata so MCP tools can resolve "this
|
|
// deal/task" deterministically without parsing CHAT_TITLE strings.
|
|
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
|
|
// the session report IS_CONNECTOR=N. The flag drives the Open Channel
|
|
// gating in handleMessage: bot drops connector traffic (humans handle
|
|
// customers) and only replies to staff who @-mention it.
|
|
FromIsConnector bool
|
|
|
|
// IsHiddenMessage mirrors data[PARAMS][PARAMS][COMPONENT_ID]=HiddenMessage,
|
|
// which Bitrix24 sets for whisper / internal-only messages in Open
|
|
// Channel sessions. Whisper messages MUST NOT be forwarded to external
|
|
// connectors (Zalo, FB, etc.) — Send() routes whisper replies through
|
|
// imbot.message.add with SKIP_CONNECTOR=Y instead of the v2 path.
|
|
// Absent or any value other than "HiddenMessage" → false (public).
|
|
IsHiddenMessage bool
|
|
}
|
|
|
|
// EventReplyMessage captures the message a user replied to (quoted). Bitrix24
|
|
// ships it inline on the reply event as `data[PARAMS][REPLY_MESSAGE][...]`:
|
|
// - ID — MESSAGE_ID of the quoted message (fallback: PARAMS[REPLY_ID])
|
|
// - AuthorID — user id who authored the quoted message
|
|
// - Text — full quoted body (BBCode); EMPTY when the quoted message is
|
|
// media-only (image/audio/file/video), in which case the attachment must
|
|
// be fetched separately via im.dialog.messages.get (see reply_context.go).
|
|
type EventReplyMessage struct {
|
|
ID string
|
|
AuthorID string
|
|
Text string
|
|
}
|
|
|
|
// newReplyMessage builds an EventReplyMessage from the raw REPLY_MESSAGE fields.
|
|
// replyID is the fallback id from the nested PARAMS[REPLY_ID] used when
|
|
// REPLY_MESSAGE[ID] is absent. Returns nil when there is no reply signal at all
|
|
// so callers can treat "not a reply" as the natural zero case.
|
|
func newReplyMessage(id, authorID, text, replyID string) *EventReplyMessage {
|
|
if id == "" {
|
|
id = replyID
|
|
}
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
return &EventReplyMessage{ID: id, AuthorID: authorID, Text: text}
|
|
}
|
|
|
|
// EventFile is one attachment element extracted from
|
|
// `data[PARAMS][FILES][<i>][...]`. URL is the urlMachine (downloadable;
|
|
// Bitrix requires `?auth=<access_token>` appended at fetch time).
|
|
type EventFile struct {
|
|
ID string
|
|
Name string
|
|
Type string // image | file | video | audio
|
|
URL string
|
|
URLPreview string
|
|
Size int64
|
|
Mime string
|
|
}
|
|
|
|
// MaxEventBodyBytes is the hard cap we accept for a webhook request body.
|
|
// Bitrix24 events are small (a few KB for message+files metadata); 1 MiB is
|
|
// an order of magnitude above real traffic. The cap matters because the
|
|
// /bitrix24/events endpoint is publicly reachable — without it an attacker
|
|
// could post an unbounded JSON/form body and exhaust memory before any auth
|
|
// check runs (auth happens AFTER parse because we need auth.domain to look
|
|
// up the portal).
|
|
const MaxEventBodyBytes = 1 << 20 // 1 MiB
|
|
|
|
// ParseEvent reads the request body and returns a normalised Event.
|
|
// It accepts either form-urlencoded (the common case) or JSON.
|
|
//
|
|
// The function does NOT validate auth; the caller (Router.handleEvent) is
|
|
// responsible for checking domain + application_token against the stored
|
|
// portal state before trusting any field.
|
|
func ParseEvent(r *http.Request) (*Event, error) {
|
|
if r == nil {
|
|
return nil, errors.New("bitrix24 event: nil request")
|
|
}
|
|
// Cap the body BEFORE parsing. http.MaxBytesReader replaces r.Body so
|
|
// both the JSON and form paths inherit the limit.
|
|
if r.Body != nil {
|
|
r.Body = http.MaxBytesReader(nil, r.Body, MaxEventBodyBytes)
|
|
}
|
|
ct := strings.ToLower(r.Header.Get("Content-Type"))
|
|
switch {
|
|
case strings.HasPrefix(ct, "application/json"):
|
|
return parseJSONEvent(r.Body)
|
|
default:
|
|
// Default to form parsing. Bitrix sometimes omits the header entirely.
|
|
if err := r.ParseForm(); err != nil {
|
|
return nil, fmt.Errorf("parse form: %w", err)
|
|
}
|
|
return parseFormEvent(r.Form)
|
|
}
|
|
}
|
|
|
|
// parseFormEvent decodes url.Values with Bitrix's square-bracket convention.
|
|
func parseFormEvent(v url.Values) (*Event, error) {
|
|
if len(v) == 0 {
|
|
return nil, errors.New("bitrix24 event: empty form body")
|
|
}
|
|
|
|
evt := &Event{Raw: v}
|
|
evt.Type = firstNonEmpty(v.Get("event"), v.Get("EVENT"))
|
|
if evt.Type == "" {
|
|
return nil, errors.New("bitrix24 event: missing event type")
|
|
}
|
|
|
|
// Timestamp: seconds-since-epoch as string. Optional.
|
|
if s := firstNonEmpty(v.Get("ts"), v.Get("TS")); s != "" {
|
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
evt.Ts = time.Unix(n, 0).UTC()
|
|
}
|
|
}
|
|
|
|
// auth[...]
|
|
evt.Auth = EventAuth{
|
|
Domain: formGet(v, "auth", "domain"),
|
|
AppToken: formGet(v, "auth", "application_token"),
|
|
AccessToken: formGet(v, "auth", "access_token"),
|
|
RefreshToken: formGet(v, "auth", "refresh_token"),
|
|
MemberID: formGet(v, "auth", "member_id"),
|
|
Scope: formGet(v, "auth", "scope"),
|
|
ServerEndpoint: formGet(v, "auth", "server_endpoint"),
|
|
ClientEndpoint: formGet(v, "auth", "client_endpoint"),
|
|
Status: formGet(v, "auth", "status"),
|
|
}
|
|
if s := formGet(v, "auth", "expires_in"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
evt.Auth.ExpiresIn = n
|
|
}
|
|
}
|
|
|
|
// data[PARAMS][...]
|
|
p := EventParams{
|
|
MessageID: formGet(v, "data", "PARAMS", "MESSAGE_ID"),
|
|
DialogID: formGet(v, "data", "PARAMS", "DIALOG_ID"),
|
|
ChatID: formGet(v, "data", "PARAMS", "CHAT_ID"),
|
|
FromUserID: formGet(v, "data", "PARAMS", "FROM_USER_ID"),
|
|
ToUserID: formGet(v, "data", "PARAMS", "TO_USER_ID"),
|
|
Message: formGet(v, "data", "PARAMS", "MESSAGE"),
|
|
MessageOriginal: formGet(v, "data", "PARAMS", "MESSAGE_ORIGINAL"),
|
|
MessageType: formGet(v, "data", "PARAMS", "MESSAGE_TYPE"),
|
|
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
|
|
}
|
|
// data[PARAMS][PARAMS][COMPONENT_ID]=HiddenMessage marks a whisper /
|
|
// internal-only message in Open Channel. Note the nested PARAMS — the
|
|
// outer PARAMS contains MESSAGE_ID/CHAT_ID/etc., and Bitrix tucks UI
|
|
// component metadata into an inner PARAMS sub-object.
|
|
if s := formGet(v, "data", "PARAMS", "PARAMS", "COMPONENT_ID"); s == "HiddenMessage" {
|
|
p.IsHiddenMessage = true
|
|
}
|
|
// data[USER][IS_CONNECTOR] — Y for messages coming in through an Open
|
|
// Channel connector (Zalo, FB Messenger, etc.), N for internal Bitrix24
|
|
// users. Used by the Open Channel gate in handle.go.
|
|
if s := formGet(v, "data", "USER", "IS_CONNECTOR"); strings.EqualFold(s, "Y") {
|
|
p.FromIsConnector = true
|
|
}
|
|
|
|
// REPLY_MESSAGE: the quoted message when the user replied. Bitrix ships the
|
|
// full quoted text inline for text originals; media-only originals carry
|
|
// just ID+AUTHOR_ID (MESSAGE empty) and are resolved later. REPLY_ID lives
|
|
// in the nested inner PARAMS as a numeric fallback id.
|
|
p.ReplyMessage = newReplyMessage(
|
|
formGet(v, "data", "PARAMS", "REPLY_MESSAGE", "ID"),
|
|
formGet(v, "data", "PARAMS", "REPLY_MESSAGE", "AUTHOR_ID"),
|
|
formGet(v, "data", "PARAMS", "REPLY_MESSAGE", "MESSAGE"),
|
|
formGet(v, "data", "PARAMS", "PARAMS", "REPLY_ID"),
|
|
)
|
|
|
|
// MENTIONED_LIST: data[PARAMS][MENTIONED_LIST][<user_id>]=<user_id>.
|
|
// Iterate all form keys to discover the structured map; key format is
|
|
// stable across portals. Empty if absent (DMs).
|
|
const mentionedPrefix = "data[PARAMS][MENTIONED_LIST]["
|
|
for key, vals := range v {
|
|
if !strings.HasPrefix(key, mentionedPrefix) || !strings.HasSuffix(key, "]") {
|
|
continue
|
|
}
|
|
id := key[len(mentionedPrefix) : len(key)-1]
|
|
if id == "" || len(vals) == 0 {
|
|
continue
|
|
}
|
|
if p.MentionedList == nil {
|
|
p.MentionedList = make(map[string]string)
|
|
}
|
|
p.MentionedList[id] = strings.TrimSpace(vals[0])
|
|
}
|
|
|
|
// BOT_ID: inspect every key starting with `data[BOT][<id>][BOT_ID]`.
|
|
// Bitrix wraps the bot id in both the outer bracket AND the inner BOT_ID
|
|
// field; we prefer the inner one because it's a stable number.
|
|
for key, vals := range v {
|
|
if !strings.HasPrefix(key, "data[BOT][") {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(key, "][BOT_ID]") {
|
|
continue
|
|
}
|
|
if len(vals) == 0 {
|
|
continue
|
|
}
|
|
if n, err := strconv.Atoi(strings.TrimSpace(vals[0])); err == nil {
|
|
p.BotID = n
|
|
break
|
|
}
|
|
}
|
|
|
|
// FILES is keyed by the Bitrix24 file ID, not by a 0-based index — webhook
|
|
// payloads look like `data[PARAMS][FILES][29968][name]=...`. Discover every
|
|
// id present in the form, then look the fields up by that id. Older
|
|
// fixtures using "0","1",... still work — they just appear as ids "0","1".
|
|
const filesPrefix = "data[PARAMS][FILES]["
|
|
seen := make(map[string]struct{})
|
|
var fileIDs []string
|
|
for key := range v {
|
|
if !strings.HasPrefix(key, filesPrefix) {
|
|
continue
|
|
}
|
|
rest := key[len(filesPrefix):]
|
|
end := strings.IndexByte(rest, ']')
|
|
if end <= 0 {
|
|
continue
|
|
}
|
|
id := rest[:end]
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
fileIDs = append(fileIDs, id)
|
|
}
|
|
// Deterministic order: numeric ids ascending; non-numeric ids fall back to
|
|
// lexicographic. Map iteration order in Go is random, so without this the
|
|
// output Files slice ordering would flap between runs.
|
|
sort.Slice(fileIDs, func(i, j int) bool {
|
|
ai, aErr := strconv.Atoi(fileIDs[i])
|
|
bi, bErr := strconv.Atoi(fileIDs[j])
|
|
if aErr == nil && bErr == nil {
|
|
return ai < bi
|
|
}
|
|
return fileIDs[i] < fileIDs[j]
|
|
})
|
|
for _, id := range fileIDs {
|
|
name := formGet(v, "data", "PARAMS", "FILES", id, "name")
|
|
url := firstNonEmpty(
|
|
formGet(v, "data", "PARAMS", "FILES", id, "urlMachine"),
|
|
formGet(v, "data", "PARAMS", "FILES", id, "url"),
|
|
formGet(v, "data", "PARAMS", "FILES", id, "urlDownload"),
|
|
)
|
|
if name == "" && url == "" {
|
|
continue
|
|
}
|
|
size, _ := strconv.ParseInt(formGet(v, "data", "PARAMS", "FILES", id, "size"), 10, 64)
|
|
// Prefer the explicit FILES[<id>][id] field when present; fall back to
|
|
// the bracket key itself (which IS the file id in live payloads).
|
|
fileID := formGet(v, "data", "PARAMS", "FILES", id, "id")
|
|
if fileID == "" {
|
|
fileID = id
|
|
}
|
|
p.Files = append(p.Files, EventFile{
|
|
ID: fileID,
|
|
Name: name,
|
|
Type: formGet(v, "data", "PARAMS", "FILES", id, "type"),
|
|
URL: url,
|
|
URLPreview: formGet(v, "data", "PARAMS", "FILES", id, "urlPreview"),
|
|
Size: size,
|
|
Mime: formGet(v, "data", "PARAMS", "FILES", id, "mime"),
|
|
})
|
|
}
|
|
|
|
evt.Params = p
|
|
return evt, nil
|
|
}
|
|
|
|
// parseJSONEvent decodes the rarer JSON event shape. Bitrix24 uses identical
|
|
// key names to the form version but nested objects instead of brackets.
|
|
func parseJSONEvent(body io.ReadCloser) (*Event, error) {
|
|
if body == nil {
|
|
return nil, errors.New("bitrix24 event: nil json body")
|
|
}
|
|
defer body.Close()
|
|
|
|
var raw struct {
|
|
Event string `json:"event"`
|
|
Ts any `json:"ts"`
|
|
Auth struct {
|
|
Domain string `json:"domain"`
|
|
ApplicationToken string `json:"application_token"`
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
MemberID string `json:"member_id"`
|
|
ExpiresIn any `json:"expires_in"`
|
|
Scope string `json:"scope"`
|
|
ServerEndpoint string `json:"server_endpoint"`
|
|
ClientEndpoint string `json:"client_endpoint"`
|
|
Status string `json:"status"`
|
|
} `json:"auth"`
|
|
Data struct {
|
|
Bot map[string]map[string]any `json:"BOT"`
|
|
User struct {
|
|
IsConnector string `json:"IS_CONNECTOR"`
|
|
} `json:"USER"`
|
|
Params struct {
|
|
MessageID any `json:"MESSAGE_ID"`
|
|
DialogID any `json:"DIALOG_ID"`
|
|
ChatID any `json:"CHAT_ID"`
|
|
FromUserID any `json:"FROM_USER_ID"`
|
|
ToUserID any `json:"TO_USER_ID"`
|
|
Message string `json:"MESSAGE"`
|
|
MessageOriginal string `json:"MESSAGE_ORIGINAL"`
|
|
MentionedList map[string]any `json:"MENTIONED_LIST"`
|
|
MessageType string `json:"MESSAGE_TYPE"`
|
|
System string `json:"SYSTEM"`
|
|
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.
|
|
// REPLY_ID is the numeric fallback id of the quoted message.
|
|
NestedParams struct {
|
|
ComponentID string `json:"COMPONENT_ID"`
|
|
ReplyID any `json:"REPLY_ID"`
|
|
} `json:"PARAMS"`
|
|
// REPLY_MESSAGE is the quoted message on reply events (see
|
|
// EventReplyMessage). MESSAGE is empty for media-only originals.
|
|
ReplyMessage struct {
|
|
ID any `json:"ID"`
|
|
AuthorID any `json:"AUTHOR_ID"`
|
|
Message string `json:"MESSAGE"`
|
|
} `json:"REPLY_MESSAGE"`
|
|
// FILES may arrive as an array OR a map keyed by file id — keep
|
|
// raw and normalize after Decode.
|
|
Files json.RawMessage `json:"FILES"`
|
|
} `json:"PARAMS"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(body).Decode(&raw); err != nil {
|
|
return nil, fmt.Errorf("decode json event: %w", err)
|
|
}
|
|
if raw.Event == "" {
|
|
return nil, errors.New("bitrix24 event: missing event type")
|
|
}
|
|
|
|
evt := &Event{Type: raw.Event}
|
|
|
|
switch t := raw.Ts.(type) {
|
|
case float64:
|
|
evt.Ts = time.Unix(int64(t), 0).UTC()
|
|
case string:
|
|
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
|
|
evt.Ts = time.Unix(n, 0).UTC()
|
|
}
|
|
}
|
|
|
|
evt.Auth = EventAuth{
|
|
Domain: raw.Auth.Domain,
|
|
AppToken: raw.Auth.ApplicationToken,
|
|
AccessToken: raw.Auth.AccessToken,
|
|
RefreshToken: raw.Auth.RefreshToken,
|
|
MemberID: raw.Auth.MemberID,
|
|
Scope: raw.Auth.Scope,
|
|
ServerEndpoint: raw.Auth.ServerEndpoint,
|
|
ClientEndpoint: raw.Auth.ClientEndpoint,
|
|
Status: raw.Auth.Status,
|
|
}
|
|
evt.Auth.ExpiresIn = asInt(raw.Auth.ExpiresIn)
|
|
|
|
for _, inner := range raw.Data.Bot {
|
|
if id := asInt(inner["BOT_ID"]); id > 0 {
|
|
evt.Params.BotID = id
|
|
break
|
|
}
|
|
}
|
|
|
|
p := &evt.Params
|
|
p.MessageID = asString(raw.Data.Params.MessageID)
|
|
p.DialogID = asString(raw.Data.Params.DialogID)
|
|
p.ChatID = asString(raw.Data.Params.ChatID)
|
|
p.FromUserID = asString(raw.Data.Params.FromUserID)
|
|
p.ToUserID = asString(raw.Data.Params.ToUserID)
|
|
p.Message = raw.Data.Params.Message
|
|
p.MessageOriginal = raw.Data.Params.MessageOriginal
|
|
p.MessageType = raw.Data.Params.MessageType
|
|
p.SystemMessage = raw.Data.Params.System == "Y"
|
|
p.ReplyMessage = newReplyMessage(
|
|
asString(raw.Data.Params.ReplyMessage.ID),
|
|
asString(raw.Data.Params.ReplyMessage.AuthorID),
|
|
raw.Data.Params.ReplyMessage.Message,
|
|
asString(raw.Data.Params.NestedParams.ReplyID),
|
|
)
|
|
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 {
|
|
p.MentionedList = make(map[string]string, len(raw.Data.Params.MentionedList))
|
|
for id, val := range raw.Data.Params.MentionedList {
|
|
p.MentionedList[id] = asString(val)
|
|
}
|
|
}
|
|
|
|
// Normalize FILES to (key, file-fields) pairs. Bitrix24 ships either an
|
|
// array OR an object keyed by the file id; we handle both. The key is the
|
|
// fallback for the file id when the inner "id" field is absent.
|
|
type filePair struct {
|
|
key string
|
|
fields map[string]any
|
|
}
|
|
var filePairs []filePair
|
|
if len(raw.Data.Params.Files) > 0 {
|
|
var asArr []map[string]any
|
|
if err := json.Unmarshal(raw.Data.Params.Files, &asArr); err == nil {
|
|
for i, f := range asArr {
|
|
filePairs = append(filePairs, filePair{key: strconv.Itoa(i), fields: f})
|
|
}
|
|
} else {
|
|
var asMap map[string]map[string]any
|
|
if err := json.Unmarshal(raw.Data.Params.Files, &asMap); err == nil {
|
|
for k, f := range asMap {
|
|
filePairs = append(filePairs, filePair{key: k, fields: f})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, fp := range filePairs {
|
|
f := fp.fields
|
|
url := asString(f["urlMachine"])
|
|
if url == "" {
|
|
url = asString(f["url"])
|
|
}
|
|
if url == "" {
|
|
url = asString(f["urlDownload"])
|
|
}
|
|
name := asString(f["name"])
|
|
if name == "" && url == "" {
|
|
continue
|
|
}
|
|
id := asString(f["id"])
|
|
if id == "" {
|
|
id = fp.key
|
|
}
|
|
p.Files = append(p.Files, EventFile{
|
|
ID: id,
|
|
Name: name,
|
|
Type: asString(f["type"]),
|
|
URL: url,
|
|
URLPreview: asString(f["urlPreview"]),
|
|
Size: int64(asInt(f["size"])),
|
|
Mime: asString(f["mime"]),
|
|
})
|
|
}
|
|
|
|
return evt, nil
|
|
}
|
|
|
|
// formGet returns the first value at a Bitrix24 bracketed path.
|
|
// `formGet(v, "data", "PARAMS", "MESSAGE")` → v.Get("data[PARAMS][MESSAGE]").
|
|
func formGet(v url.Values, head string, rest ...string) string {
|
|
if len(rest) == 0 {
|
|
return v.Get(head)
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(head)
|
|
for _, seg := range rest {
|
|
b.WriteByte('[')
|
|
b.WriteString(seg)
|
|
b.WriteByte(']')
|
|
}
|
|
return v.Get(b.String())
|
|
}
|
|
|
|
func firstNonEmpty(ss ...string) string {
|
|
for _, s := range ss {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// asString coerces common JSON scalars to string. Covers the schema drift
|
|
// where Bitrix returns numeric ids as either "123" or 123 across releases.
|
|
func asString(v any) string {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return x
|
|
case float64:
|
|
if x == float64(int64(x)) {
|
|
return strconv.FormatInt(int64(x), 10)
|
|
}
|
|
return strconv.FormatFloat(x, 'f', -1, 64)
|
|
case int:
|
|
return strconv.Itoa(x)
|
|
case int64:
|
|
return strconv.FormatInt(x, 10)
|
|
case bool:
|
|
if x {
|
|
return "Y"
|
|
}
|
|
return "N"
|
|
case json.Number:
|
|
return x.String()
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// asInt coerces common JSON scalars to int; returns 0 for anything unparseable.
|
|
func asInt(v any) int {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return 0
|
|
case float64:
|
|
return int(x)
|
|
case int:
|
|
return x
|
|
case int64:
|
|
return int(x)
|
|
case string:
|
|
n, _ := strconv.Atoi(x)
|
|
return n
|
|
case json.Number:
|
|
n, _ := x.Int64()
|
|
return int(n)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|