返回 DeepSeek-Reasonix
inbox_queue.go
根目录 / internal / bot / inbox_queue.go
1 package bot
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "slices"
9 "strings"
10 "time"
11
12 "reasonix/internal/control"
13 "reasonix/internal/sessioninbox"
14 )
15
16 const botInboxMessageExtraKey = "reasonix.bot.inbound.v1"
17
18 type durableBotMessage struct {
19 Platform Platform `json:"platform"`
20 ConnectionID string `json:"connectionId,omitempty"`
21 Domain string `json:"domain,omitempty"`
22 ChatType ChatType `json:"chatType"`
23 ChatID string `json:"chatId"`
24 UserID string `json:"userId,omitempty"`
25 UserName string `json:"userName,omitempty"`
26 OperatorID string `json:"operatorId,omitempty"`
27 MessageID string `json:"messageId,omitempty"`
28 ThreadID string `json:"threadId,omitempty"`
29 SessionWebhook string `json:"sessionWebhook,omitempty"`
30 }
31
32 func botInboxExtra(msg InboundMessage) map[string]string {
33 data, err := json.Marshal(durableBotMessage{
34 Platform: msg.Platform, ConnectionID: msg.ConnectionID, Domain: msg.Domain,
35 ChatType: msg.ChatType, ChatID: msg.ChatID, UserID: msg.UserID,
36 UserName: msg.UserName, OperatorID: msg.OperatorID, MessageID: msg.MessageID,
37 ThreadID: msg.ThreadID, SessionWebhook: msg.SessionWebhook,
38 })
39 if err != nil {
40 return nil
41 }
42 return map[string]string{botInboxMessageExtraKey: string(data)}
43 }
44
45 func botMessageFromEnvelope(env sessioninbox.PromptEnvelope, fallback InboundMessage) InboundMessage {
46 msg := fallback
47 if raw := env.Extra[botInboxMessageExtraKey]; raw != "" {
48 var stored durableBotMessage
49 if json.Unmarshal([]byte(raw), &stored) == nil {
50 msg.Platform = stored.Platform
51 msg.ConnectionID = stored.ConnectionID
52 msg.Domain = stored.Domain
53 msg.ChatType = stored.ChatType
54 msg.ChatID = stored.ChatID
55 msg.UserID = stored.UserID
56 msg.UserName = stored.UserName
57 msg.OperatorID = stored.OperatorID
58 msg.MessageID = stored.MessageID
59 msg.ThreadID = stored.ThreadID
60 msg.SessionWebhook = stored.SessionWebhook
61 }
62 }
63 msg.Text = firstNonEmptyBotText(env.DisplayText, env.SubmitText, env.RawText)
64 msg.Media = nil
65 msg.MediaURLs = nil
66 msg.ResolveUserName = nil
67 msg.Raw = nil
68 return msg
69 }
70
71 func firstNonEmptyBotText(values ...string) string {
72 for _, value := range values {
73 if strings.TrimSpace(value) != "" {
74 return value
75 }
76 }
77 return ""
78 }
79
80 // enqueueViaInbox durably queues an inbound message on the controller's
81 // session inbox. Platform message IDs are used as idempotency keys.
82 func enqueueViaInbox(ctrl control.SessionAPI, msg InboundMessage, intent sessioninbox.InboxIntent) (sessioninbox.InboxReceipt, error) {
83 if ctrl == nil {
84 return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
85 }
86 if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
87 ensurer.EnsureSessionPath()
88 }
89 text := strings.TrimSpace(msg.Text)
90 if text == "" {
91 return sessioninbox.InboxReceipt{}, sessioninbox.ErrEmpty
92 }
93 idem := strings.TrimSpace(msg.MessageID)
94 req := control.InboxRequest{
95 Intent: intent,
96 Display: text,
97 Raw: text,
98 Submit: text,
99 Source: "bot",
100 Idempotency: idem,
101 Extra: botInboxExtra(msg),
102 }
103 if intent == sessioninbox.IntentSteer {
104 return ctrl.TryEnqueueAndSteer(req)
105 }
106 // Bot owns synchronous response rendering and drains the durable FIFO itself;
107 // detached Controller dispatch would lose the platform sink.
108 return ctrl.EnqueueInbox(req)
109 }
110
111 // collectAppend tries to append text into the last queued follow-up blob within
112 // the debounce window. Falls back to a new enqueue.
113 func collectAppend(ctrl control.SessionAPI, msg InboundMessage, debounce time.Duration) (sessioninbox.InboxReceipt, error) {
114 if ctrl == nil {
115 return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
116 }
117 snap := ctrl.InboxSnapshot()
118 // Find last queued follow-up.
119 var last *sessioninbox.InboxItemMeta
120 for i, it := range slices.Backward(snap.Items) {
121 if it.State == sessioninbox.StateQueued && it.Intent == sessioninbox.IntentFollowup {
122 last = &snap.Items[i]
123 break
124 }
125 }
126 text := strings.TrimSpace(msg.Text)
127 if last != nil && debounce > 0 && time.Since(last.UpdatedAt) < debounce {
128 if _, err := ctrl.AppendInboxItem(last.ID, text, strings.TrimSpace(msg.MessageID), botInboxExtra(msg)); err == nil {
129 return sessioninbox.InboxReceipt{
130 ItemID: last.ID,
131 Disposition: sessioninbox.DispositionQueuedFollowup,
132 Position: snap.Capacity.Items,
133 Paused: snap.Paused,
134 Capacity: snap.Capacity,
135 }, nil
136 }
137 }
138 return enqueueViaInbox(ctrl, msg, sessioninbox.IntentFollowup)
139 }
140
141 // interruptEnqueue cancels the current turn and moves a new item to the front.
142 func interruptEnqueue(ctrl control.SessionAPI, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
143 if ctrl == nil {
144 return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
145 }
146 ctrl.Cancel()
147 rec, err := enqueueViaInbox(ctrl, msg, sessioninbox.IntentFollowup)
148 if err != nil {
149 return rec, err
150 }
151 // Move to front (index 0) so it runs next; do not delete existing queue.
152 if err := ctrl.MoveInboxItem(rec.ItemID, 0); err != nil {
153 slog.Warn("bot: move interrupt item to front", "err", err)
154 }
155 return rec, nil
156 }
157
158 // formatQueuedReceipt is the user-visible durable queue confirmation.
159 func formatQueuedReceipt(rec sessioninbox.InboxReceipt) string {
160 return fmt.Sprintf("已持久排队 #%s", shortItemID(rec.ItemID))
161 }
162
163 func shortItemID(id string) string {
164 if len(id) <= 8 {
165 return id
166 }
167 return id[:8]
168 }
169
170 // warnDeprecatedQueueDrop logs once when an old drop policy is still configured.
171 func warnDeprecatedQueueDrop(drop string) {
172 switch NormalizeQueueDrop(drop) {
173 case QueueDropOld, QueueDropSummarize:
174 slog.Warn("bot: queue_drop is deprecated; capacity rejections no longer drop old messages", "drop", drop)
175 }
176 }
177
178 // handleQueueInboxCommand extends /queue with durable inbox management.
179 // Returns handled=false for mode-switch forms of /queue.
180 func (gw *BotGateway) handleQueueInboxCommand(ctx context.Context, key string, msg InboundMessage) (string, bool, bool) {
181 _ = ctx
182 parts := strings.Fields(msg.Text)
183 if len(parts) < 2 {
184 return "", false, false
185 }
186 sub := strings.ToLower(parts[1])
187 if !isBotInboxCommand(sub) {
188 return "", false, false
189 }
190 api := gw.sessionAPI(key)
191 if api == nil {
192 return "当前没有可管理的会话队列。", true, false
193 }
194 // Group chats: only the same session initiator or admins may read bodies.
195 // Mode-level admin gate is enforced by requireCommandRole on sensitive ops.
196 switch sub {
197 case "list", "ls":
198 return formatBotInboxList(api), true, false
199 case "show":
200 return showBotInboxItem(api, parts), true, false
201 case "delete", "rm":
202 return deleteBotInboxItem(api, parts), true, false
203 case "move":
204 return moveBotInboxItem(api, parts), true, false
205 case "pause":
206 return setBotInboxPaused(api, true), true, false
207 case "resume":
208 reply := setBotInboxPaused(api, false)
209 return reply, true, reply == "inbox resumed"
210 case "retry":
211 reply := retryBotInboxItem(api, parts)
212 return reply, true, strings.HasPrefix(reply, "retry #")
213 case "refresh":
214 return refreshBotInboxItem(api, parts), true, false
215 }
216 return "", false, false
217 }
218
219 func formatBotInboxList(api control.SessionAPI) string {
220 snap := api.InboxSnapshot()
221 if len(snap.Items) == 0 {
222 return "inbox empty" + pausedSuffix(snap.Paused)
223 }
224 var b strings.Builder
225 fmt.Fprintf(&b, "inbox items=%d", len(snap.Items))
226 if snap.Paused {
227 b.WriteString(" paused")
228 }
229 b.WriteByte('\n')
230 limit := min(len(snap.Items), 15)
231 for i := range limit {
232 it := snap.Items[i]
233 fmt.Fprintf(&b, "%d. [%s/%s] %s #%s\n", i+1, it.Intent, it.State, it.Preview, shortItemID(it.ID))
234 }
235 return strings.TrimRight(b.String(), "\n")
236 }
237
238 func showBotInboxItem(api control.SessionAPI, parts []string) string {
239 if len(parts) < 3 {
240 return "用法: /queue show <n|id>"
241 }
242 id, err := resolveBotInboxRef(api, parts[2])
243 if err != nil {
244 return err.Error()
245 }
246 _, env, err := api.ReadInboxItem(id)
247 if err != nil {
248 return "show: " + err.Error()
249 }
250 return env.SubmitText
251 }
252
253 func deleteBotInboxItem(api control.SessionAPI, parts []string) string {
254 if len(parts) < 3 {
255 return "用法: /queue delete <n|id>"
256 }
257 id, err := resolveBotInboxRef(api, parts[2])
258 if err != nil {
259 return err.Error()
260 }
261 if err := api.DeleteInboxItem(id); err != nil {
262 return "delete: " + err.Error()
263 }
264 return "deleted #" + shortItemID(id)
265 }
266
267 func moveBotInboxItem(api control.SessionAPI, parts []string) string {
268 if len(parts) < 4 {
269 return "用法: /queue move <n|id> <to>"
270 }
271 id, err := resolveBotInboxRef(api, parts[2])
272 if err != nil {
273 return err.Error()
274 }
275 var to int
276 if _, err := fmt.Sscanf(parts[3], "%d", &to); err != nil {
277 return "move: bad index"
278 }
279 if err := api.MoveInboxItem(id, to-1); err != nil {
280 return "move: " + err.Error()
281 }
282 return "moved #" + shortItemID(id)
283 }
284
285 func setBotInboxPaused(api control.SessionAPI, paused bool) string {
286 setter, ok := any(api).(interface{ SetInboxPausedPassive(bool) error })
287 var err error
288 if ok {
289 err = setter.SetInboxPausedPassive(paused)
290 } else {
291 err = api.SetInboxPaused(paused)
292 }
293 if err != nil {
294 return err.Error()
295 }
296 if paused {
297 return "inbox paused"
298 }
299 return "inbox resumed"
300 }
301
302 func retryBotInboxItem(api control.SessionAPI, parts []string) string {
303 if len(parts) < 3 {
304 return "用法: /queue retry <n|id>"
305 }
306 id, err := resolveBotInboxRef(api, parts[2])
307 if err != nil {
308 return err.Error()
309 }
310 retrier, ok := any(api).(interface{ RetryInboxItemPassive(string) error })
311 var retryErr error
312 if ok {
313 retryErr = retrier.RetryInboxItemPassive(id)
314 } else {
315 retryErr = api.RetryInboxItem(id)
316 }
317 if retryErr != nil {
318 return retryErr.Error()
319 }
320 return "retry #" + shortItemID(id)
321 }
322
323 func refreshBotInboxItem(api control.SessionAPI, parts []string) string {
324 if len(parts) < 3 {
325 return "用法: /queue refresh <n|id>"
326 }
327 id, err := resolveBotInboxRef(api, parts[2])
328 if err != nil {
329 return err.Error()
330 }
331 if err := api.RefreshInboxReferences(id); err != nil {
332 return err.Error()
333 }
334 return "refs refreshed #" + shortItemID(id)
335 }
336
337 func isBotInboxCommand(sub string) bool {
338 switch sub {
339 case "list", "ls", "show", "delete", "rm", "move", "pause", "resume", "retry", "refresh":
340 return true
341 default:
342 return false
343 }
344 }
345
346 func pausedSuffix(paused bool) string {
347 if paused {
348 return " (paused)"
349 }
350 return ""
351 }
352
353 func resolveBotInboxRef(api control.SessionAPI, ref string) (string, error) {
354 snap := api.InboxSnapshot()
355 var n int
356 if _, err := fmt.Sscanf(ref, "%d", &n); err == nil && n >= 1 && n <= len(snap.Items) {
357 return snap.Items[n-1].ID, nil
358 }
359 for _, it := range snap.Items {
360 if it.ID == ref || strings.HasPrefix(it.ID, ref) {
361 return it.ID, nil
362 }
363 }
364 return "", fmt.Errorf("unknown inbox item %q", ref)
365 }
366
366 lines GO