refactor: extract manager.go fork additions into manager_ext.go

Move fork-specific status/task message handling to reduce upstream conflicts:
- managerExt embedded struct with statusMsgIDs, taskMsgIDs, statusEditTimes
- statusMsgEntry type, fork-specific constants
- handleStatusSend, handleTaskStatusSend, generateDraftID, PromoteStatusToTask

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 16:06:58 +09:00
parent e99ff8b96b
commit 785dae12cf
2 changed files with 298 additions and 279 deletions

View file

@ -10,9 +10,7 @@ import (
"context"
"errors"
"fmt"
"hash/fnv"
"math"
"strings"
"sync"
"time"
@ -37,14 +35,6 @@ const (
janitorInterval = 10 * time.Second
typingStopTTL = 5 * time.Minute
placeholderTTL = 10 * time.Minute
statusMsgTTL = 5 * time.Minute
taskMsgTTL = 30 * time.Minute
// statusEditInterval is the minimum interval between EditMessage calls
// for the same status/task bubble. EditMessage APIs are more rate-sensitive
// than SendMessageDraft, so we throttle edits to avoid "(edited)" flicker
// and API rate limit errors. Draft-based channels bypass this throttle.
statusEditInterval = 500 * time.Millisecond
)
// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.
@ -65,13 +55,6 @@ type placeholderEntry struct {
createdAt time.Time
}
// statusMsgEntry tracks a status or task message ID for later editing.
type statusMsgEntry struct {
messageID string
draftID int // non-zero when using draft-based streaming
createdAt time.Time
}
// channelRateConfig maps channel name to per-second rate limit.
var channelRateConfig = map[string]float64{
"telegram": 20,
@ -93,6 +76,8 @@ type channelWorker struct {
}
type Manager struct {
managerExt // fork-specific fields (see manager_ext.go)
channels map[string]Channel
workers map[string]*channelWorker
bus *bus.MessageBus
@ -103,9 +88,6 @@ type Manager struct {
placeholders sync.Map // "channel:chatID" → placeholderEntry
typingStops sync.Map // "channel:chatID" → typingEntry
reactionUndos sync.Map // "channel:chatID" → reactionEntry
statusMsgIDs sync.Map // "channel:chatID" → statusMsgEntry (streaming preview)
taskMsgIDs sync.Map // "channel:chatID:taskID" → statusMsgEntry (background task status)
statusEditTimes sync.Map // key → time.Time — last EditMessage time for throttling
}
type asyncTask struct {
@ -551,235 +533,6 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
}
}
// handleStatusSend processes IsStatus messages (streaming previews).
// It reuses an existing placeholder or tracked status message, or sends a new
// one via SendWithID so subsequent status updates edit the same bubble.
// For channels implementing DraftSender (e.g. Telegram private chats),
// sendMessageDraft is preferred as it avoids the "(edited)" indicator.
// If the channel doesn't support editing, the message is silently dropped.
func (m *Manager) handleStatusSend(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
if err := w.limiter.Wait(ctx); err != nil {
return
}
key := name + ":" + msg.ChatID
// 0. Draft-based streaming (preferred for supported channels)
if drafter, ok := w.ch.(DraftSender); ok {
var did int
if v, loaded := m.statusMsgIDs.Load(key); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.draftID != 0 {
did = entry.draftID
}
}
if did == 0 {
did = generateDraftID(key)
}
if err := drafter.SendDraft(ctx, msg.ChatID, did, msg.Content); err == nil {
// Track draft only after successful send. If draft fails (e.g. group
// main thread), keep existing messageID entry so fallback edits can
// reuse the same status bubble instead of creating duplicates.
m.statusMsgIDs.Store(key, statusMsgEntry{
draftID: did,
createdAt: time.Now(),
})
return
}
// Draft failed — fall through to edit-based approach
}
// Edit-based path: throttle to statusEditInterval per key to avoid
// API rate limit errors and "(edited)" flicker.
if v, loaded := m.statusEditTimes.Load(key); loaded {
if t, ok := v.(time.Time); ok && time.Since(t) < statusEditInterval {
return // too recent, skip this update
}
}
// 1. Try editing an existing placeholder
if v, loaded := m.placeholders.Load(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
m.statusEditTimes.Store(key, time.Now())
return
}
}
}
}
// 2. Try editing a previously tracked status message
if v, loaded := m.statusMsgIDs.Load(key); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
m.statusEditTimes.Store(key, time.Now())
return
}
}
}
}
// 3. Send new message via SendWithID and track it
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
m.statusMsgIDs.Store(key, statusMsgEntry{
messageID: msgID,
createdAt: time.Now(),
})
return
}
}
// 4. Channel doesn't support SendWithID or editing — drop silently
}
func taskStatusKey(channel, chatID, taskID string) string {
if taskID == "" {
return ""
}
if channel == "" || chatID == "" {
return taskID
}
return channel + ":" + chatID + ":" + taskID
}
// handleTaskStatusSend processes IsTaskStatus messages (background task status).
// It reuses a previously tracked task message, or sends a new one via SendWithID.
// For channels implementing DraftSender, sendMessageDraft is used to avoid "(edited)".
// If the channel doesn't support editing, falls back to regular Send.
func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
if err := w.limiter.Wait(ctx); err != nil {
return
}
taskKey := taskStatusKey(name, msg.ChatID, msg.TaskID)
// Final message: reuse the existing bubble when possible to avoid
// duplicate messages. If a permanent message (messageID) is tracked,
// edit it in-place. If a draft (draftID) is tracked, update it with
// the completion content (the draft persists in Telegram and serves
// as the visible message; sending a separate permanent message would
// create a duplicate).
if msg.Final {
v, loaded := m.taskMsgIDs.LoadAndDelete(taskKey)
m.statusEditTimes.Delete(taskKey)
if loaded {
if entry, ok := v.(statusMsgEntry); ok {
// Path A: a permanent message exists — edit it in-place.
if entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
return
}
}
// Edit failed — fall through to send a new message.
}
// Path B: a draft exists — update it with the final
// content. Drafts persist visibly in Telegram, so do NOT
// send a separate permanent message (that causes duplicates).
if entry.draftID != 0 {
if drafter, ok := w.ch.(DraftSender); ok {
if err := drafter.SendDraft(ctx, msg.ChatID, entry.draftID, msg.Content); err == nil {
return
}
}
// Draft update failed — fall through to send permanent.
}
}
}
// No existing bubble to reuse — send a new permanent message.
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
return
}
}
_ = w.ch.Send(ctx, msg)
return
}
// 0. Draft-based streaming (preferred for supported channels)
if drafter, ok := w.ch.(DraftSender); ok && taskKey != "" {
var did int
if v, loaded := m.taskMsgIDs.Load(taskKey); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.draftID != 0 {
did = entry.draftID
}
}
if did == 0 {
did = generateDraftID(taskKey)
}
if err := drafter.SendDraft(ctx, msg.ChatID, did, msg.Content); err == nil {
// Track draft only after successful send to avoid clobbering an
// existing messageID entry when drafts are unsupported.
m.taskMsgIDs.Store(taskKey, statusMsgEntry{
draftID: did,
createdAt: time.Now(),
})
return
}
// Draft failed — fall through to edit-based approach
}
// Edit-based path: throttle to statusEditInterval per task key.
if taskKey != "" {
if v, loaded := m.statusEditTimes.Load(taskKey); loaded {
if t, ok := v.(time.Time); ok && time.Since(t) < statusEditInterval {
return
}
}
}
// 1. Try editing an existing task message
if taskKey != "" {
if v, loaded := m.taskMsgIDs.Load(taskKey); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
m.statusEditTimes.Store(taskKey, time.Now())
return
}
}
}
}
}
// 2. Send new message via SendWithID and track it
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
if taskKey != "" {
m.taskMsgIDs.Store(taskKey, statusMsgEntry{
messageID: msgID,
createdAt: time.Now(),
})
}
return
}
}
// 3. Fallback: regular Send (for channels without SendWithID)
_ = w.ch.Send(ctx, msg)
}
// generateDraftID produces a stable non-zero int from a key string.
// The same key always maps to the same draft ID so successive calls
// animate the same Telegram draft bubble.
func generateDraftID(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
v := int(h.Sum32())
if v == 0 {
v = 1 // draftID must be non-zero
}
if v < 0 {
v = -v
}
return v
}
// sendWithRetry sends a message through the channel with rate limiting and
// retry logic. It classifies errors to determine the retry strategy:
// - ErrNotRunning / ErrSendFailed: permanent, no retry
@ -1073,26 +826,6 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
}
}
// PromoteStatusToTask moves the tracked streaming status message for the given
// channel:chatID key into the task message map under channel:chatID:taskID. This allows the
// next IsTaskStatus publish to edit the streaming bubble instead of creating a
// new message. Returns true if a status message was found and promoted.
func (m *Manager) PromoteStatusToTask(statusKey, taskID string) bool {
v, loaded := m.statusMsgIDs.LoadAndDelete(statusKey)
if !loaded {
return false
}
parts := strings.SplitN(statusKey, ":", 2)
if len(parts) == 2 {
m.taskMsgIDs.Store(taskStatusKey(parts[0], parts[1], taskID), v)
return true
}
m.taskMsgIDs.Store(taskID, v)
return true
}
func (m *Manager) GetChannel(name string) (Channel, bool) {
m.mu.RLock()
defer m.mu.RUnlock()

286
pkg/channels/manager_ext.go Normal file
View file

@ -0,0 +1,286 @@
package channels
import (
"context"
"hash/fnv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
)
const (
statusMsgTTL = 5 * time.Minute
taskMsgTTL = 30 * time.Minute
// statusEditInterval is the minimum interval between EditMessage calls
// for the same status/task bubble. EditMessage APIs are more rate-sensitive
// than SendMessageDraft, so we throttle edits to avoid "(edited)" flicker
// and API rate limit errors. Draft-based channels bypass this throttle.
statusEditInterval = 500 * time.Millisecond
)
// statusMsgEntry tracks a status or task message ID for later editing.
type statusMsgEntry struct {
messageID string
draftID int // non-zero when using draft-based streaming
createdAt time.Time
}
// managerExt holds fork-specific fields for Manager.
// Embedded in Manager so existing field access continues to work.
type managerExt struct {
statusMsgIDs sync.Map // "channel:chatID" → statusMsgEntry (streaming preview)
taskMsgIDs sync.Map // "channel:chatID:taskID" → statusMsgEntry (background task status)
statusEditTimes sync.Map // key → time.Time — last EditMessage time for throttling
}
// handleStatusSend processes IsStatus messages (streaming previews).
// It reuses an existing placeholder or tracked status message, or sends a new
// one via SendWithID so subsequent status updates edit the same bubble.
// For channels implementing DraftSender (e.g. Telegram private chats),
// sendMessageDraft is preferred as it avoids the "(edited)" indicator.
// If the channel doesn't support editing, the message is silently dropped.
func (m *Manager) handleStatusSend(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
if err := w.limiter.Wait(ctx); err != nil {
return
}
key := name + ":" + msg.ChatID
// 0. Draft-based streaming (preferred for supported channels)
if drafter, ok := w.ch.(DraftSender); ok {
var did int
if v, loaded := m.statusMsgIDs.Load(key); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.draftID != 0 {
did = entry.draftID
}
}
if did == 0 {
did = generateDraftID(key)
}
if err := drafter.SendDraft(ctx, msg.ChatID, did, msg.Content); err == nil {
// Track draft only after successful send. If draft fails (e.g. group
// main thread), keep existing messageID entry so fallback edits can
// reuse the same status bubble instead of creating duplicates.
m.statusMsgIDs.Store(key, statusMsgEntry{
draftID: did,
createdAt: time.Now(),
})
return
}
// Draft failed — fall through to edit-based approach
}
// Edit-based path: throttle to statusEditInterval per key to avoid
// API rate limit errors and "(edited)" flicker.
if v, loaded := m.statusEditTimes.Load(key); loaded {
if t, ok := v.(time.Time); ok && time.Since(t) < statusEditInterval {
return // too recent, skip this update
}
}
// 1. Try editing an existing placeholder
if v, loaded := m.placeholders.Load(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
m.statusEditTimes.Store(key, time.Now())
return
}
}
}
}
// 2. Try editing a previously tracked status message
if v, loaded := m.statusMsgIDs.Load(key); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
m.statusEditTimes.Store(key, time.Now())
return
}
}
}
}
// 3. Send new message via SendWithID and track it
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
m.statusMsgIDs.Store(key, statusMsgEntry{
messageID: msgID,
createdAt: time.Now(),
})
return
}
}
// 4. Channel doesn't support SendWithID or editing — drop silently
}
func taskStatusKey(channel, chatID, taskID string) string {
if taskID == "" {
return ""
}
if channel == "" || chatID == "" {
return taskID
}
return channel + ":" + chatID + ":" + taskID
}
// handleTaskStatusSend processes IsTaskStatus messages (background task status).
// It reuses a previously tracked task message, or sends a new one via SendWithID.
// For channels implementing DraftSender, sendMessageDraft is used to avoid "(edited)".
// If the channel doesn't support editing, falls back to regular Send.
func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
if err := w.limiter.Wait(ctx); err != nil {
return
}
taskKey := taskStatusKey(name, msg.ChatID, msg.TaskID)
// Final message: reuse the existing bubble when possible to avoid
// duplicate messages. If a permanent message (messageID) is tracked,
// edit it in-place. If a draft (draftID) is tracked, update it with
// the completion content (the draft persists in Telegram and serves
// as the visible message; sending a separate permanent message would
// create a duplicate).
if msg.Final {
v, loaded := m.taskMsgIDs.LoadAndDelete(taskKey)
m.statusEditTimes.Delete(taskKey)
if loaded {
if entry, ok := v.(statusMsgEntry); ok {
// Path A: a permanent message exists — edit it in-place.
if entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
return
}
}
// Edit failed — fall through to send a new message.
}
// Path B: a draft exists — update it with the final
// content. Drafts persist visibly in Telegram, so do NOT
// send a separate permanent message (that causes duplicates).
if entry.draftID != 0 {
if drafter, ok := w.ch.(DraftSender); ok {
if err := drafter.SendDraft(ctx, msg.ChatID, entry.draftID, msg.Content); err == nil {
return
}
}
// Draft update failed — fall through to send permanent.
}
}
}
// No existing bubble to reuse — send a new permanent message.
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
return
}
}
_ = w.ch.Send(ctx, msg)
return
}
// 0. Draft-based streaming (preferred for supported channels)
if drafter, ok := w.ch.(DraftSender); ok && taskKey != "" {
var did int
if v, loaded := m.taskMsgIDs.Load(taskKey); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.draftID != 0 {
did = entry.draftID
}
}
if did == 0 {
did = generateDraftID(taskKey)
}
if err := drafter.SendDraft(ctx, msg.ChatID, did, msg.Content); err == nil {
// Track draft only after successful send to avoid clobbering an
// existing messageID entry when drafts are unsupported.
m.taskMsgIDs.Store(taskKey, statusMsgEntry{
draftID: did,
createdAt: time.Now(),
})
return
}
// Draft failed — fall through to edit-based approach
}
// Edit-based path: throttle to statusEditInterval per task key.
if taskKey != "" {
if v, loaded := m.statusEditTimes.Load(taskKey); loaded {
if t, ok := v.(time.Time); ok && time.Since(t) < statusEditInterval {
return
}
}
}
// 1. Try editing an existing task message
if taskKey != "" {
if v, loaded := m.taskMsgIDs.Load(taskKey); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
m.statusEditTimes.Store(taskKey, time.Now())
return
}
}
}
}
}
// 2. Send new message via SendWithID and track it
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
if taskKey != "" {
m.taskMsgIDs.Store(taskKey, statusMsgEntry{
messageID: msgID,
createdAt: time.Now(),
})
}
return
}
}
// 3. Fallback: regular Send (for channels without SendWithID)
_ = w.ch.Send(ctx, msg)
}
// generateDraftID produces a stable non-zero int from a key string.
// The same key always maps to the same draft ID so successive calls
// animate the same Telegram draft bubble.
func generateDraftID(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
v := int(h.Sum32())
if v == 0 {
v = 1 // draftID must be non-zero
}
if v < 0 {
v = -v
}
return v
}
// PromoteStatusToTask moves the tracked streaming status message for the given
// channel:chatID key into the task message map under channel:chatID:taskID. This allows the
// next IsTaskStatus publish to edit the streaming bubble instead of creating a
// new message. Returns true if a status message was found and promoted.
func (m *Manager) PromoteStatusToTask(statusKey, taskID string) bool {
v, loaded := m.statusMsgIDs.LoadAndDelete(statusKey)
if !loaded {
return false
}
parts := strings.SplitN(statusKey, ":", 2)
if len(parts) == 2 {
m.taskMsgIDs.Store(taskStatusKey(parts[0], parts[1], taskID), v)
return true
}
m.taskMsgIDs.Store(taskID, v)
return true
}