Class: Insika::Commands::SendMessage
- Inherits:
-
Object
- Object
- Insika::Commands::SendMessage
- Defined in:
- lib/insika/commands/send_message.rb
Overview
Canonical turn command: validates everything synchronously, creates the Task, fires
the fiber and responds {task_id:} immediately — the result flows through the Event
Stream. Validations that fail do NOT create a Task
(ValidationError/NotFoundError -> direct HTTP response).
Constant Summary collapse
- COALESCABLE_TRANSPORTS =
surfaces whose response can carry the "you do not own the reply" verdict (
mergedforcollect,steeredforsteer), and therefore the only ones where a message may join another turn./v1/responsesis NOT here: its body is OpenAI-shaped SSE with nowhere to put the field, and it is frozen because a live consumer speaks it. Joining a caller that cannot hear the verdict makes it deliver the same answer once per message, which is worse than not joining at all. Channels declare themselves bychannel:<id>once lands. %i[http:json].freeze
Instance Method Summary collapse
- #call(command) ⇒ Object
-
#initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil, contact_store: nil, followup_store: nil, store: nil) ⇒ SendMessage
constructor
inbound_logis the retry window for channel event ids.
Constructor Details
#initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil, contact_store: nil, followup_store: nil, store: nil) ⇒ SendMessage
inbound_log is the retry window for channel event ids.
nil = no dedup (every surface that does not send an event_id, which is all
of them today), and a caller that cannot supply a stable id gets
at-least-once turns rather than a content hash pretending to be dedup.
contact_store/followup_store : the contact-state hook —
a customer message reopens the contact cell and a cancellation keyword
revokes it + falls the pending records. nil = the hook is off (parity).
store is the SHARED backend the two stores ride — the keyword revoke
commits in ONE transaction (D2). nil = best-effort separate writes.
29 30 31 32 33 34 35 36 37 38 39 |
# File 'lib/insika/commands/send_message.rb', line 29 def initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil, contact_store: nil, followup_store: nil, store: nil) @profiles = ProfileSource.coerce(profiles) @session_store = session_store @task_store = task_store @executor = executor @inbound_log = inbound_log @contact_store = contact_store @followup_store = followup_store @store = store end |
Instance Method Details
#call(command) ⇒ Object
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/insika/commands/send_message.rb', line 41 def call(command) p = normalize(command.payload) # accepts string and symbol keys agent = p[:agent].to_s raise Insika::ValidationError, "agent is required" if agent.empty? profile = @profiles[agent] || (raise Insika::NotFoundError, "agent '#{agent}' not configured") # A turn is text OR media. The media half (WS9) is what a voice note # with no caption looks like on the wire — `{ parts: [{type: "audio", # url: …}] }` and nothing else — and demanding a message here made the # anchor use case unreachable end to end: the audio becomes the message # at the :media stage, one step later. = p[:message] if .to_s.strip.empty? && !media?(p[:parts]) raise Insika::ValidationError, "message is required and non-empty (or a media part)" end # session_id XOR history (both -> error; neither -> one-shot). if p[:session_id] && p[:history] raise Insika::ValidationError, "session_id and history are mutually exclusive" end validate_history!(p[:history]) if p[:history] # WHO wrote this message (MessageOrigin). Absent = a customer typed it, which # is what a turn has always meant. Refused here rather than downstream: a # typo'd origin would read as absent, and a marker that silently means # "unmarked" is worse than none — it looks like the filtering is on. origin = Insika::MessageOrigin.parse!(p[:origin]) # `scheduled` is ENGINE-RESERVED — the FollowupEngine's # synthetic turn stamps it, and the edge must not let a consumer # impersonate the engine's kick (a spoofed follow-up is the spam bug). if origin == Insika::MessageOrigin::SCHEDULED raise Insika::ValidationError, "origin 'scheduled' is engine-reserved: it is stamped by the follow-up engine only" end if p[:session_id] @session_store.find(p[:session_id]) || (raise Insika::NotFoundError, "session '#{p[:session_id]}' not found") end # the platform retried a webhook it already delivered. Answer # with the turn it ALREADY produced and run nothing: without this, one flaky # ack costs a second LLM turn and sends the customer the same answer twice. # Checked before the queue doors on purpose — a duplicate is not a fragment to # merge and not a correction to steer with, it is the same message again. key = dedup_key(command, p) if key && (prior = @inbound_log.find(key)) return { task_id: prior, duplicate: true } end # the contact-state hook — the ONLY path that sees every # customer message. A real customer message reopens the contact cell; a # cancellation keyword revokes it and falls the pending records in ONE # transaction. Runs AFTER validation and dedup: a refused or duplicated # message must not touch contact state. Nil-safe and policy-gated: no # profile declaration = the hook is off (parity). touch_contact(p, profile, command, origin) result = start_turn(command, p, profile) @inbound_log.record(key, result[:task_id]) if key result end |