mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-07 14:26:47 +02:00
* feat(slack): Socket Mode channel.Channel adapter (MUL-3516) First slice of the Slack adapter: implements channel.Channel (Type/Connect/Disconnect/Send/Capabilities) over Slack Socket Mode, normalizes inbound events to channel.InboundMessage (DM, channel @mention, thread reply; bot-loop + edit/delete guards), decodes the per-installation config/secret blob, and registers the Factory under TypeSlack. No engine, core, or channel_* schema change. Unit-tested (translation, capabilities, config decode, chunking, Send via httptest). Resolvers + engine wiring + Block Kit binding replier follow. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): address adapter review (MUL-3516) - Propagate InboundHandler errors through dispatchEventsAPI/handleSocketEvent to Connect so an infra failure tears down the connection for Supervisor reconnect/backoff instead of being silently swallowed (ACK still happens first). - Capabilities: declare only CapText | CapThreadReply; drop CapRichCard/CapAttachment/CapMessageEdit until those Send paths are wired. - slackChatType: map mpim (multi-party DM) to group, not p2p, so the 'must address bot' filter applies; only 1:1 im is p2p. - Document the group-addressing decision: explicit @bot mention required in groups; mention-free thread continuation deferred to the session-aware layer. - Tests: handler-error propagation, slackChatType table, mpim-requires-mention, capabilities negative assertions. Co-authored-by: multica-agent <github@multica.ai> * refactor(channel): shared channel-agnostic ChatSession service (MUL-3516) Extract the session/append//issue machinery — currently locked inside the Feishu-pinned lark.chatSessionService — into a shared engine.ChatSession parameterized by channel_type + session titles, so every IM adapter reuses it instead of re-implementing it. Logic is verbatim (find-or-create session+binding with unique-violation race re-read; append+touch+reply-target+in-tx dedup Mark; /issue parse with bare-command previous-message fallback) but channel-neutral: command-parse source is supplied by the adapter (enrichment is platform-specific). Backed by a narrow SessionQueries interface so it is unit-tested with an in-memory fake (no DB). /issue parser moved to engine.ParseIssueCommand. Next: migrate Feishu onto it and wire Slack's ResolverSet, removing the lark duplicate. Co-authored-by: multica-agent <github@multica.ai> * fix(channel): decouple session binding key from outbound target (MUL-3516) Addresses Elon's round-2 review. engine.ChatSession.EnsureSession previously keyed the binding on a raw chat id (EnsureSessionInput.ChatID), so a resolver wiring Slack straight through would collapse every @bot thread in one channel into a single chat_session and overwrite last_thread_id. Make the API un-misusable: - EnsureSessionInput.ChatID -> BindingKey: the explicit session-isolation key (Feishu: chat id; Slack DM: channel id; Slack channel: channel id + thread root), documented so a raw threaded-platform chat id is never passed straight through. - Add EnsureSessionInput.BindingConfig (opaque) persisted on the binding's config column, so the real outbound channel/thread is preserved when BindingKey is composite — outbound routing stays separate from the isolation key. - channel.sql CreateChannelChatSessionBinding now writes config (additive, uses the existing NOT NULL column; lark caller passes '{}', no schema change, no Feishu regression). - Tests: TestEnsureSession_ThreadRootIsolation (two thread roots in one channel -> two sessions; same root reuses) and TestEnsureSession_StoresBindingConfig. No production wiring change yet (per review, the not-yet-wired shared service is an accepted preparatory state); this makes the API correct before Feishu/Slack are migrated onto it. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): Slack ResolverSet with thread-root session isolation (MUL-3516) Wires Slack into the channel-agnostic engine.Router via a ResolverSet built on the generic channel_* queries (installation route by team_id, identity + workspace-membership recheck, two-phase dedup, audit) plus the shared engine.ChatSession. No new query, no schema change. slackSessionRouting is the per-message isolation rule (Elon round-2 / Niko round-3): a DM is one session per channel; a channel/group message is isolated by thread root (key = channel:threadRoot, root = inbound thread_ts or the message ts for a top-level @mention), so two @bot threads in one channel are two sessions. The real channel id rides in BindingConfig for outbound; the reply thread is returned separately. Tests cover DM/channel/thread routing, config, and that distinct thread roots isolate while a same-thread follow-up reuses its key. Not yet wired into router.go (still a preparatory commit, per review); Feishu migration onto the shared service, router/config wiring, and the Slack outbound path follow. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): Markdown->mrkdwn outbound formatting (MUL-3516) Slack renders mrkdwn, not Markdown, so an unconverted agent reply shows literal ** , ## and [text](url). Add formatMrkdwn — a faithful Go port of Hermes Agent's slack format_message (MIT) — and apply it in slackChannel.Send before chunking/posting. Protects fenced+inline code, converted links, and existing Slack entities behind placeholders; converts headers/bold/italic/strike/links; escapes control chars. Unit tests cover each construct plus fenced-code protection and a link nested in bold. Co-authored-by: multica-agent <github@multica.ai> * docs(slack): preserve Hermes MIT notice for ported mrkdwn converter (MUL-3516) Addresses Niko's review. formatMrkdwn is a substantial port of Hermes Agent's slack format_message; MIT requires preserving the copyright + permission notice. Add the full Hermes MIT copyright/permission notice + source URL as a header on mrkdwn.go (no repo-level third-party notice file exists, and the header cannot get separated from the ported code). Also add the suggested Send-layer regression test (TestSend_AppliesMrkdwn) that pins the wiring: slackChannel.Send converts Markdown to mrkdwn before posting. Co-authored-by: multica-agent <github@multica.ai> * refactor(lark): migrate Feishu onto shared engine.ChatSession, drop duplicate (MUL-3516) Completes 'every IM reuses one shared session service' and removes the dual-path the reviewers flagged as temporary. Feishu's ResolverSet now drives the channel-agnostic engine.ChatSession (channel_type=feishu, Lark session titles preserved) instead of the Feishu-specific lark.chatSessionService, which is deleted. Behavior is unchanged: engine.ChatSession is the verbatim port of the old logic and is unit-tested; the new Feishu binder param-mapping (BindingKey=chat id, CommandText=un-enriched CommandBody from Raw) is covered by feishu_resolvers_test.go. - Delete chat_service.go (chatSessionService + helpers) and issue_command.go/_test.go (parser now engine.ParseIssueCommand). Relocate the shared TxStarter interface to tx.go (still used by binding-token + registration services). - chat.go keeps only the AuditLogger seam; remove the now-dead ChatSessionService / EnsureChatSessionParams / AppendUserMessageParams / AppendResult / IssueCommand types. - router.go constructs engine.NewChatSession for Feishu; inbound_enricher_test + doc.go updated. make-test parity: go build ./..., go vet, gofmt, and go test ./internal/integrations/{lark,channel/...,slack} all pass (full Feishu suite green). Co-authored-by: multica-agent <github@multica.ai> * feat(slack): wire Slack adapter + ResolverSet + outbound into router (MUL-3516) Activates the full Slack pipeline, gated by MULTICA_SLACK_SECRET_KEY (the bot/app-token decryption key). When unset the block is skipped, so existing deployments are unaffected and Feishu is untouched. - router.go registers slack.RegisterSlack (Socket Mode connect/send Factory) + channelRouter.Register(TypeSlack, NewSlackResolverSet) (inbound pipeline) + slack.NewOutbound(...).Register(bus) (outbound). - New slack/outbound.go: an EventChatDone subscriber mirroring the Feishu Patcher. It finds the Slack chat binding for the finished session, recovers the real channel from the binding config (the channel_chat_id may be a composite thread-isolation key) + the reply thread from last_thread_id, and posts via slackChannel.Send (reusing formatMrkdwn / chunking / threading). Sessions with no Slack binding are ignored, so it coexists with the Feishu Patcher on the shared bus. - Tests: posts to the bound channel/thread with the real channel id; ignores non-Slack sessions, empty completions, revoked installations, and non-chat events. Slack now shares engine.ChatSession, channel_* tables, IssueService and TaskService with Feishu. Remaining: config-driven installation provisioning (an operator currently creates the channel_type='slack' row; the config block shape — which workspace/agent — is a product decision) and a live end-to-end smoke. go build ./..., go vet, gofmt, and go test ./internal/integrations/{slack,channel/...,lark} all pass. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
333 lines
13 KiB
Go
333 lines
13 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// This file is the SHARED, channel-agnostic chat-session service every IM
|
|
// adapter reuses (MUL-3516). It was lifted out of the Feishu-specific
|
|
// lark.chatSessionService so that adding an IM never re-implements the
|
|
// session/append/`/issue` machinery — the platform adapter contributes only a
|
|
// channel_type, its session titles, and (because enrichment is
|
|
// platform-specific) the command-parse source. The logic — find-or-create
|
|
// session + binding, append message + touch + reply-target + in-tx dedup mark,
|
|
// `/issue` parse — is identical across platforms and carries the channel_type
|
|
// discriminator through the generalized channel_* tables.
|
|
|
|
const pgSQLStateUniqueViolation = "23505"
|
|
|
|
// TxStarter abstracts transaction creation. Satisfied by *pgxpool.Pool. Kept
|
|
// local to the engine so the integration layer never back-references
|
|
// internal/service.
|
|
type TxStarter interface {
|
|
Begin(ctx context.Context) (pgx.Tx, error)
|
|
}
|
|
|
|
// SessionQueries is the narrow slice of the generated queries the ChatSession
|
|
// service needs. *db.Queries satisfies it through the dbSessionQueries adapter
|
|
// (whose WithTx returns the interface type); tests supply an in-memory fake.
|
|
type SessionQueries interface {
|
|
WithTx(tx pgx.Tx) SessionQueries
|
|
GetChannelChatSessionBinding(ctx context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
|
|
CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error)
|
|
CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
|
|
CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error)
|
|
TouchChatSession(ctx context.Context, id pgtype.UUID) error
|
|
GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error)
|
|
UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error
|
|
MarkChannelInboundDedupProcessed(ctx context.Context, arg db.MarkChannelInboundDedupProcessedParams) (int64, error)
|
|
}
|
|
|
|
// dbSessionQueries adapts *db.Queries to SessionQueries — the only purpose is
|
|
// to give WithTx an interface return type so the transactional path stays
|
|
// behind SessionQueries.
|
|
type dbSessionQueries struct{ q *db.Queries }
|
|
|
|
func (a dbSessionQueries) WithTx(tx pgx.Tx) SessionQueries {
|
|
return dbSessionQueries{q: a.q.WithTx(tx)}
|
|
}
|
|
func (a dbSessionQueries) GetChannelChatSessionBinding(ctx context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
|
|
return a.q.GetChannelChatSessionBinding(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error) {
|
|
return a.q.CreateChatSession(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
|
|
return a.q.CreateChannelChatSessionBinding(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error) {
|
|
return a.q.CreateChatMessage(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) TouchChatSession(ctx context.Context, id pgtype.UUID) error {
|
|
return a.q.TouchChatSession(ctx, id)
|
|
}
|
|
func (a dbSessionQueries) GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error) {
|
|
return a.q.GetMostRecentUserChatMessage(ctx, chatSessionID)
|
|
}
|
|
func (a dbSessionQueries) UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error {
|
|
return a.q.UpdateChannelChatSessionBindingReplyTarget(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) MarkChannelInboundDedupProcessed(ctx context.Context, arg db.MarkChannelInboundDedupProcessedParams) (int64, error) {
|
|
return a.q.MarkChannelInboundDedupProcessed(ctx, arg)
|
|
}
|
|
|
|
// SessionTitles are the per-platform display titles a freshly created
|
|
// chat_session gets (the first message has not been appended yet, so the title
|
|
// cannot be derived from content). The adapter supplies its own wording.
|
|
type SessionTitles struct {
|
|
Group string
|
|
Direct string
|
|
Fallback string
|
|
}
|
|
|
|
func (t SessionTitles) forType(ct channel.ChatType) string {
|
|
switch ct {
|
|
case channel.ChatTypeGroup:
|
|
return t.Group
|
|
case channel.ChatTypeP2P:
|
|
return t.Direct
|
|
default:
|
|
return t.Fallback
|
|
}
|
|
}
|
|
|
|
// ChatSession is the shared chat-session service. One instance is built per
|
|
// channel_type (so the binding rows carry the right discriminator); the logic
|
|
// is otherwise platform-neutral.
|
|
type ChatSession struct {
|
|
q SessionQueries
|
|
tx TxStarter
|
|
channelType channel.Type
|
|
titles SessionTitles
|
|
}
|
|
|
|
// NewChatSession builds the shared service over the generated queries. tx is
|
|
// required: AppendUserMessage runs the dedup Mark inside the chat_message
|
|
// transaction so the durable write and the Mark commit (or roll back) together.
|
|
func NewChatSession(q *db.Queries, tx TxStarter, channelType channel.Type, titles SessionTitles) *ChatSession {
|
|
return &ChatSession{q: dbSessionQueries{q: q}, tx: tx, channelType: channelType, titles: titles}
|
|
}
|
|
|
|
// newChatSessionWith is the test seam: it accepts a SessionQueries directly so
|
|
// an in-memory fake can stand in for *db.Queries.
|
|
func newChatSessionWith(q SessionQueries, tx TxStarter, channelType channel.Type, titles SessionTitles) *ChatSession {
|
|
return &ChatSession{q: q, tx: tx, channelType: channelType, titles: titles}
|
|
}
|
|
|
|
// EnsureSessionInput is the channel-agnostic input for EnsureSession.
|
|
//
|
|
// BindingKey is the SESSION-ISOLATION key (stored as channel_chat_id; one
|
|
// chat_session per (installation_id, BindingKey)). It is intentionally NOT the
|
|
// same thing as "the chat to reply into": the adapter composes it so that
|
|
// distinct conversations get distinct sessions — Feishu passes the chat id;
|
|
// Slack passes the channel id for a DM, and the channel id PLUS the thread root
|
|
// for a channel/thread, so two @bot threads in one Slack channel do not collapse
|
|
// into one transcript (the Hermes model: IM-independent, Slack groups isolated
|
|
// by thread root). A raw platform chat id must never be passed straight through
|
|
// as the key for a threaded platform.
|
|
//
|
|
// BindingConfig is opaque platform routing the key alone cannot carry — e.g.
|
|
// Slack's real channel_id when BindingKey is a composite — persisted on the
|
|
// binding's config for the outbound path to read back. nil means "{}".
|
|
//
|
|
// Sender is the already-resolved Multica user (the session creator: the sole
|
|
// human for p2p, the installer for group chats — the caller decides which).
|
|
type EnsureSessionInput struct {
|
|
WorkspaceID pgtype.UUID
|
|
AgentID pgtype.UUID
|
|
InstallationID pgtype.UUID
|
|
Sender pgtype.UUID
|
|
BindingKey string
|
|
BindingConfig []byte
|
|
ChatType channel.ChatType
|
|
}
|
|
|
|
// EnsureSession returns the chat_session.id bound to (installation, BindingKey),
|
|
// creating it (with its channel_chat_session_binding) on first contact. The
|
|
// race between two concurrent first messages is resolved by the
|
|
// UNIQUE (installation_id, channel_chat_id) constraint: the loser re-reads the
|
|
// winner's row.
|
|
func (s *ChatSession) EnsureSession(ctx context.Context, in EnsureSessionInput) (pgtype.UUID, error) {
|
|
lookup := db.GetChannelChatSessionBindingParams{InstallationID: in.InstallationID, ChannelChatID: in.BindingKey}
|
|
|
|
existing, err := s.q.GetChannelChatSessionBinding(ctx, lookup)
|
|
if err == nil {
|
|
return existing.ChatSessionID, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return pgtype.UUID{}, fmt.Errorf("lookup chat session binding: %w", err)
|
|
}
|
|
|
|
id, err := s.createSessionAndBinding(ctx, in)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
if isUniqueViolation(err) {
|
|
existing, lookupErr := s.q.GetChannelChatSessionBinding(ctx, lookup)
|
|
if lookupErr == nil {
|
|
return existing.ChatSessionID, nil
|
|
}
|
|
return pgtype.UUID{}, fmt.Errorf("race re-read after unique violation: %w", lookupErr)
|
|
}
|
|
return pgtype.UUID{}, err
|
|
}
|
|
|
|
func (s *ChatSession) createSessionAndBinding(ctx context.Context, in EnsureSessionInput) (pgtype.UUID, error) {
|
|
tx, err := s.tx.Begin(ctx)
|
|
if err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.q.WithTx(tx)
|
|
|
|
session, err := qtx.CreateChatSession(ctx, db.CreateChatSessionParams{
|
|
WorkspaceID: in.WorkspaceID,
|
|
AgentID: in.AgentID,
|
|
CreatorID: in.Sender,
|
|
Title: s.titles.forType(in.ChatType),
|
|
})
|
|
if err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("create chat session: %w", err)
|
|
}
|
|
bindingConfig := in.BindingConfig
|
|
if len(bindingConfig) == 0 {
|
|
bindingConfig = []byte("{}")
|
|
}
|
|
if _, err := qtx.CreateChannelChatSessionBinding(ctx, db.CreateChannelChatSessionBindingParams{
|
|
ChatSessionID: session.ID,
|
|
InstallationID: in.InstallationID,
|
|
ChannelType: string(s.channelType),
|
|
ChannelChatID: in.BindingKey,
|
|
ChatType: string(in.ChatType),
|
|
Config: bindingConfig,
|
|
}); err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return session.ID, nil
|
|
}
|
|
|
|
// AppendInput is the channel-agnostic input for AppendUserMessage. Body is the
|
|
// full stored text (including any platform enrichment); CommandText is the
|
|
// user's OWN typed text used for `/issue` parsing (empty falls back to Body) —
|
|
// the adapter supplies it because enrichment is platform-specific. ClaimToken
|
|
// is the dedup owner-fence: when valid, the Mark runs inside this method's tx.
|
|
//
|
|
// MessageID and ThreadID are the REAL platform message id and thread id of this
|
|
// trigger — the outbound reply target recorded on the binding (last_message_id /
|
|
// last_thread_id), NOT the session BindingKey. Because each isolated session has
|
|
// its own binding row, recording the real thread here per session does not clash
|
|
// across sibling threads.
|
|
type AppendInput struct {
|
|
SessionID pgtype.UUID
|
|
Sender pgtype.UUID
|
|
InstallationID pgtype.UUID
|
|
Body string
|
|
CommandText string
|
|
MessageID string
|
|
ThreadID string
|
|
ClaimToken pgtype.UUID
|
|
}
|
|
|
|
// AppendUserMessage writes the user message into the chat_session (touching it
|
|
// and recording the reply target), runs the in-tx dedup Mark when a claim token
|
|
// is supplied, and returns the parsed `/issue` command when present. Returns
|
|
// ErrClaimLost when a concurrent reclaim rotated the dedup token mid-flight, in
|
|
// which case the whole transaction rolls back (no chat_message lands).
|
|
func (s *ChatSession) AppendUserMessage(ctx context.Context, in AppendInput) (AppendResult, error) {
|
|
tx, err := s.tx.Begin(ctx)
|
|
if err != nil {
|
|
return AppendResult{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.q.WithTx(tx)
|
|
|
|
// Parse before the insert so the bare-`/issue` previous-message fallback
|
|
// queries the message set that does NOT yet include this message.
|
|
commandSource := in.CommandText
|
|
if commandSource == "" {
|
|
commandSource = in.Body
|
|
}
|
|
cmd, _ := ParseIssueCommand(commandSource)
|
|
if cmd != nil && cmd.Title == "" {
|
|
prev, err := qtx.GetMostRecentUserChatMessage(ctx, in.SessionID)
|
|
if err == nil {
|
|
cmd.Title = titleFromPreviousMessage(prev.Content)
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return AppendResult{}, fmt.Errorf("previous message lookup: %w", err)
|
|
}
|
|
}
|
|
|
|
if _, err := qtx.CreateChatMessage(ctx, db.CreateChatMessageParams{
|
|
ChatSessionID: in.SessionID,
|
|
Role: "user",
|
|
Content: in.Body,
|
|
}); err != nil {
|
|
return AppendResult{}, fmt.Errorf("create chat message: %w", err)
|
|
}
|
|
if err := qtx.TouchChatSession(ctx, in.SessionID); err != nil {
|
|
return AppendResult{}, fmt.Errorf("touch chat session: %w", err)
|
|
}
|
|
|
|
// Record the latest trigger so the decoupled outbound patcher can thread
|
|
// its reply back into the originating topic.
|
|
if in.MessageID != "" {
|
|
if err := qtx.UpdateChannelChatSessionBindingReplyTarget(ctx, db.UpdateChannelChatSessionBindingReplyTargetParams{
|
|
ChatSessionID: in.SessionID,
|
|
LastMessageID: textOrNull(in.MessageID),
|
|
LastThreadID: textOrNull(in.ThreadID),
|
|
}); err != nil {
|
|
return AppendResult{}, fmt.Errorf("update reply target: %w", err)
|
|
}
|
|
}
|
|
|
|
markedInTx := false
|
|
if in.ClaimToken.Valid && in.MessageID != "" {
|
|
rows, err := qtx.MarkChannelInboundDedupProcessed(ctx, db.MarkChannelInboundDedupProcessedParams{
|
|
InstallationID: in.InstallationID,
|
|
MessageID: in.MessageID,
|
|
ClaimToken: in.ClaimToken,
|
|
})
|
|
if err != nil {
|
|
return AppendResult{}, fmt.Errorf("mark dedup processed: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
// Another worker re-claimed the dedup row; roll back via the
|
|
// deferred Rollback so no second chat_message lands.
|
|
return AppendResult{}, ErrClaimLost
|
|
}
|
|
markedInTx = true
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return AppendResult{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return AppendResult{IssueCommand: cmd, DedupMarked: markedInTx}, nil
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pg *pgconn.PgError
|
|
if errors.As(err, &pg) {
|
|
return pg.Code == pgSQLStateUniqueViolation
|
|
}
|
|
return false
|
|
}
|
|
|
|
func textOrNull(s string) pgtype.Text {
|
|
if s == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|