mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
Before: every Open Channel message required a bot @mention before the agent
picked it up — including external-customer turns on 1-to-1 connectors
(Facebook Messenger, Zalo OA, …) where the customer has no syntactic way
to produce a mention. The gate, designed for group-style connectors
(Zalo personal pools multiple customers into one Open Channel chat),
was silently dropping every legitimate customer message on every other
connector.
This change scopes the gate by connector:
- Internal staff (IS_CONNECTOR=N) — STILL gate. Operators can @-mention
and we don't want the bot jumping into operator-to-operator chatter
in the same session.
- External customer (IS_CONNECTOR=Y) on a whitelisted connector
(default: synity_zalo_personal) — STILL gate. Multiple customers
share the chat, so untagged turns would otherwise spam them.
- External customer on any other connector (Facebook, Zalo OA, the long
tail of 1-to-1 connectors, legacy payloads without CHAT_ENTITY_ID) —
do NOT gate. Every message is addressed to the bot by construction.
The whitelist is overridable via BITRIX24_REQUIRE_MENTION_CONNECTORS
(comma-separated connector codes — the leading token of CHAT_ENTITY_ID).
Default keeps synity_zalo_personal gated so a fresh deployment is safe.
Adding a future group-style connector (e.g. a Zalo group flavour) is an
env edit + restart, not a code change.
- mention_gate.go (new):
connectorCodeFromEntityID() parses "synity_zalo_personal|20|grp|960"
into "synity_zalo_personal".
requireMentionConnectorSet() reads the env once (sync.Once cache)
and falls back to the hardcoded default {synity_zalo_personal}.
shouldRequireMentionForOpenline() is the policy matrix.
- mention_gate_test.go (new): pins the parser, the env-override path,
and every row of the policy matrix.
- handle.go: replaces the unconditional Open Channel mention gate with
a call to shouldRequireMentionForOpenline(). Adds chat_entity_id to
the drop log so operators can see which connector tripped the gate.
- handle_test.go:
Update existing "ConnectorWithoutMentionDropped" test to set
ChatEntityID to the whitelisted connector — that's what the gate now
keys on.
Add new "OneToOneConnectorWithoutMentionForwarded" test pinning the
new behavior: facebook (not whitelisted) forwards every customer
message without a mention.
Surface parity: backend-only. No API contract / web UI / CLI change.
The env var follows the same operational model as the other BITRIX24_*
vars in docker-compose.yml; documenting it there is a no-op until the
default needs overriding.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
134 lines
5.3 KiB
Go
134 lines
5.3 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// resetRequireMentionCache lets each test observe the env override path —
|
|
// the package-level sync.Once otherwise locks in whatever the FIRST caller
|
|
// in the process saw. Tests that touch BITRIX24_REQUIRE_MENTION_CONNECTORS
|
|
// MUST call this before reading the set, and again at cleanup so a later
|
|
// test doesn't inherit a polluted cache.
|
|
func resetRequireMentionCache() {
|
|
requireMentionOnce = sync.Once{}
|
|
requireMentionSet = nil
|
|
}
|
|
|
|
func TestConnectorCodeFromEntityID(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
// Real-shape payload — what the Bitrix Open Channel webhook actually
|
|
// ships for the Synity Zalo personal connector.
|
|
{"synity_zalo_personal|20|grp|960", "synity_zalo_personal"},
|
|
// Hypothetical future Facebook Open Channel payload — verifies the
|
|
// helper doesn't require any specific connector name to work.
|
|
{"facebook|34|36305652112415023|1074", "facebook"},
|
|
// Token with no pipe at all — return the whole string so the caller
|
|
// can still look it up in the whitelist.
|
|
{"plain_token", "plain_token"},
|
|
// Surrounding whitespace from sloppy upstream serialization gets
|
|
// trimmed before the split — otherwise we'd never match.
|
|
{" spaced|x|y ", "spaced"},
|
|
// Empty / pipe-leading cases collapse to "" so the gate falls into
|
|
// the "unknown connector → don't gate" branch.
|
|
{"", ""},
|
|
{"|leading|pipe", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
if got := connectorCodeFromEntityID(tc.in); got != tc.want {
|
|
t.Errorf("connectorCodeFromEntityID(%q) = %q; want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequireMentionConnectorSet_DefaultWhenEnvUnset pins the safety
|
|
// default: when operators haven't configured BITRIX24_REQUIRE_MENTION_CONNECTORS
|
|
// the gate still trips for the one connector we currently know is
|
|
// group-style (Zalo personal). Without this default a fresh deployment
|
|
// would silently reply to every Zalo customer turn.
|
|
func TestRequireMentionConnectorSet_DefaultWhenEnvUnset(t *testing.T) {
|
|
resetRequireMentionCache()
|
|
defer resetRequireMentionCache()
|
|
t.Setenv("BITRIX24_REQUIRE_MENTION_CONNECTORS", "")
|
|
|
|
set := requireMentionConnectorSet()
|
|
if !set["synity_zalo_personal"] {
|
|
t.Error("default set must include synity_zalo_personal")
|
|
}
|
|
if set["facebook"] {
|
|
t.Error("default set must not include facebook (1-to-1 connector)")
|
|
}
|
|
}
|
|
|
|
// TestRequireMentionConnectorSet_EnvOverride is the whole point of the
|
|
// env-driven design — operators can add a future group-style connector
|
|
// without redeploying the binary. Once "zalo_group" gets shipped this
|
|
// is the runtime knob that turns the gate on for it.
|
|
func TestRequireMentionConnectorSet_EnvOverride(t *testing.T) {
|
|
resetRequireMentionCache()
|
|
defer resetRequireMentionCache()
|
|
t.Setenv("BITRIX24_REQUIRE_MENTION_CONNECTORS", "synity_zalo_personal, zalo_group , ")
|
|
|
|
set := requireMentionConnectorSet()
|
|
if !set["synity_zalo_personal"] {
|
|
t.Error("override must keep synity_zalo_personal")
|
|
}
|
|
if !set["zalo_group"] {
|
|
t.Error("override must add zalo_group")
|
|
}
|
|
// Whitespace + trailing empty entry from the comma split must NOT
|
|
// register a phantom "" key — that would gate every payload with an
|
|
// empty CHAT_ENTITY_ID, which we explicitly want to leave un-gated.
|
|
if set[""] {
|
|
t.Error("override must not register an empty-string key")
|
|
}
|
|
}
|
|
|
|
// TestShouldRequireMentionForOpenline is the policy matrix in one place.
|
|
// Each row corresponds to a decided business case from the spec review,
|
|
// so a regression on any row is a regression on a deliberate decision.
|
|
func TestShouldRequireMentionForOpenline(t *testing.T) {
|
|
resetRequireMentionCache()
|
|
defer resetRequireMentionCache()
|
|
t.Setenv("BITRIX24_REQUIRE_MENTION_CONNECTORS", "")
|
|
|
|
cases := []struct {
|
|
name string
|
|
fromConnector bool
|
|
chatEntityID string
|
|
want bool
|
|
}{
|
|
// Staff (IS_CONNECTOR=N) ALWAYS gate — they can @-mention and we
|
|
// don't want the bot jumping into operator-to-operator chatter.
|
|
{"staff in zalo personal still gates", false, "synity_zalo_personal|20|grp|960", true},
|
|
{"staff in facebook still gates", false, "facebook|34|x|960", true},
|
|
{"staff without entity id still gates", false, "", true},
|
|
// Customer (IS_CONNECTOR=Y) on the whitelisted connector → gate.
|
|
// Multiple customers share one Zalo personal Open Channel chat, so
|
|
// every untagged turn would otherwise spam them.
|
|
{"customer on zalo personal gates", true, "synity_zalo_personal|20|grp|960", true},
|
|
// Customer on a 1-to-1 connector → DO NOT gate. Every message is
|
|
// addressed to the bot; gating would drop legitimate traffic.
|
|
{"customer on facebook does not gate", true, "facebook|34|x|1074", false},
|
|
{"customer on zalo_oa does not gate", true, "zalo_oa|1|x|1", false},
|
|
// Customer with empty CHAT_ENTITY_ID → DO NOT gate. We can't
|
|
// distinguish the source, and the safe default for the long tail
|
|
// of legacy payloads is "let it through" rather than "silently
|
|
// drop and look broken".
|
|
{"customer without entity id does not gate", true, "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := shouldRequireMentionForOpenline(tc.fromConnector, tc.chatEntityID); got != tc.want {
|
|
t.Errorf("shouldRequireMentionForOpenline(connector=%v, entity=%q) = %v; want %v",
|
|
tc.fromConnector, tc.chatEntityID, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|