Files
homeagent-sdk/example/calendar/plugin.go
JianFeeeee 4cb3a0bda4 feat(sdk): 通道方向契约落地 + 模板工程/示例插件显式登记 inputch + 生成器两处修正
## 背景:内核侧发现的真问题

在真实二进制压力测试里发现:插件只调 `RegisterOutputChannel("cli", ...)`,
却用同一个通道名 `InjectTextSync("cli", ...)` 注入输入 ⇒ 内核 inputch 登记表里
**没有**这个通道,"把 inputch 划给驻留子"直接失败(`划入 inputch cli: inputch 未注册`)。

根因是**契约没有落到插件与 SDK 面上**:inputch 是内核最基本的**输入路由单位**,
"谁会往这个通道注入输入"必须显式声明,而 SDK 文档没说清它与 RegisterOutputChannel
的分工,示例与模板工程也没有示范。

## SDK 面

- `RegisterInputChannel` / `RegisterOutputChannel` 的文档补齐**方向契约**:
  入站(谁会注入)与出站(output_send__<name> 的回复发给谁)是分开登记的两件事;
  凡是用 `InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)` 注入的
  通道名都要 RegisterInputChannel。README 同步补了一段契约说明。

## 示例插件(全部补齐,之前只有 qq/weather 是对的)

`a2a`、`acp`、`browser`、`memo`:注入用 `p.name` ⇒ 登记 `p.name`;
`calendar`、`rss`:注入用字面量通道名 ⇒ 登记同名通道。
(这些插件此前是"能注入、但通道不在登记表里",与 cli 同类问题。)

## 模板工程(生成器 templates.go)

- `tmplPluginGo`:示范入站+出站两个方向(含 ChannelDef/NoMemory 说明与 `inputch 未注册` 的成因)。
- `tmplMainLua`:同样两个方向(`register_input_channel` / `register_output_channel`)。
- `tmplReadme`:新增 "Channels" 一节(方向对照表 + 兜底告警说明)。
- 实测:`hmapdev init` 生成的 Go/Lua 工程都含通道代码,Go 工程可构建打包出 `.hmap`;
  `--lua` 工程同样生成通道代码。

## 生成器两处修正(都是实测踩出来的)

1. `sdk install --from <dir>`:install 原本只能从 Release 归档下载,而 SDK 开发期的新能力
   (如 proc 桥要透传的 `InjectOptions.Priority`)还没发版 ⇒ 生成的工程必然编译失败
   (`z_proc_gen.go: opts.Priority undefined`)。现在可用本地源码装一个版本并激活。
   实测:`hmapdev sdk install --from <local sdk>` → 装成 v1.3.0 并激活 → 工程构建通过。
2. 构建前置校验 `sdkHasInjectPriority`:proc 桥模板需要 `InjectOptions.Priority`,
   旧 SDK 没有时应给出**可执行**的报错(升级 SDK 或用 `--from`),
   而不是把两条 `opts.Priority undefined` 编译错误甩给用户(那些错误指向生成物,
   完全看不出是 SDK 版本问题)。实测:声明 sdk=1.2.0 的工程构建时正确命中该提示。

## 未决(发布期事项)

`InjectOptions.Priority` 属本特性线新增能力,**已发布的 SDK v1.2.0 不含它**;
发版时 SDK 版本需随之内含该能力(当前源码 meta 已是 1.3.0),否则外部开发者
按文档生成的工程会撞上上面那条守卫。
2026-09-13 11:37:36 +08:00

