mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 08:58:03 +00:00
## 背景:内核侧发现的真问题
在真实二进制压力测试里发现:插件只调 `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),否则外部开发者
按文档生成的工程会撞上上面那条守卫。
1193 lines
33 KiB
Go
1193 lines
33 KiB
Go
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)
|
||
}
|