mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
* feat(slack): add typing reaction on inbound message (MUL-3874) Mirror the Feishu typing indicator on Slack: react with 👀 on the user's message when it is ingested, then remove the reaction when the agent's run finishes (EventChatDone) or fails (EventTaskFailed). - New slack.TypingIndicatorManager: Add on ingest, Clear on terminal run events; state keyed by chat_session_id, bot token re-resolved from the DB on clear (never held in memory), all failures logged and swallowed (best-effort). - Wire via the channel-agnostic engine.TypingNotifier seam (slackTypingNotifier in the ResolverSet) — the Router already calls OnIngested off the ACK path. - Clear subscribes to the event bus directly so a failed run also drops the reaction (the outbound replier only handles EventChatDone). - Skip messages older than 2m so Socket Mode reconnect replays don't restamp. Requires the installed Slack app to hold the reactions:write scope. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): clear typing reaction when no task runs; document reactions:write (MUL-3874) Addresses review feedback on the typing-indicator PR. 1. Stuck reaction on offline/archived agent. The debounced flush (flushChatRun) enqueues no task when the agent has no runtime or is archived (or on any enqueue/reload error), so no task lifecycle event is ever published and the bus-driven clear never fires — leaving the 👀 (and Feishu's Typing) reaction stuck on the user's message. Fix at the shared engine seam: add TypingNotifier.OnSettled(ctx, sessionID), which the Router calls from the flush on every no-task exit (before any offline/archived notice). Both the Slack and Feishu notifiers route it to manager.Clear, so the latent Feishu case is fixed too. Adds engine coverage (offline/archived clear, success does not) and a Slack OnSettled test. 2. Missing reactions:write scope in docs. reactions.add/remove silently fail without the scope, but the BYO app manifest/docs never listed it. Add reactions:write to the manifest + scope table and a reinstall note across all four locales (en/zh/ja/ko). Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent-j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
477 lines
18 KiB
Go
477 lines
18 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
)
|
|
|
|
// Router is the channel-agnostic inbound pipeline — the generalization of the
|
|
// Feishu-only lark.Dispatcher (+ the Hub's handleEvent outbound seam). It is
|
|
// the single shared channel.InboundHandler the Supervisor injects into every
|
|
// Channel: a Channel translates its platform payload into a
|
|
// channel.InboundMessage and calls Handle, which routes by ChannelType to that
|
|
// platform's registered resolver set and runs the same ordered pipeline for
|
|
// every platform — installation route → two-phase dedup → group @bot filter →
|
|
// identity + membership → ensure session → append+mark → /issue → debounced
|
|
// run trigger — then drives the detached outbound replier + typing indicator.
|
|
//
|
|
// The core contains no platform specifics: everything platform-shaped lives
|
|
// behind the resolver interfaces (a feishu ResolverSet is the first
|
|
// implementation). Adding a platform is "register a ResolverSet", not "edit
|
|
// the Router".
|
|
type Router struct {
|
|
mu sync.RWMutex
|
|
sets map[channel.Type]ResolverSet
|
|
|
|
issues IssueCreator
|
|
tasks TaskEnqueuer
|
|
reader SessionReader
|
|
|
|
batcher *pendingBatcher
|
|
|
|
replyTimeout time.Duration
|
|
replyWg sync.WaitGroup
|
|
|
|
logger *slog.Logger
|
|
|
|
pendingFreshMu sync.Mutex
|
|
pendingFresh map[string]bool
|
|
}
|
|
|
|
// Config tunes the Router. Zero values default.
|
|
type RouterConfig struct {
|
|
// ReplyTimeout caps a single detached OutboundReplier.Reply / typing
|
|
// call. It runs off the connector ACK path, so it must stay strictly
|
|
// under the platform ACK deadline (Lark: 3s). Defaults to 2.5s.
|
|
ReplyTimeout time.Duration
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewRouter builds a Router around the shared (platform-agnostic) services:
|
|
// the IssueCreator + TaskEnqueuer that /issue and chat runs go through, and a
|
|
// SessionReader for the debounced flush. Register a platform's ResolverSet
|
|
// with Register before Handle is called.
|
|
func NewRouter(issues IssueCreator, tasks TaskEnqueuer, reader SessionReader, cfg RouterConfig) *Router {
|
|
if cfg.ReplyTimeout == 0 {
|
|
cfg.ReplyTimeout = 2500 * time.Millisecond
|
|
}
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = slog.Default()
|
|
}
|
|
return &Router{
|
|
sets: make(map[channel.Type]ResolverSet),
|
|
issues: issues,
|
|
tasks: tasks,
|
|
reader: reader,
|
|
replyTimeout: cfg.ReplyTimeout,
|
|
logger: cfg.Logger,
|
|
pendingFresh: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// Register binds a platform's ResolverSet under t. Call at boot, before Run.
|
|
// Registering an empty Type or a set missing a required resolver is ignored.
|
|
func (r *Router) Register(t channel.Type, set ResolverSet) {
|
|
if t == "" || set.Installation == nil || set.Identity == nil || set.Dedup == nil || set.Session == nil || set.Audit == nil {
|
|
r.logger.Warn("channel router: ignoring incomplete resolver set", "channel_type", string(t))
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sets[t] = set
|
|
}
|
|
|
|
// EnableRunBatching installs the debouncer in front of the per-session run
|
|
// trigger. Call once at boot. A non-positive window uses
|
|
// DefaultChatRunBatchWindow. Without it, runs fire inline (used by tests).
|
|
func (r *Router) EnableRunBatching(window time.Duration) {
|
|
r.batcher = newPendingBatcher(window)
|
|
}
|
|
|
|
// Drain flushes debounced run triggers and joins in-flight reply goroutines.
|
|
// Call on shutdown AFTER the Supervisor has stopped delivering events.
|
|
func (r *Router) Drain() {
|
|
if r.batcher != nil {
|
|
r.batcher.FlushAll()
|
|
}
|
|
r.replyWg.Wait()
|
|
}
|
|
|
|
// ErrNoResolverSet is returned by Handle when a message arrives for a channel
|
|
// type that has no registered ResolverSet — a boot/registration bug. It is an
|
|
// infrastructure error so the adapter surfaces it rather than silently
|
|
// dropping.
|
|
var ErrNoResolverSet = errors.New("channel router: no resolver set for channel type")
|
|
|
|
// Handle is the shared channel.InboundHandler. It runs the pipeline and then
|
|
// drives the detached outbound side; it returns a non-nil error only for
|
|
// infrastructure failures (the adapter reconnects). Product outcomes (dropped,
|
|
// needs-binding, …) are not errors.
|
|
func (r *Router) Handle(ctx context.Context, msg channel.InboundMessage) error {
|
|
r.mu.RLock()
|
|
set, ok := r.sets[msg.Source.ChannelType]
|
|
r.mu.RUnlock()
|
|
if !ok {
|
|
r.logger.Error("channel router: no resolver set", "channel_type", string(msg.Source.ChannelType))
|
|
return ErrNoResolverSet
|
|
}
|
|
|
|
res, inst, err := r.dispatch(ctx, set, msg)
|
|
if err != nil {
|
|
r.logger.Error("channel router: dispatch error",
|
|
"channel_type", string(msg.Source.ChannelType),
|
|
"event_id", msg.EventID,
|
|
"error", err,
|
|
)
|
|
return err
|
|
}
|
|
r.logger.Debug("channel router: dispatch outcome",
|
|
"channel_type", string(msg.Source.ChannelType),
|
|
"event_id", msg.EventID,
|
|
"outcome", string(res.Outcome),
|
|
"drop_reason", string(res.DropReason),
|
|
)
|
|
|
|
// Typing indicator on ingest, detached so the reaction HTTP call never
|
|
// blocks the connector ACK path.
|
|
if res.Outcome == OutcomeIngested && set.Typing != nil {
|
|
go func() {
|
|
tctx, cancel := context.WithTimeout(context.Background(), r.replyTimeout)
|
|
defer cancel()
|
|
set.Typing.OnIngested(tctx, inst, msg, res.ChatSessionID)
|
|
}()
|
|
}
|
|
r.scheduleReply(set, inst, msg, res)
|
|
return nil
|
|
}
|
|
|
|
// dispatch runs the pipeline and returns the typed result plus the resolved
|
|
// installation (needed by the outbound side). Mirrors lark.Dispatcher.Handle.
|
|
func (r *Router) dispatch(ctx context.Context, set ResolverSet, msg channel.InboundMessage) (Result, ResolvedInstallation, error) {
|
|
// 1. Route to installation. The adapter maps the platform routing key
|
|
// (carried on the message) to its installation row. These drop
|
|
// branches run BEFORE the dedup claim because they have no valid
|
|
// installation to attach a claim to.
|
|
inst, err := set.Installation.ResolveInstallation(ctx, msg)
|
|
if err != nil {
|
|
if errors.Is(err, ErrInstallationNotFound) {
|
|
_ = set.Audit.RecordDrop(ctx, pgtype.UUID{}, msg, DropReasonInvalidEvent)
|
|
return Result{Outcome: OutcomeDropped, DropReason: DropReasonInvalidEvent}, ResolvedInstallation{}, nil
|
|
}
|
|
return Result{}, ResolvedInstallation{}, fmt.Errorf("resolve installation: %w", err)
|
|
}
|
|
if !inst.Active {
|
|
return r.drop(ctx, set, msg, inst.ID, DropReasonRevokedInstallation), inst, nil
|
|
}
|
|
|
|
// 2. Two-phase dedup claim with owner fencing — before group filter and
|
|
// identity so a reconnect replay cannot re-trigger a binding prompt,
|
|
// re-write a drop audit, or re-touch the session. Empty MessageID
|
|
// means there is no key to dedup by; skip the claim.
|
|
var claimToken pgtype.UUID
|
|
claimed := false
|
|
if msg.MessageID != "" {
|
|
token, err := set.Dedup.Claim(ctx, inst.ID, msg.MessageID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrDuplicate) {
|
|
return r.drop(ctx, set, msg, inst.ID, DropReasonDuplicate), inst, nil
|
|
}
|
|
return Result{}, inst, fmt.Errorf("dedup claim: %w", err)
|
|
}
|
|
claimToken = token
|
|
claimed = true
|
|
}
|
|
|
|
res, finalize, err := r.processClaimed(ctx, set, msg, inst, claimToken)
|
|
|
|
if claimed {
|
|
r.applyFinalize(ctx, set, inst.ID, msg.MessageID, claimToken, finalize)
|
|
}
|
|
|
|
// ErrClaimLost: another worker holds the claim. Surface as duplicate.
|
|
if errors.Is(err, ErrClaimLost) {
|
|
return r.drop(ctx, set, msg, inst.ID, DropReasonDuplicate), inst, nil
|
|
}
|
|
return res, inst, err
|
|
}
|
|
|
|
// dedupFinalize tells dispatch how to land the claim row after processClaimed.
|
|
type dedupFinalize int
|
|
|
|
const (
|
|
finalizeNone dedupFinalize = iota
|
|
finalizeMark
|
|
finalizeRelease
|
|
)
|
|
|
|
// processClaimed runs the post-dedup pipeline. Mirrors
|
|
// lark.Dispatcher.processClaimed; see its boundary contract per step.
|
|
func (r *Router) processClaimed(ctx context.Context, set ResolverSet, msg channel.InboundMessage, inst ResolvedInstallation, claimToken pgtype.UUID) (Result, dedupFinalize, error) {
|
|
// 3. Group-mention filter (group chats only), before identity so an
|
|
// unbound user's idle group chatter never spams a binding card.
|
|
if msg.Source.ChatType == channel.ChatTypeGroup && !msg.AddressedToBot {
|
|
return r.drop(ctx, set, msg, inst.ID, DropReasonNotAddressedInGroup), finalizeMark, nil
|
|
}
|
|
|
|
// 4. Identity check: map the platform sender to a Multica user and
|
|
// re-verify workspace membership (no binding->member FK; MUL-3515 §4).
|
|
identity, err := set.Identity.ResolveSender(ctx, inst, msg)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ErrSenderUnbound):
|
|
_ = set.Audit.RecordDrop(ctx, inst.ID, msg, DropReasonUnboundUser)
|
|
return Result{
|
|
Outcome: OutcomeNeedsBinding,
|
|
DropReason: DropReasonUnboundUser,
|
|
InstallationID: inst.ID,
|
|
Sender: msg.Source.SenderID,
|
|
}, finalizeMark, nil
|
|
case errors.Is(err, ErrSenderNotMember):
|
|
return r.drop(ctx, set, msg, inst.ID, DropReasonNonWorkspaceMember), finalizeMark, nil
|
|
default:
|
|
return Result{}, finalizeRelease, fmt.Errorf("resolve sender: %w", err)
|
|
}
|
|
}
|
|
|
|
// 5. Resolve the chat_session. Group sessions are created by the INSTALLER
|
|
// (stable workspace identity that won't churn with group membership);
|
|
// p2p sessions by the sole human sender.
|
|
sessionCreator := identity.UserID
|
|
if msg.Source.ChatType == channel.ChatTypeGroup {
|
|
sessionCreator = inst.InstallerUserID
|
|
}
|
|
sessionID, err := set.Session.EnsureSession(ctx, EnsureSessionParams{
|
|
Installation: inst,
|
|
Sender: sessionCreator,
|
|
Message: msg,
|
|
})
|
|
if err != nil {
|
|
// Single tx; an error rolled it back, nothing landed. Release.
|
|
return Result{}, finalizeRelease, fmt.Errorf("ensure chat session: %w", err)
|
|
}
|
|
|
|
// 6. Append message + in-tx dedup Mark — the durable transition point.
|
|
appendRes, err := set.Session.AppendMessage(ctx, AppendParams{
|
|
SessionID: sessionID,
|
|
Sender: identity.UserID,
|
|
InstallationID: inst.ID,
|
|
Message: msg,
|
|
ClaimToken: claimToken,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ErrClaimLost) {
|
|
return Result{}, finalizeNone, err
|
|
}
|
|
return Result{}, finalizeRelease, fmt.Errorf("append user message: %w", err)
|
|
}
|
|
|
|
// Post-append paths must NOT Release (chat_message + Mark already
|
|
// committed). Mark-again is a no-op, so finalizeNone — unless the binder
|
|
// did not Mark in-tx (defensive), then fall back to a post-pipeline Mark.
|
|
postAppendFinalize := finalizeNone
|
|
if !appendRes.DedupMarked {
|
|
postAppendFinalize = finalizeMark
|
|
}
|
|
|
|
res := Result{
|
|
Outcome: OutcomeIngested,
|
|
InstallationID: inst.ID,
|
|
ChatSessionID: sessionID,
|
|
Sender: msg.Source.SenderID,
|
|
}
|
|
|
|
// 7. /issue command, if present. chat_message is already durable; all
|
|
// error returns from here signal finalizeNone (or the defensive Mark).
|
|
if appendRes.IssueCommand != nil {
|
|
issueRes, err := r.createIssue(ctx, inst, set.OriginType, identity.UserID, sessionID, *appendRes.IssueCommand)
|
|
if err != nil {
|
|
return Result{}, postAppendFinalize, fmt.Errorf("create issue from command: %w", err)
|
|
}
|
|
res.IssueID = issueRes.Issue.ID
|
|
res.IssueNumber = issueRes.Issue.Number
|
|
res.IssueTitle = issueRes.Issue.Title
|
|
if ws, werr := r.reader.GetWorkspace(ctx, inst.WorkspaceID); werr == nil && ws.IssuePrefix != "" {
|
|
res.IssueIdentifier = fmt.Sprintf("%s-%d", ws.IssuePrefix, issueRes.Issue.Number)
|
|
} else {
|
|
res.IssueIdentifier = fmt.Sprintf("#%d", issueRes.Issue.Number)
|
|
}
|
|
}
|
|
|
|
// 8. Debounce the run trigger. The synchronous outcome is OutcomeIngested
|
|
// with no TaskID — the task row is created at flush. identity.UserID is
|
|
// THIS message's sender (the task initiator), deliberately not the
|
|
// session creator (group sessions are creator=installer). Latest sender
|
|
// in a window wins (MUL-2645).
|
|
r.scheduleRun(set, inst, msg, sessionID, identity.UserID)
|
|
return res, postAppendFinalize, nil
|
|
}
|
|
|
|
// scheduleRun hands the per-session run trigger to the debouncer (or fires it
|
|
// inline when batching is disabled).
|
|
func (r *Router) scheduleRun(set ResolverSet, inst ResolvedInstallation, msg channel.InboundMessage, sessionID, initiatorUserID pgtype.UUID) {
|
|
key := keyForSession(sessionID)
|
|
fresh := msg.ForceFresh
|
|
if r.batcher == nil {
|
|
r.flushChatRun(set, inst, msg, sessionID, initiatorUserID, fresh)
|
|
return
|
|
}
|
|
if fresh {
|
|
r.markPendingFresh(key)
|
|
}
|
|
flush := func() {
|
|
r.flushChatRun(set, inst, msg, sessionID, initiatorUserID, r.takePendingFresh(key, fresh))
|
|
}
|
|
r.batcher.Schedule(key, flush)
|
|
}
|
|
|
|
// chatRunFlushTimeout bounds the detached flush (session reload + enqueue +
|
|
// notice), which runs on its own fresh context.
|
|
const chatRunFlushTimeout = 10 * time.Second
|
|
|
|
// flushChatRun is the debounced run-trigger: reload session, enqueue exactly
|
|
// one chat task for the window, and emit the offline/archived notice (only
|
|
// known here now) via the replier. Errors are logged, not returned.
|
|
func (r *Router) flushChatRun(set ResolverSet, inst ResolvedInstallation, msg channel.InboundMessage, sessionID, initiatorUserID pgtype.UUID, forceFresh bool) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), chatRunFlushTimeout)
|
|
defer cancel()
|
|
|
|
session, err := r.reader.GetChatSession(ctx, sessionID)
|
|
if err != nil {
|
|
r.logger.Error("channel router: flush reload chat session failed",
|
|
"chat_session_id", uuidString(sessionID), "err", err.Error())
|
|
r.clearTyping(ctx, set, sessionID)
|
|
return
|
|
}
|
|
if _, err := r.tasks.EnqueueChatTask(ctx, session, initiatorUserID, forceFresh); err != nil {
|
|
// No task was enqueued, so no task lifecycle event will ever publish and
|
|
// the platform's bus-driven typing clear can never fire. Clear the
|
|
// indicator here (before any notice) so the "processing" reaction does
|
|
// not stick on the user's message.
|
|
r.clearTyping(ctx, set, sessionID)
|
|
switch {
|
|
case errors.Is(err, service.ErrChatTaskAgentNoRuntime):
|
|
r.emitFlushReply(ctx, set, inst, msg, sessionID, OutcomeAgentOffline)
|
|
case errors.Is(err, service.ErrChatTaskAgentArchived):
|
|
r.emitFlushReply(ctx, set, inst, msg, sessionID, OutcomeAgentArchived)
|
|
default:
|
|
r.logger.Error("channel router: flush enqueue chat task failed",
|
|
"chat_session_id", uuidString(sessionID), "err", err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// clearTyping asks the platform to drop the "processing" indicator for a session
|
|
// whose flush produced no task run. A nil TypingNotifier (platform without the
|
|
// feature) is a no-op.
|
|
func (r *Router) clearTyping(ctx context.Context, set ResolverSet, sessionID pgtype.UUID) {
|
|
if set.Typing != nil {
|
|
set.Typing.OnSettled(ctx, sessionID)
|
|
}
|
|
}
|
|
|
|
func (r *Router) markPendingFresh(key string) {
|
|
r.pendingFreshMu.Lock()
|
|
defer r.pendingFreshMu.Unlock()
|
|
r.pendingFresh[key] = true
|
|
}
|
|
|
|
func (r *Router) takePendingFresh(key string, fallback bool) bool {
|
|
r.pendingFreshMu.Lock()
|
|
defer r.pendingFreshMu.Unlock()
|
|
fresh := fallback || r.pendingFresh[key]
|
|
delete(r.pendingFresh, key)
|
|
return fresh
|
|
}
|
|
|
|
// emitFlushReply delivers an offline/archived notice for a flushed run.
|
|
func (r *Router) emitFlushReply(ctx context.Context, set ResolverSet, inst ResolvedInstallation, msg channel.InboundMessage, sessionID pgtype.UUID, outcome Outcome) {
|
|
if set.Replier == nil {
|
|
return
|
|
}
|
|
set.Replier.Reply(ctx, inst, msg, Result{
|
|
Outcome: outcome,
|
|
InstallationID: inst.ID,
|
|
ChatSessionID: sessionID,
|
|
Sender: msg.Source.SenderID,
|
|
})
|
|
}
|
|
|
|
// scheduleReply detaches the OutboundReplier from the ACK critical path. The
|
|
// reply goroutine uses a fresh context with a ReplyTimeout deadline so it is
|
|
// independent of the inbound emit ctx (which the adapter cancels when its
|
|
// receive loop exits). A nil replier short-circuits — no goroutine.
|
|
func (r *Router) scheduleReply(set ResolverSet, inst ResolvedInstallation, msg channel.InboundMessage, res Result) {
|
|
if set.Replier == nil {
|
|
return
|
|
}
|
|
r.replyWg.Add(1)
|
|
go func() {
|
|
defer r.replyWg.Done()
|
|
ctx, cancel := context.WithTimeout(context.Background(), r.replyTimeout)
|
|
defer cancel()
|
|
set.Replier.Reply(ctx, inst, msg, res)
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
r.logger.Warn("channel router: outbound reply timed out",
|
|
"event_id", msg.EventID, "outcome", string(res.Outcome),
|
|
"timeout", r.replyTimeout.String())
|
|
}
|
|
}()
|
|
}
|
|
|
|
// keyForSession is the batcher key. chat_session_id is globally unique.
|
|
func keyForSession(sessionID pgtype.UUID) string {
|
|
return string(sessionID.Bytes[:])
|
|
}
|
|
|
|
// applyFinalize flips the in-flight claim row to its terminal state,
|
|
// token-fenced. Best-effort: a transport failure cannot abort the outcome.
|
|
func (r *Router) applyFinalize(ctx context.Context, set ResolverSet, instID pgtype.UUID, messageID string, claimToken pgtype.UUID, action dedupFinalize) {
|
|
switch action {
|
|
case finalizeMark:
|
|
_ = set.Dedup.Mark(ctx, instID, messageID, claimToken)
|
|
case finalizeRelease:
|
|
_ = set.Dedup.Release(ctx, instID, messageID, claimToken)
|
|
case finalizeNone:
|
|
}
|
|
}
|
|
|
|
func (r *Router) drop(ctx context.Context, set ResolverSet, msg channel.InboundMessage, instID pgtype.UUID, reason DropReason) Result {
|
|
_ = set.Audit.RecordDrop(ctx, instID, msg, reason)
|
|
return Result{Outcome: OutcomeDropped, DropReason: reason, InstallationID: instID}
|
|
}
|
|
|
|
func (r *Router) createIssue(ctx context.Context, inst ResolvedInstallation, originType string, creatorUserID, sessionID pgtype.UUID, cmd IssueCommand) (service.IssueCreateResult, error) {
|
|
if cmd.Title == "" {
|
|
return service.IssueCreateResult{}, ErrEmptyIssueTitle
|
|
}
|
|
params := service.IssueCreateParams{
|
|
WorkspaceID: inst.WorkspaceID,
|
|
Title: cmd.Title,
|
|
Description: pgtype.Text{String: cmd.Description, Valid: cmd.Description != ""},
|
|
Status: "todo",
|
|
Priority: "none",
|
|
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
|
AssigneeID: inst.AgentID,
|
|
CreatorType: "member",
|
|
CreatorID: creatorUserID,
|
|
OriginType: pgtype.Text{String: originType, Valid: originType != ""},
|
|
OriginID: sessionID,
|
|
}
|
|
return r.issues.Create(ctx, params, service.IssueCreateOpts{})
|
|
}
|
|
|
|
// ErrEmptyIssueTitle is returned by createIssue when /issue has no title and
|
|
// the binder's previous-message fallback found nothing usable.
|
|
var ErrEmptyIssueTitle = errors.New("issue title is empty")
|
|
|
|
var _ channel.InboundHandler = (*Router)(nil).Handle
|