Files
SYNITYandDangTinh311 d07dca440a feat(bitrix24): status-reaction support via imbot.v2 reactions [B24:2794] (#1446)
Bitrix24 was the only channel that never showed the agent's progress as an
emoji reaction on the user's message (Telegram/Slack/Feishu/Discord/... all do).
Two things were missing: the channel didn't implement channels.ReactionChannel,
and its inbound metadata omitted the generic "message_id" key the gateway
consumer reads to thread status/streaming events back to a message.

- handle.go: set meta["message_id"] = MESSAGE_ID (every other channel does this;
  without it the run has no message id to react to). Kept the existing
  bitrix_message_id key for reply-link routing.
- reactions.go (new): implement OnReactionEvent + ClearReaction. Map agent
  status → a Bitrix v2 reaction code (thinkingFace / fire / whiteHeavyCheckMark /
  crossMark / ...), debounce intermediate updates (terminal states bypass), and
  replace the previous reaction via imbot.v2.Chat.Message.Reaction.delete +
  .add (Bitrix has no in-place replace). REACTION_ALREADY_SET is treated as
  success. ReactionLevel off/minimal/full already existed in the channel config.
- channel.go: add the per-message reactions state map + compile-time assertion.

Tests: full flow (add → delete+add on status change), minimal skips
intermediate, off/invalid/unknown are no-ops, ClearReaction removes current.

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
2026-07-16 18:54:40 +07:00

130 lines
4.0 KiB
Go

package bitrix24
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type reactionCall struct {
op string // "add" | "delete"
reaction string
}
// reactionRecorder serves the v2 reaction endpoints and records each call.
func reactionRecorder(t *testing.T) (*httptest.Server, func() []reactionCall) {
t.Helper()
var mu sync.Mutex
var calls []reactionCall
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
op := ""
switch {
case strings.HasSuffix(r.URL.Path, "/rest/imbot.v2.Chat.Message.Reaction.add.json"):
op = "add"
case strings.HasSuffix(r.URL.Path, "/rest/imbot.v2.Chat.Message.Reaction.delete.json"):
op = "delete"
default:
t.Errorf("unexpected reaction path: %q", r.URL.Path)
}
mu.Lock()
calls = append(calls, reactionCall{op: op, reaction: r.Form.Get("reaction")})
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"result": true})
}))
return srv, func() []reactionCall {
mu.Lock()
defer mu.Unlock()
out := make([]reactionCall, len(calls))
copy(out, calls)
return out
}
}
func TestOnReactionEvent_FullFlow_AddThenReplaceViaV2(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "full"
ctx := context.Background()
// thinking → add thinkingFace
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "thinking")
// done (terminal, bypasses debounce) → delete thinkingFace + add whiteHeavyCheckMark
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "done")
calls := snapshot()
if len(calls) != 3 {
t.Fatalf("expected 3 reaction calls (add, delete, add), got %d: %+v", len(calls), calls)
}
if calls[0].op != "add" || calls[0].reaction != "thinkingFace" {
t.Errorf("call[0] = %+v; want add thinkingFace", calls[0])
}
if calls[1].op != "delete" || calls[1].reaction != "thinkingFace" {
t.Errorf("call[1] = %+v; want delete thinkingFace", calls[1])
}
if calls[2].op != "add" || calls[2].reaction != "whiteHeavyCheckMark" {
t.Errorf("call[2] = %+v; want add whiteHeavyCheckMark", calls[2])
}
}
func TestOnReactionEvent_MinimalSkipsIntermediate(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "minimal"
ctx := context.Background()
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "thinking") // skipped in minimal
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "error") // terminal → shown
calls := snapshot()
if len(calls) != 1 || calls[0].op != "add" || calls[0].reaction != "crossMark" {
t.Fatalf("minimal should send only the terminal reaction; got %+v", calls)
}
}
func TestOnReactionEvent_OffAndInvalidAreNoOp(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
// off level
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "off"
_ = bc.OnReactionEvent(context.Background(), "chat100", "5001", "done")
// full level but non-numeric message id (connector synthetic id)
bc2 := newChannelWithBoundPortal(t, srv)
bc2.cfg.ReactionLevel = "full"
_ = bc2.OnReactionEvent(context.Background(), "chat100", "not-a-number", "done")
// full level, unknown status
bc2.cfg.ReactionLevel = "full"
_ = bc2.OnReactionEvent(context.Background(), "chat100", "5001", "bogus-status")
if calls := snapshot(); len(calls) != 0 {
t.Fatalf("expected no reaction calls for off/invalid/unknown, got %+v", calls)
}
}
func TestClearReaction_RemovesCurrent(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "full"
ctx := context.Background()
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "thinking") // add thinkingFace
_ = bc.ClearReaction(ctx, "chat100", "5001") // delete thinkingFace
calls := snapshot()
if len(calls) != 2 || calls[1].op != "delete" || calls[1].reaction != "thinkingFace" {
t.Fatalf("expected add then delete thinkingFace; got %+v", calls)
}
}