1193 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
const (
RepeatNone = "none"
RepeatDaily = "daily"
RepeatWeekday = "weekday"
RepeatWeekly = "weekly"
RepeatBiweekly = "biweekly"
RepeatMonthly = "monthly"
RepeatYearly = "yearly"
RepeatLunarYearly = "lunar_yearly"
)
type CalendarEvent struct {
ID string `json:"id"`
Title string `json:"title"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Location string `json:"location,omitempty"`
Note string `json:"note,omitempty"`
Reminds []int `json:"reminds,omitempty"`
RemindAt []int64 `json:"remind_at,omitempty"`
Repeat string `json:"repeat,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Lunar bool `json:"lunar,omitempty"`
LunarMonth int `json:"lunar_month,omitempty"`
LunarDay int `json:"lunar_day,omitempty"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
dataDir string
mu sync.RWMutex
events []CalendarEvent
nextEventID int
stopCh chan struct{}
wg sync.WaitGroup
remindTicker *time.Ticker
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
}
func (p *Plugin) Name() string { return p.name }
func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T {
v, err := s.Get(key)
if err == nil && v != nil {
if sv, ok := v.(string); ok && sv != "" {
switch any(fallback).(type) {
case string:
return any(sv).(T)
case int64:
if n, err := strconv.ParseInt(sv, 10, 64); err == nil {
return any(n).(T)
}
case float64:
if n, err := strconv.ParseFloat(sv, 64); err == nil {
return any(n).(T)
}
}
}
}
v2, err2 := s.GetCore("plugin." + "calendar" + "." + key)
if err2 == nil && v2 != nil {
if sv, ok := v2.(string); ok && sv != "" {
switch any(fallback).(type) {
case string:
return any(sv).(T)
case int64:
if n, err := strconv.ParseInt(sv, 10, 64); err == nil {
return any(n).(T)
}
case float64:
if n, err := strconv.ParseFloat(sv, 64); err == nil {
return any(n).(T)
}
}
}
}
return fallback
}
func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T {
v, ok := args[key]
if !ok || v == nil {
return fallback
}
switch any(fallback).(type) {
case string:
if s, ok := v.(string); ok {
return any(s).(T)
}
case int64:
switch n := v.(type) {
case float64:
return any(int64(n)).(T)
case int64:
return any(n).(T)
case string:
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
return any(i).(T)
}
}
case float64:
switch n := v.(type) {
case float64:
return any(n).(T)
case int64:
return any(float64(n)).(T)
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return any(f).(T)
}
}
}
return fallback
}
func readArgBool(args map[string]interface{}, key string) bool {
if v, ok := args[key]; ok && v != nil {
if b, ok := v.(bool); ok {
return b
}
if s, ok := v.(string); ok {
return s == "1" || strings.EqualFold(s, "true")
}
}
return false
}
// --- Time Helpers ---
var shortWeekday = map[time.Weekday]string{
time.Monday: "一", time.Tuesday: "二", time.Wednesday: "三",
time.Thursday: "四", time.Friday: "五", time.Saturday: "六", time.Sunday: "日",
}
func parseEventTime(s string) (time.Time, bool, bool) {
t, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local)
if err == nil {
return t, false, true
}
t, err = time.ParseInLocation("2006-01-02", s, time.Local)
if err == nil {
return t, true, true
}
return time.Time{}, false, false
}
// --- Lunar Calendar Engine ---
var lunarInfo = []int{
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0,
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0,
0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160,
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a4d0, 0x0d150, 0x0f252,
0x0d520,
}
func daysInLunarYear(year int) int {
if year < 1900 || year > 2100 {
return 365
}
y := lunarInfo[year-1900]
sum := 0
for i := 0x8000; i > 0x8; i >>= 1 {
if y&i > 0 {
sum += 30
} else {
sum += 29
}
}
return sum + leapDays(year)
}
func leapMonth(year int) int {
if year < 1900 || year > 2100 {
return 0
}
return lunarInfo[year-1900] & 0xf
}
func leapDays(year int) int {
if year < 1900 || year > 2100 {
return 0
}
if leapMonth(year) == 0 {
return 0
}
if lunarInfo[year-1900]&0x10000 > 0 {
return 30
}
return 29
}
func monthDays(year, month int) int {
if year < 1900 || year > 2100 || month < 1 || month > 12 {
return 30
}
if lunarInfo[year-1900]&(0x10000>>month) > 0 {
return 30
}
return 29
}
var baseSolar = func() time.Time { t, _ := time.ParseInLocation("2006-01-02", "1900-01-31", time.Local); return t }()
func lunarToSolar(year, month, day int) (time.Time, bool) {
if year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 30 {
return time.Time{}, false
}
offset := 0
for y := 1900; y < year; y++ {
offset += daysInLunarYear(y)
}
lm := leapMonth(year)
_ = lm
for m := 1; m < month; m++ {
offset += monthDays(year, m)
}
offset += day - 1
solar := baseSolar.AddDate(0, 0, offset)
return solar, true
}
func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bool) {
afterYear := after.Year()
for y := afterYear; y <= afterYear+2; y++ {
t, ok := lunarToSolar(y, targetMonth, targetDay)
if !ok {
continue
}
if t.After(after) {
return t, true
}
}
return time.Time{}, false
}
// --- Plugin Lifecycle ---
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
}
p.loadEvents()
// 持久化交由 stop handler内核会在调用 Stop() 之前执行,
// 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。
s.RegisterStopHandler(p.saveEvents)
// 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。
s.RegisterOnRemoveHandler(p.cleanupData)
tp := p.name + "_"
s.RegisterTool(tp+"event_add", sdk.ToolDef{
Name: tp + "event_add", Description: "Add a calendar event. Time: YYYY-MM-DD HH:MM or YYYY-MM-DD for all-day.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]interface{}{"type": "string", "description": "Event title"},
"start_time": map[string]interface{}{"type": "string", "description": "Start time (YYYY-MM-DD HH:MM or YYYY-MM-DD)"},
"end_time": map[string]interface{}{"type": "string", "description": "End time (optional)"},
"location": map[string]interface{}{"type": "string", "description": "Location (optional)"},
"note": map[string]interface{}{"type": "string", "description": "Notes (optional)"},
"remind_before": map[string]interface{}{"type": "string", "description": "Reminder minutes before event. Multiple: comma-separated, e.g. '15,60,1440' for 15min + 1hr + 1day before. 0 or empty = no reminder."},
"repeat": map[string]interface{}{"type": "string", "description": "Repeat: none, daily, weekday, weekly, biweekly, monthly, yearly, lunar_yearly"},
"lunar": map[string]interface{}{"type": "boolean", "description": "Whether the date is lunar calendar. If true, repeat=lunar_yearly by default. Also set lunar_month and lunar_day."},
"lunar_month": map[string]interface{}{"type": "integer", "description": "Lunar month (1-12), required when lunar=true"},
"lunar_day": map[string]interface{}{"type": "integer", "description": "Lunar day (1-30), required when lunar=true"},
},
"required": []string{"title", "start_time"},
},
}, p.handleEventAdd)
s.RegisterTool(tp+"event_list", sdk.ToolDef{
Name: tp + "event_list", Description: "List upcoming events. Shows date, time, repeat pattern.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"days": map[string]interface{}{"type": "integer", "description": "Days ahead (default 7, max 365)"},
},
},
}, p.handleEventList)
s.RegisterTool(tp+"event_delete", sdk.ToolDef{
Name: tp + "event_delete", Description: "Delete an event by ID. Deletes this and all future recurrences.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "Event ID"},
},
"required": []string{"id"},
},
}, p.handleEventDelete)
s.RegisterTool(tp+"event_update", sdk.ToolDef{
Name: tp + "event_update", Description: "Update an event. Only provided fields change. Resets reminder state.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "Event ID"},
"title": map[string]interface{}{"type": "string", "description": "New title"},
"start_time": map[string]interface{}{"type": "string", "description": "New start time"},
"end_time": map[string]interface{}{"type": "string", "description": "New end time"},
"location": map[string]interface{}{"type": "string", "description": "New location"},
"note": map[string]interface{}{"type": "string", "description": "New notes"},
"remind_before": map[string]interface{}{"type": "string", "description": "New reminder minutes (comma-separated)"},
"repeat": map[string]interface{}{"type": "string", "description": "New repeat type"},
"lunar": map[string]interface{}{"type": "boolean", "description": "Whether lunar calendar"},
"lunar_month": map[string]interface{}{"type": "integer", "description": "Lunar month 1-12"},
"lunar_day": map[string]interface{}{"type": "integer", "description": "Lunar day 1-30"},
},
"required": []string{"id"},
},
}, p.handleEventUpdate)
s.RegisterTool(tp+"today", sdk.ToolDef{
Name: tp + "today", Description: "Show today's events with countdown.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleToday)
s.RegisterTool(tp+"week", sdk.ToolDef{
Name: tp + "week", Description: "Show this week's events grouped by day.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleWeek)
s.RegisterTool(tp+"month", sdk.ToolDef{
Name: tp + "month", Description: "Show a month calendar grid with event dots.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"year": map[string]interface{}{"type": "integer", "description": "Year (default: current)"},
"month": map[string]interface{}{"type": "integer", "description": "Month 1-12 (default: current)"},
},
},
}, p.handleMonth)
s.RegisterTool(tp+"search", sdk.ToolDef{
Name: tp + "search", Description: "Search events by keyword in title, location, or notes.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"keyword": map[string]interface{}{"type": "string", "description": "Search keyword"},
},
"required": []string{"keyword"},
},
}, p.handleSearch)
p.remindTicker = time.NewTicker(30 * time.Second)
p.wg.Add(1)
go p.remindLoop()
fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events))
return nil
}
func (p *Plugin) Stop() error {
p.remindTicker.Stop()
close(p.stopCh)
p.wg.Wait()
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
// --- Reminder Loop ---
func (p *Plugin) remindLoop() {
defer p.wg.Done()
for {
select {
case <-p.remindTicker.C:
p.checkReminders()
case <-p.stopCh:
return
}
}
}
func (p *Plugin) checkReminders() {
now := time.Now()
p.mu.Lock()
changed := false
var injectMsgs []string
for i := range p.events {
e := &p.events[i]
evtTime, allDay, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if allDay || evtTime.Before(now) {
continue
}
for ri, remindMin := range e.Reminds {
if remindMin <= 0 {
continue
}
if ri < len(e.RemindAt) && e.RemindAt[ri] > 0 {
continue
}
remindAt := evtTime.Add(-time.Duration(remindMin) * time.Minute)
if !now.After(remindAt) && !now.Equal(remindAt) {
continue
}
if len(e.RemindAt) <= ri {
e.RemindAt = append(e.RemindAt, make([]int64, ri+1-len(e.RemindAt))...)
}
e.RemindAt[ri] = remindAt.Unix()
changed = true
timeUntil := evtTime.Sub(now).Round(time.Minute)
msg := fmt.Sprintf("⏰ 提醒: %s (%s)", e.Title, e.StartTime)
if timeUntil > 0 {
msg += fmt.Sprintf(" (还有%s)", timeUntil)
}
if len(e.Reminds) > 1 {
msg += fmt.Sprintf(" [第%d次提醒]", ri+1)
}
if e.Location != "" {
msg += fmt.Sprintf("\n📍 %s", e.Location)
}
if e.Note != "" {
msg += fmt.Sprintf("\n📝 %s", e.Note)
}
injectMsgs = append(injectMsgs, msg)
}
}
newEvents := []CalendarEvent{}
for i := range p.events {
e := &p.events[i]
evtTime, _, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if !now.After(evtTime) {
continue
}
if e.Repeat == "" || e.Repeat == RepeatNone {
continue
}
next := p.nextOccurrence(*e, evtTime)
if next != nil {
pid := e.ID
if e.ParentID != "" {
pid = e.ParentID
}
next.ParentID = pid
dup := false
for _, ev := range p.events {
if ev.ID != e.ID && ev.ParentID == pid && ev.StartTime == next.StartTime {
dup = true
break
}
}
if !dup {
newEvents = append(newEvents, *next)
changed = true
}
}
}
if len(newEvents) > 0 {
p.events = append(p.events, newEvents...)
}
p.cleanupPastEvents()
if changed {
p.saveEventsLocked()
}
p.mu.Unlock()
for _, msg := range injectMsgs {
// NoMemory日程到点提醒不是记忆内容。
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
}
}
func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent {
var next time.Time
switch e.Repeat {
case RepeatDaily:
next = evtTime.AddDate(0, 0, 1)
case RepeatWeekday:
next = evtTime.AddDate(0, 0, 1)
for next.Weekday() == time.Saturday || next.Weekday() == time.Sunday {
next = next.AddDate(0, 0, 1)
}
case RepeatWeekly:
next = evtTime.AddDate(0, 0, 7)
case RepeatBiweekly:
next = evtTime.AddDate(0, 0, 14)
case RepeatMonthly:
next = evtTime.AddDate(0, 1, 0)
case RepeatYearly:
next = evtTime.AddDate(1, 0, 0)
case RepeatLunarYearly:
if e.LunarMonth > 0 && e.LunarDay > 0 {
t, ok := nextLunarYearly(e.LunarMonth, e.LunarDay, evtTime)
if ok {
next = t
} else {
return nil
}
} else {
return nil
}
default:
return nil
}
timeStr := next.Format("2006-01-02 15:04")
if e.AllDay {
timeStr = next.Format("2006-01-02")
}
reminds := make([]int, len(e.Reminds))
copy(reminds, e.Reminds)
return &CalendarEvent{
ID: fmt.Sprintf("evt_%d_%d", next.Unix(), p.nextEventID),
Title: e.Title,
StartTime: timeStr,
EndTime: e.EndTime,
AllDay: e.AllDay,
Location: e.Location,
Note: e.Note,
Reminds: reminds,
Repeat: e.Repeat,
ParentID: e.ParentID,
}
}
func (p *Plugin) cleanupPastEvents() {
now := time.Now()
keep := []CalendarEvent{}
for _, e := range p.events {
evtTime, _, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if !now.After(evtTime) {
keep = append(keep, e)
continue
}
_ = e // 过时重复事件不再保留next 已由 nextOccurrence 追加
}
p.events = keep
}
// --- Persistence ---
func (p *Plugin) eventsFile() string {
return filepath.Join(p.dataDir, "events.json")
}
// cleanupData 删除插件时清理本地持久化数据文件。
func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
} else {
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
}
}
func (p *Plugin) loadEvents() {
p.mu.Lock()
defer p.mu.Unlock()
b, err := os.ReadFile(p.eventsFile())
if err != nil {
p.events = nil
p.nextEventID = 1
return
}
var data struct {
Events []CalendarEvent `json:"events"`
NextEventID int `json:"next_id"`
}
if json.Unmarshal(b, &data) != nil {
p.events = nil
p.nextEventID = 1
return
}
p.events = data.Events
p.nextEventID = data.NextEventID
if p.nextEventID < 1 {
p.nextEventID = 1
}
if p.events == nil {
p.events = []CalendarEvent{}
}
}
func (p *Plugin) saveEvents() {
p.mu.RLock()
defer p.mu.RUnlock()
p.saveEventsLocked()
}
func (p *Plugin) saveEventsLocked() {
data := struct {
Events []CalendarEvent `json:"events"`
NextEventID int `json:"next_id"`
}{
Events: p.events,
NextEventID: p.nextEventID,
}
b, _ := json.MarshalIndent(data, "", " ")
atomicWriteJSON(p.eventsFile(), b)
}
// --- Helper: parse remind_before ---
func parseReminds(s string) []int {
s = strings.TrimSpace(s)
if s == "" || s == "0" {
return nil
}
parts := strings.Split(s, ",")
vals := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
v, err := strconv.Atoi(p)
if err != nil || v <= 0 {
continue
}
vals = append(vals, v)
}
sort.Ints(vals)
return vals
}
// --- Helper: format event duration ---
func formatTimeUntil(t time.Time) string {
now := time.Now()
if t.Before(now) {
return "已开始"
}
d := t.Sub(now)
if d < time.Hour {
m := int(d.Minutes())
return fmt.Sprintf("还有%d分钟", m)
}
if d < 24*time.Hour {
h := int(d.Hours())
m := int(d.Minutes()) % 60
if m > 0 {
return fmt.Sprintf("还有%d小时%d分", h, m)
}
return fmt.Sprintf("还有%d小时", h)
}
d2 := int(d.Hours() / 24)
return fmt.Sprintf("还有%d天", d2)
}
// --- Tool: event_add ---
func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error) {
title := readArg(args, "title", "")
startTime := readArg(args, "start_time", "")
if title == "" || startTime == "" {
return map[string]interface{}{"isError": true, "content": "title and start_time are required"}, nil
}
parsedStart, allDay, ok := parseEventTime(startTime)
if !ok {
return map[string]interface{}{"isError": true, "content": "Invalid start_time. Use YYYY-MM-DD HH:MM or YYYY-MM-DD."}, nil
}
endTime := readArg(args, "end_time", "")
if endTime != "" {
if _, _, ok := parseEventTime(endTime); !ok {
return map[string]interface{}{"isError": true, "content": "Invalid end_time."}, nil
}
}
location := readArg(args, "location", "")
note := readArg(args, "note", "")
remindStr := readArg(args, "remind_before", "")
reminds := parseReminds(remindStr)
lunar := readArgBool(args, "lunar")
lunarMonth := int(readArg(args, "lunar_month", int64(0)))
lunarDay := int(readArg(args, "lunar_day", int64(0)))
repeat := readArg(args, "repeat", RepeatNone)
if lunar && repeat == RepeatNone {
repeat = RepeatLunarYearly
}
switch repeat {
case RepeatNone, RepeatDaily, RepeatWeekday, RepeatWeekly, RepeatBiweekly, RepeatMonthly, RepeatYearly, RepeatLunarYearly:
default:
repeat = RepeatNone
}
if lunar && (lunarMonth < 1 || lunarMonth > 12 || lunarDay < 1 || lunarDay > 30) {
return map[string]interface{}{"isError": true, "content": "lunar_month (1-12) and lunar_day (1-30) required when lunar=true"}, nil
}
event := CalendarEvent{
ID: fmt.Sprintf("evt_%d_%d", parsedStart.Unix(), p.nextEventID),
Title: title,
StartTime: startTime,
EndTime: endTime,
AllDay: allDay,
Location: location,
Note: note,
Reminds: reminds,
Repeat: repeat,
Lunar: lunar,
LunarMonth: lunarMonth,
LunarDay: lunarDay,
}
p.mu.Lock()
p.events = append(p.events, event)
p.nextEventID++
p.mu.Unlock()
p.saveEvents()
detail := fmt.Sprintf("Event added: %s (ID: %s)", title, event.ID)
if len(reminds) > 0 {
parts := make([]string, len(reminds))
for i, r := range reminds {
parts[i] = fmt.Sprintf("%dmin", r)
}
detail += fmt.Sprintf(" | 提醒: %s", strings.Join(parts, ", "))
}
if repeat != RepeatNone {
detail += " | 重复: " + repeat
}
return map[string]interface{}{"content": detail}, nil
}
// --- Tool: event_list ---
func (p *Plugin) handleEventList(args map[string]interface{}) (interface{}, error) {
days := int(readArg(args, "days", int64(7)))
if days < 1 {
days = 1
}
if days > 365 {
days = 365
}
now := time.Now()
cutoff := now.AddDate(0, 0, days)
p.mu.RLock()
upcoming := make([]CalendarEvent, 0)
for _, e := range p.events {
evtTime, _, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if evtTime.Before(cutoff) && evtTime.After(now.Add(-24*time.Hour)) {
upcoming = append(upcoming, e)
}
}
p.mu.RUnlock()
sort.Slice(upcoming, func(i, j int) bool {
return upcoming[i].StartTime < upcoming[j].StartTime
})
if len(upcoming) == 0 {
return map[string]interface{}{"content": fmt.Sprintf("No events in the next %d days.", days)}, nil
}
var lines []string
lines = append(lines, fmt.Sprintf("📋 Events (%d):", len(upcoming)))
for _, e := range upcoming {
timeStr := e.StartTime
if e.EndTime != "" {
timeStr += " → " + e.EndTime
}
extra := ""
if e.Location != "" {
extra += " 📍" + e.Location
}
if len(e.Reminds) > 0 {
parts := make([]string, len(e.Reminds))
for i, r := range e.Reminds {
parts[i] = fmt.Sprintf("%d", r)
}
extra += " 🔔" + strings.Join(parts, ",")
}
if e.Lunar {
extra += fmt.Sprintf(" 🌙%d-%d", e.LunarMonth, e.LunarDay)
}
if e.Repeat != "" && e.Repeat != RepeatNone {
extra += " 🔄" + e.Repeat
}
if e.Note != "" {
extra += " 📝" + e.Note
}
lines = append(lines, fmt.Sprintf(" [%s] %s%s", timeStr, e.Title, extra))
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
// --- Tool: event_delete ---
func (p *Plugin) handleEventDelete(args map[string]interface{}) (interface{}, error) {
id := readArg(args, "id", "")
if id == "" {
return map[string]interface{}{"isError": true, "content": "Event ID is required"}, nil
}
p.mu.Lock()
defer p.mu.Unlock()
found := false
remaining := []CalendarEvent{}
for _, e := range p.events {
if e.ID == id {
found = true
continue
}
pid := e.ParentID
if pid == "" {
pid = e.ID
}
if pid == id {
continue
}
remaining = append(remaining, e)
}
if !found {
return map[string]interface{}{"isError": true, "content": "Event not found: " + id}, nil
}
p.events = remaining
p.saveEventsLocked()
return map[string]interface{}{"content": "Deleted event and all recurrences: " + id}, nil
}
// --- Tool: event_update ---
func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, error) {
id := readArg(args, "id", "")
if id == "" {
return map[string]interface{}{"isError": true, "content": "Event ID is required"}, nil
}
p.mu.Lock()
defer p.mu.Unlock()
for i := range p.events {
if p.events[i].ID != id {
continue
}
e := &p.events[i]
if v := readArg(args, "title", ""); v != "" {
e.Title = v
}
if v := readArg(args, "start_time", ""); v != "" {
if _, allDay, ok := parseEventTime(v); ok {
e.StartTime = v
e.AllDay = allDay
}
}
if v := readArg(args, "end_time", ""); v != "" {
if _, _, ok := parseEventTime(v); ok {
e.EndTime = v
}
}
if v := readArg(args, "location", ""); v != "" {
e.Location = v
}
if v := readArg(args, "note", ""); v != "" {
e.Note = v
}
if v := readArg(args, "remind_before", ""); v != "" {
e.Reminds = parseReminds(v)
}
if v := readArg(args, "repeat", ""); v != "" {
switch v {
case RepeatNone, RepeatDaily, RepeatWeekday, RepeatWeekly, RepeatBiweekly, RepeatMonthly, RepeatYearly, RepeatLunarYearly:
e.Repeat = v
}
}
if v, ok := args["lunar"]; ok && v != nil {
if b, ok := v.(bool); ok {
e.Lunar = b
} else if s, ok := v.(string); ok {
e.Lunar = s == "1" || strings.EqualFold(s, "true")
}
}
if v := readArg(args, "lunar_month", int64(0)); v > 0 {
e.LunarMonth = int(v)
}
if v := readArg(args, "lunar_day", int64(0)); v > 0 {
e.LunarDay = int(v)
}
e.RemindAt = nil
p.saveEventsLocked()
return map[string]interface{}{"content": "Event updated: " + e.Title}, nil
}
return map[string]interface{}{"isError": true, "content": "Event not found: " + id}, nil
}
// --- Tool: today ---
func (p *Plugin) handleToday(args map[string]interface{}) (interface{}, error) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.AddDate(0, 0, 1)
p.mu.RLock()
events := make([]CalendarEvent, 0)
for _, e := range p.events {
evtTime, _, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if evtTime.After(todayStart.Add(-time.Hour)) && evtTime.Before(todayEnd) {
events = append(events, e)
}
}
p.mu.RUnlock()
sort.Slice(events, func(i, j int) bool {
return events[i].StartTime < events[j].StartTime
})
dateStr := now.Format("2006-01-02")
weekday := shortWeekday[now.Weekday()]
lines := []string{fmt.Sprintf("📅 %s 周%s — 今天", dateStr, weekday)}
if len(events) == 0 {
lines = append(lines, " 今天没有事件")
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
for _, e := range events {
evtTime, _, _ := parseEventTime(e.StartTime)
timeStr := e.StartTime
if now.Format("2006-01-02") == evtTime.Format("2006-01-02") {
timeStr = evtTime.Format("15:04")
}
countdown := formatTimeUntil(evtTime)
detail := fmt.Sprintf(" %s — %s (%s)", timeStr, e.Title, countdown)
if e.AllDay {
detail = fmt.Sprintf(" 🌙 %s (全天)", e.Title)
}
if e.Location != "" {
detail += " 📍" + e.Location
}
lines = append(lines, detail)
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
// --- Tool: week ---
func (p *Plugin) handleWeek(args map[string]interface{}) (interface{}, error) {
now := time.Now()
weekStart := now.AddDate(0, 0, -int(now.Weekday()-time.Monday))
if now.Weekday() == time.Sunday {
weekStart = now.AddDate(0, 0, -6)
}
weekEnd := weekStart.AddDate(0, 0, 7)
p.mu.RLock()
dayEvents := make(map[string][]CalendarEvent)
for _, e := range p.events {
evtTime, _, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if evtTime.After(weekStart.Add(-time.Hour)) && evtTime.Before(weekEnd) {
dayKey := evtTime.Format("2006-01-02")
dayEvents[dayKey] = append(dayEvents[dayKey], e)
}
}
p.mu.RUnlock()
for k := range dayEvents {
sort.Slice(dayEvents[k], func(i, j int) bool {
return dayEvents[k][i].StartTime < dayEvents[k][j].StartTime
})
}
lines := []string{fmt.Sprintf("📅 %s %s", weekStart.Format("01-02"), weekEnd.AddDate(0, 0, -1).Format("01-02"))}
eventCount := 0
for i := 0; i < 7; i++ {
d := weekStart.AddDate(0, 0, i)
dayKey := d.Format("2006-01-02")
wd := shortWeekday[d.Weekday()]
prefix := " "
if d.Format("2006-01-02") == now.Format("2006-01-02") {
prefix = "▶"
}
line := fmt.Sprintf("%s %s %s", prefix, d.Format("01-02"), wd)
if evts, ok := dayEvents[dayKey]; ok && len(evts) > 0 {
titles := make([]string, len(evts))
for i, e := range evts {
timeStr := e.StartTime
if !e.AllDay {
timeStr = parseTimeShort(e.StartTime)
} else {
timeStr = "全天"
}
titles[i] = fmt.Sprintf("%s %s", timeStr, e.Title)
eventCount++
}
line += " " + strings.Join(titles, ", ")
}
lines = append(lines, line)
}
if eventCount == 0 {
lines = append(lines, " 本周没有事件")
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
func parseTimeShort(s string) string {
t, _, ok := parseEventTime(s)
if !ok {
return s
}
return t.Format("15:04")
}
// --- Tool: month ---
func (p *Plugin) handleMonth(args map[string]interface{}) (interface{}, error) {
now := time.Now()
year := int(readArg(args, "year", int64(now.Year())))
month := int(readArg(args, "month", int64(now.Month())))
if month < 1 || month > 12 {
month = int(now.Month())
}
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
lastDay := firstDay.AddDate(0, 1, -1)
daysInMonth := lastDay.Day()
startWeekday := int(firstDay.Weekday())
if startWeekday == 0 {
startWeekday = 7
}
p.mu.RLock()
daySet := make(map[int]bool)
for _, e := range p.events {
evtTime, _, ok := parseEventTime(e.StartTime)
if !ok {
continue
}
if evtTime.Year() == year && evtTime.Month() == time.Month(month) {
daySet[evtTime.Day()] = true
}
}
p.mu.RUnlock()
monthName := firstDay.Format("January")
lines := []string{fmt.Sprintf("📅 %d年%d月 (%s)", year, month, monthName)}
lines = append(lines, " 一 二 三 四 五 六 日")
lines = append(lines, "")
row := " "
for i := 1; i < startWeekday; i++ {
row += " "
}
for d := 1; d <= daysInMonth; d++ {
mark := " "
if daySet[d] {
mark = "•"
}
row += fmt.Sprintf(" %2d%s", d, mark)
wd := startWeekday - 1 + d
if wd%7 == 0 || d == daysInMonth {
lines = append(lines, row)
row = " "
}
}
count := 0
for d := range daySet {
count++
_ = d
}
lines = append(lines, fmt.Sprintf("\n本月 %d 天有事件", count))
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
// --- Tool: search ---
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
keyword := strings.ToLower(readArg(args, "keyword", ""))
if keyword == "" {
return map[string]interface{}{"isError": true, "content": "keyword is required"}, nil
}
p.mu.RLock()
results := make([]CalendarEvent, 0)
for _, e := range p.events {
if strings.Contains(strings.ToLower(e.Title), keyword) ||
strings.Contains(strings.ToLower(e.Location), keyword) ||
strings.Contains(strings.ToLower(e.Note), keyword) {
results = append(results, e)
}
}
p.mu.RUnlock()
sort.Slice(results, func(i, j int) bool {
return results[i].StartTime < results[j].StartTime
})
if len(results) == 0 {
return map[string]interface{}{"content": fmt.Sprintf("No events match: %s", keyword)}, nil
}
var lines []string
lines = append(lines, fmt.Sprintf("🔍 Found %d events for \"%s\":", len(results), keyword))
for _, e := range results {
lines = append(lines, fmt.Sprintf(" [%s] %s (ID: %s)", e.StartTime, e.Title, e.ID))
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}