feat: 群聊提醒 — 提醒关联平台/频道/适配器,到期发回原群聊

- reminder_create 新增 platform/channel_type/channel_id/adapter_name 参数
- Gateway Reminder 模型 + DB 迁移 + CRUD 全部支持新字段
- 提醒到期时:群聊提醒 → platform-bridge 发回原群聊
- 非群聊提醒 → 走 Web 端推送
- Thinker 新增 PushPlatformMessage 方法

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:57:58 +08:00
parent 680696e2c4
commit 27a497e397
6 changed files with 77 additions and 15 deletions
+19
View File
@@ -547,6 +547,10 @@ func main() {
Description string `json:"description"` Description string `json:"description"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Platform string `json:"platform,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
AdapterName string `json:"adapter_name,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@@ -580,8 +584,23 @@ func main() {
// Push through the message pusher if available. // Push through the message pusher if available.
if thinker != nil { if thinker != nil {
// If group reminder, push through platform-bridge via thinker
if req.Platform != "" && req.ChannelID != "" {
target := background.ProactiveTarget{
Platform: req.Platform,
ChatType: req.ChannelType,
GroupID: req.ChannelID,
}
if req.AdapterName != "" {
target.Platform = req.AdapterName
}
thinker.PushPlatformMessage(target, reminderMsg)
}
// 只有非群聊提醒才推Web端(群聊提醒已通过platform-bridge发送)
if req.Platform == "" {
thinker.TriggerReminderMessage(req.UserID, req.SessionID, reminderMsg) thinker.TriggerReminderMessage(req.UserID, req.SessionID, reminderMsg)
} }
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`)) w.Write([]byte(`{"status":"ok"}`))
}) })
@@ -397,6 +397,17 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
} }
} }
// PushPlatformMessage pushes a message to a platform channel via the platform pusher.
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
t.mu.Lock()
pusher := t.platformMessagePusher
t.mu.Unlock()
if pusher != nil {
log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message)
pusher(target, message)
}
}
// ThinkerConfig 后台思考配置 // ThinkerConfig 后台思考配置
type ThinkerConfig struct { type ThinkerConfig struct {
Enabled bool Enabled bool
@@ -38,7 +38,7 @@ type Reminder struct {
} }
// CreateReminder calls POST /api/v1/internal/reminders on the Gateway. // CreateReminder calls POST /api/v1/internal/reminders on the Gateway.
func (c *GatewayClient) CreateReminder(ctx context.Context, userID, title, description, remindAt, repeatType, sessionID string) (*Reminder, error) { func (c *GatewayClient) CreateReminder(ctx context.Context, userID, title, description, remindAt, repeatType, sessionID, platform, channelType, channelID, adapterName string) (*Reminder, error) {
body := map[string]interface{}{ body := map[string]interface{}{
"user_id": userID, "user_id": userID,
"title": title, "title": title,
@@ -46,6 +46,10 @@ func (c *GatewayClient) CreateReminder(ctx context.Context, userID, title, descr
"remind_at": remindAt, "remind_at": remindAt,
"repeat_type": repeatType, "repeat_type": repeatType,
"session_id": sessionID, "session_id": sessionID,
"platform": platform,
"channel_type": channelType,
"channel_id": channelID,
"adapter_name": adapterName,
} }
reqBody, _ := json.Marshal(body) reqBody, _ := json.Marshal(body)
@@ -20,7 +20,7 @@ func NewReminderCreateTool(gw *GatewayClient, adminID string) *ReminderCreateToo
func (t *ReminderCreateTool) Definition() ToolDefinition { func (t *ReminderCreateTool) Definition() ToolDefinition {
return ToolDefinition{ return ToolDefinition{
Name: "reminder_create", Name: "reminder_create",
Description: "创建一个定时提醒。可用于提醒用户做某事、定时通知等。支持重复类型(none/daily/weekly/monthly)。时间格式为 ISO8601(如 2026-06-23T08:00:00+08:00)。", Description: "创建一个定时提醒。时间格式为 ISO8601(如 2026-06-23T08:00:00+08:00。如果在群聊中创建提醒,请传入 platform/channel_id/channel_type/adapter_name 以便提醒时发回原群聊。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
@@ -28,6 +28,10 @@ func (t *ReminderCreateTool) Definition() ToolDefinition {
"description": map[string]string{"type": "string", "description": "提醒详细描述(可选)"}, "description": map[string]string{"type": "string", "description": "提醒详细描述(可选)"},
"remind_at": map[string]string{"type": "string", "description": "提醒时间,ISO8601 格式,如 2026-06-23T08:00:00+08:00"}, "remind_at": map[string]string{"type": "string", "description": "提醒时间,ISO8601 格式,如 2026-06-23T08:00:00+08:00"},
"repeat_type": map[string]string{"type": "string", "description": "重复类型:none(不重复), daily(每天), weekly(每周), monthly(每月),默认 none"}, "repeat_type": map[string]string{"type": "string", "description": "重复类型:none(不重复), daily(每天), weekly(每周), monthly(每月),默认 none"},
"platform": map[string]string{"type": "string", "description": "(群聊提醒时必填)平台类型,如 obv11"},
"channel_type": map[string]string{"type": "string", "description": "(群聊提醒时必填)频道类型:group"},
"channel_id": map[string]string{"type": "string", "description": "(群聊提醒时必填)群号"},
"adapter_name": map[string]string{"type": "string", "description": "(群聊提醒时必填)适配器名,如 obv11-main"},
}, },
"required": []string{"title", "remind_at"}, "required": []string{"title", "remind_at"},
}, },
@@ -47,7 +51,11 @@ func (t *ReminderCreateTool) Execute(ctx context.Context, args map[string]interf
return &ToolResult{Success: false, Error: "title 和 remind_at 为必填项"}, nil return &ToolResult{Success: false, Error: "title 和 remind_at 为必填项"}, nil
} }
reminder, err := t.gw.CreateReminder(ctx, t.adminID, title, description, remindAt, repeatType, "") platform, _ := args["platform"].(string)
channelType, _ := args["channel_type"].(string)
channelID, _ := args["channel_id"].(string)
adapterName, _ := args["adapter_name"].(string)
reminder, err := t.gw.CreateReminder(ctx, t.adminID, title, description, remindAt, repeatType, "", platform, channelType, channelID, adapterName)
if err != nil { if err != nil {
return &ToolResult{Success: false, Error: err.Error()}, nil return &ToolResult{Success: false, Error: err.Error()}, nil
} }
@@ -35,7 +35,11 @@ type CreateReminderRequest struct {
RemindAt string `json:"remind_at" binding:"required"` // ISO 8601 格式 RemindAt string `json:"remind_at" binding:"required"` // ISO 8601 格式
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
UserID string `json:"user_id,omitempty"` // 内部调用时直接传 userID UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
AdapterName string `json:"adapter_name,omitempty"` // 内部调用时直接传 userID
} }
// UpdateReminderRequest 更新提醒请求体 // UpdateReminderRequest 更新提醒请求体
@@ -129,6 +133,10 @@ func (h *ReminderHandler) Create(c *gin.Context) {
Status: "pending", Status: "pending",
RepeatType: repeatType, RepeatType: repeatType,
SessionID: req.SessionID, SessionID: req.SessionID,
Platform: req.Platform,
ChannelType: req.ChannelType,
ChannelID: req.ChannelID,
AdapterName: req.AdapterName,
Notified: false, Notified: false,
} }
@@ -360,6 +368,10 @@ func triggerLLMReminder(aiCoreURL, internalToken string, r store.Reminder) {
"description": r.Description, "description": r.Description,
"user_id": r.UserID, "user_id": r.UserID,
"session_id": r.SessionID, "session_id": r.SessionID,
"platform": r.Platform,
"channel_type": r.ChannelType,
"channel_id": r.ChannelID,
"adapter_name": r.AdapterName,
} }
reqBody, _ := json.Marshal(body) reqBody, _ := json.Marshal(body)
req, err := http.NewRequest("POST", aiCoreURL+"/api/v1/internal/reminder-trigger", bytes.NewReader(reqBody)) req, err := http.NewRequest("POST", aiCoreURL+"/api/v1/internal/reminder-trigger", bytes.NewReader(reqBody))
@@ -19,6 +19,10 @@ type Reminder struct {
CompletedAt *time.Time `json:"completed_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"`
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Platform string `json:"platform,omitempty"` // 来源平台
ChannelType string `json:"channel_type,omitempty"` // 频道类型
ChannelID string `json:"channel_id,omitempty"` // 频道ID
AdapterName string `json:"adapter_name,omitempty"` // 适配器名
Notified bool `json:"notified"` Notified bool `json:"notified"`
} }
@@ -59,6 +63,10 @@ func (s *ReminderStore) migrate() error {
`CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at)`, `CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at)`,
`CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status)`, `CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status)`,
`CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at, status, notified)`, `CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at, status, notified)`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS platform VARCHAR(32) DEFAULT ''`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS channel_type VARCHAR(16) DEFAULT ''`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS channel_id VARCHAR(64) DEFAULT ''`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS adapter_name VARCHAR(64) DEFAULT ''`,
} }
for _, q := range queries { for _, q := range queries {
@@ -75,7 +83,7 @@ func (s *ReminderStore) CreateReminder(r *Reminder) error {
r.CreatedAt = time.Now() r.CreatedAt = time.Now()
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO reminders (id, user_id, title, description, remind_at, status, created_at, repeat_type, session_id) `INSERT INTO reminders (id, user_id, title, description, remind_at, status, created_at, repeat_type, session_id, platform, channel_type, channel_id, adapter_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.CreatedAt, r.RepeatType, r.SessionID, r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.CreatedAt, r.RepeatType, r.SessionID,
) )
@@ -99,14 +107,14 @@ func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset
if status != "" { if status != "" {
rows, err = s.db.Query( rows, err = s.db.Query(
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified `SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified, platform, channel_type, channel_id, adapter_name
FROM reminders WHERE user_id = $1 AND status = $2 FROM reminders WHERE user_id = $1 AND status = $2
ORDER BY remind_at ASC LIMIT $3 OFFSET $4`, ORDER BY remind_at ASC LIMIT $3 OFFSET $4`,
userID, status, limit, offset, userID, status, limit, offset,
) )
} else { } else {
rows, err = s.db.Query( rows, err = s.db.Query(
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified `SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified, platform, channel_type, channel_id, adapter_name
FROM reminders WHERE user_id = $1 FROM reminders WHERE user_id = $1
ORDER BY remind_at ASC LIMIT $2 OFFSET $3`, ORDER BY remind_at ASC LIMIT $2 OFFSET $3`,
userID, limit, offset, userID, limit, offset,
@@ -121,7 +129,7 @@ func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset
for rows.Next() { for rows.Next() {
var r Reminder var r Reminder
if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt, if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt,
&r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified); err != nil { &r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified, &r.Platform, &r.ChannelType, &r.ChannelID, &r.AdapterName); err != nil {
return nil, fmt.Errorf("扫描提醒行失败: %w", err) return nil, fmt.Errorf("扫描提醒行失败: %w", err)
} }
reminders = append(reminders, r) reminders = append(reminders, r)
@@ -136,7 +144,7 @@ func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset
// GetDueReminders 获取所有到期且未通知的提醒 // GetDueReminders 获取所有到期且未通知的提醒
func (s *ReminderStore) GetDueReminders() ([]Reminder, error) { func (s *ReminderStore) GetDueReminders() ([]Reminder, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified `SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified, platform, channel_type, channel_id, adapter_name
FROM reminders FROM reminders
WHERE remind_at <= NOW() AND status = 'pending' AND notified = FALSE WHERE remind_at <= NOW() AND status = 'pending' AND notified = FALSE
ORDER BY remind_at ASC`, ORDER BY remind_at ASC`,
@@ -150,7 +158,7 @@ func (s *ReminderStore) GetDueReminders() ([]Reminder, error) {
for rows.Next() { for rows.Next() {
var r Reminder var r Reminder
if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt, if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt,
&r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified); err != nil { &r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified, &r.Platform, &r.ChannelType, &r.ChannelID, &r.AdapterName); err != nil {
return nil, fmt.Errorf("扫描到期提醒行失败: %w", err) return nil, fmt.Errorf("扫描到期提醒行失败: %w", err)
} }
reminders = append(reminders, r) reminders = append(reminders, r)