diff --git a/CHANGELOG.md b/CHANGELOG.md index 77fb53a..2465e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- **通知専用カレンダー(`digest_calendars`)**: ブロッカー配布せずダイジェストにだけ載せるカレンダーを指定可能に(google のみ・リマインド対象外) - **Slack 通知の Block Kit 化(v2)**: ダイジェストを予定ごとのブロック表示にし、件名をカレンダーの当該予定へのリンクに、Zoom / Meet / Teams の会議 URL を「参加」ボタンにした(conferenceData / onlineMeeting → location・本文の URL 検出の順で抽出)。開始前リマインドに会議参加ボタンと本文全文(プレーンテキスト化・3,000 字制限内に切り詰め)を追加。通知プレビューには従来のテキスト形式を fallback として維持し、blocks が不正な場合はテキストのみで 1 回縮退再送する - **Slack 通知 v2.1(実表示フィードバック反映)**: ダイジェストの予定行を予定ごとの色付き attachment(`accounts` の定義順で固定パレットを巡回割当、未知アカウントは灰色)に変更し、表示上限を 46 件から 20 件に短縮(超過は「他 N 件」)。リマインドも単一 attachment 化。`chat.postMessage` に `unfurl_links` / `unfurl_media` を常に付与し htmlLink・会議 URL のプレビュー展開を抑止。縮退再送のトリガーに `invalid_attachments` を追加 diff --git a/README.md b/README.md index 57fe97a..e364499 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,30 @@ notifications: - `morning_digest` と `remind_before` の両方を省略すると設定エラーになります(設定したのに何も送られない事故の防止) - トークンは環境変数からのみ読みます(YAML にトークンを書かないでください)。検証は `calsync run` 起動時のみ行われ、トークンが空だと起動を拒否します(`status` / `doctor` はトークンなしでも動作します) +### 通知専用カレンダー(`digest_calendars`) + +「他アカウントへブロッカーを配布したくないが、朝のダイジェストには載せたい」カレンダーをアカウントごとに指定できます(例: 個人アカウントの「ゴミの日」カレンダーは予定として知りたいが、それで仕事カレンダーを「予定あり」ブロックしたくない場合)。 + +```yaml +accounts: + - id: personal + provider: google + email: user@gmail.com + digest_calendars: ["xxxxx@group.calendar.google.com"] # 通知専用(ブロック元にはならない) +``` + +- 対象カレンダーはダイジェストの**ライブ取得にだけ**参加します。`calendars`(監視対象)には含まれないため、同期(カーソル・イベントキャッシュ・日次リコンサイル・ブロッカー配布)には一切関与しません +- **google アカウントのみ**指定できます(microsoft は v1 制約により非対応。Graph 実装が `/me/calendarView` 固定でプライマリ以外を取得できないため) +- 同一アカウント内で `calendars` との重複、`digest_calendars` 内の重複、空文字列はいずれも設定エラーになります +- **リマインド対象外です**。イベントキャッシュに入らないため構造的に発火しません(free の予定・終日予定がリマインド対象外なのと同列の制約です) + +#### カレンダー ID の調べ方 + +1. Google カレンダーの設定画面を開く +2. 左側の「マイカレンダー」から対象カレンダーを選ぶ +3. 「カレンダーの設定」→ 対象カレンダーの設定ページ → 「カレンダーの統合」 +4. 「カレンダー ID」欄の値(`xxxxx@group.calendar.google.com` 形式)をコピーし、`digest_calendars` に指定する + ### Slack App の作成手順 1. [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch** @@ -237,7 +261,7 @@ SLACK_BOT_TOKEN=xoxb-... ### 制約 -- リマインドの対象は「busy かつ時刻指定の予定」のみです。**free の予定・終日予定はリマインド対象外**です(v1 制約)。ダイジェストはライブ取得のため両方とも件名付きで含まれます +- リマインドの対象は「busy かつ時刻指定の予定」のみです。**free の予定・終日予定・`digest_calendars` の予定はリマインド対象外**です(v1 制約)。ダイジェストはライブ取得のため free の予定・`digest_calendars` の予定も件名付きで含まれます - calsync を停止していた間に日付を跨いだ場合、その日のダイジェストはキャッチアップされません(`reconcile_at` と同じ制約です) - **通知先チャンネルの公開範囲に注意してください**。ダイジェスト・リマインドとも予定の件名がそのまま通知に含まれます。それに加えて、**リマインドには予定の本文(description)が全文表示され、会議 URL が見つかった予定にはダイジェスト・リマインドの両方に「参加」ボタンが付きます**。会議 URL には Zoom のパスワード付きリンクのように参加用の秘密情報が含まれる場合があり、本文にも会議の詳細や機密情報が書かれている可能性があります。共有範囲の広いチャンネルを指定すると、予定の件名だけでなく会議への参加手段や本文の内容まで意図せず広く見える可能性があるため、`channel` には信頼できる範囲のメンバーのみが参加するチャンネルまたは DM を指定してください diff --git a/docs/superpowers/specs/2026-07-06-digest-calendars-design.md b/docs/superpowers/specs/2026-07-06-digest-calendars-design.md new file mode 100644 index 0000000..793aefe --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-digest-calendars-design.md @@ -0,0 +1,48 @@ +# calsync 通知専用カレンダー(digest_calendars)設計書 + +作成日: 2026-07-06 +ステータス: 承認済みドラフト(実装計画の入力) +親設計書: [2026-07-06-slack-notifications-v2-design.md](2026-07-06-slack-notifications-v2-design.md)(v2。本書は差分) + +## 1. 概要・動機 + +「他アカウントへブロッカーを配布したくないが、朝のダイジェストには載せたい」カレンダーのための第 3 の役割を追加する(実例: 個人アカウントの「佐伯区ゴミ」カレンダー — ゴミの日は予定として通知されてほしいが、仕事カレンダーを「予定あり」でブロックしてはならない)。 + +既存の役割は「監視(= ブロック元・`calendars`)」と「ブロッカー置き場(`blocker_calendar`)」の 2 つ。監視対象に足す回避策は、(a) busy 予定が 1 件でもあれば全アカウントへ配布される(イベント単位の transparency 頼みで保証がない)、(b) カーソル・キャッシュ・日次リコンサイルのオーバーヘッドが用途に対して過剰、のため不採用。 + +## 2. 設計 + +```yaml +accounts: + - id: personal + provider: google + email: okash1n.fcs@gmail.com + digest_calendars: ["xxxxx@group.calendar.google.com"] # 通知専用(ブロック元にならない) +``` + +- **`accounts[].digest_calendars []string`**(新設・省略可): ダイジェストの**ライブ取得にだけ**参加するカレンダー ID のリスト +- **同期エンジンには一切関与しない**: `calendars`(監視)に含まれないため、tick の SyncCalendar・カーソル・events キャッシュ・日次リコンサイル・ブロッカー配布の対象に**構造的に**ならない。エンジン側の決定則・不変条件は無変更 +- `collectDigest` のカレンダーループを `acct.Calendars` → `acct.DigestCalendars` の順の連結にする(取得失敗の failed 集約はアカウント単位で従来どおり)。ブロッカー除外(IsBlocker/OriginTag)・dedupe・ソートは既存規則がそのまま適用される(同一アカウント内の重複招待も iCalUID+開始時刻 dedupe が既に吸収する) +- **リマインドは対象外**(キャッシュに入らないため構造的に発火しない)。v1 制約(free・終日のリマインドなし)と同列の制約として明記 + +## 3. 検証(config.Load) + +- `digest_calendars` は **google アカウントのみ許容**。microsoft はエラー(v1 の Graph 実装は `/me/calendarView` 固定でプライマリ以外を取得できないため — 既存の「microsoft は primary のみ」制約と同型のメッセージ) +- 同一アカウント内で `calendars` と `digest_calendars` の**重複はエラー**(二重取得・二重表示の防止) +- `digest_calendars` 内の重複もエラー。空文字列はエラー + +## 4. テスト計画 + +- **config**: google で通る / microsoft でエラー / calendars との重複エラー / リスト内重複・空文字列エラー / 省略時は空 +- **engine(fake)**: digest_calendars のイベントがダイジェストに入る / 同カレンダーの busy イベントでも**ブロッカーが作られない**(tick を回して fake の Blockers が空のまま — 本機能の核心の回帰テスト)/ 監視カレンダーとの dedupe / digest_calendars の取得失敗が failed に載る +- 全テスト `-race -count=1` + +## 5. ドキュメント + +- README: `digest_calendars` の説明+**カレンダー ID の調べ方**(Google カレンダー設定 → 対象カレンダー → 「カレンダーの統合」→ カレンダー ID)+「リマインド対象外」の明記 +- CHANGELOG `[Unreleased]` に追記 + +## 6. スコープ外 + +- 通知専用カレンダーのリマインド(必要になったら「監視するが配布しない」役割として別途設計) +- microsoft の非プライマリカレンダー対応(v1 制約のまま) diff --git a/internal/config/config.go b/internal/config/config.go index 980667d..b580b09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,7 +48,11 @@ type SlackConfig struct { type Account struct { ID, Provider, Email string // Provider は "google" | "microsoft" Calendars []string // 既定 ["primary"]。microsoft は ["primary"] 以外エラー(v1制約) - BlockerCalendar string // 既定 "primary" + // DigestCalendars: ダイジェストのライブ取得にだけ参加するカレンダー ID。 + // 監視対象(Calendars)には含まれないため、tick の同期・カーソル・events キャッシュ・ + // 日次リコンサイル・ブロッカー配布の対象に構造的にならない。google のみ許容(v1制約)。 + DigestCalendars []string + BlockerCalendar string // 既定 "primary" // ShowOriginInDescription: true のとき、このアカウントのカレンダーに作成される // ブロッカーの説明欄に元アカウントの ID(YAML の id。メールアドレスは含めない)を // 記載する。既定 false(完全匿名)。トグル変更は次回リコンサイルで既存分にも遡及する。 @@ -98,6 +102,7 @@ type rawAccount struct { Provider string `yaml:"provider"` Email string `yaml:"email"` Calendars []string `yaml:"calendars"` + DigestCalendars []string `yaml:"digest_calendars"` BlockerCalendar string `yaml:"blocker_calendar"` ShowOriginInDescription bool `yaml:"show_origin_in_description"` } @@ -224,6 +229,7 @@ func Load(path string) (*Config, error) { Provider: ra.Provider, Email: ra.Email, Calendars: ra.Calendars, + DigestCalendars: ra.DigestCalendars, BlockerCalendar: ra.BlockerCalendar, ShowOriginInDescription: ra.ShowOriginInDescription, } @@ -258,6 +264,31 @@ func Load(path string) (*Config, error) { if a.BlockerCalendar != "primary" { return nil, fmt.Errorf("config: account %q: microsoft supports only the primary calendar in v1 (got blocker_calendar %q)", a.ID, a.BlockerCalendar) } + if len(a.DigestCalendars) > 0 { + // v1 の Graph 実装は /me/calendarView 固定でプライマリ以外を取得できないため、 + // 既存の「microsoft は primary のみ」制約と同型でエラーにする(スペック 3 章)。 + return nil, fmt.Errorf("config: account %q: microsoft supports only the primary calendar in v1 (got digest_calendars %q)", a.ID, a.DigestCalendars) + } + } + // digest_calendars 内の重複・空文字列、および calendars との重複を検証する + // (二重取得・二重表示の防止。スペック 3 章)。microsoft は上で既に弾かれているため + // ここに到達するのは google のみ。 + calSet := make(map[string]bool, len(a.Calendars)) + for _, cal := range a.Calendars { + calSet[cal] = true + } + seenDigest := make(map[string]bool, len(a.DigestCalendars)) + for _, cal := range a.DigestCalendars { + if cal == "" { + return nil, fmt.Errorf("config: account %q: digest_calendars entries must not be empty", a.ID) + } + if calSet[cal] { + return nil, fmt.Errorf("config: account %q: digest_calendars %q duplicates calendars", a.ID, cal) + } + if seenDigest[cal] { + return nil, fmt.Errorf("config: account %q: duplicate digest_calendars entry %q", a.ID, cal) + } + seenDigest[cal] = true } cfg.Accounts = append(cfg.Accounts, a) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 477e5d0..637d897 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,6 +47,7 @@ func TestLoad(t *testing.T) { require.Equal(t, "user@gmail.com", c.Accounts[0].Email) require.Equal(t, []string{"primary"}, c.Accounts[0].Calendars) require.Equal(t, "primary", c.Accounts[0].BlockerCalendar) + require.Empty(t, c.Accounts[0].DigestCalendars) }, }, { @@ -190,6 +191,65 @@ accounts: `, wantErr: "microsoft supports only the primary calendar", }, + { + name: "digest_calendars are accepted for google accounts", + yaml: ` +accounts: + - id: personal + provider: google + email: user@gmail.com + calendars: [primary] + digest_calendars: [gomi@group.calendar.google.com] +`, + check: func(t *testing.T, c *Config) { + require.Equal(t, []string{"gomi@group.calendar.google.com"}, c.Accounts[0].DigestCalendars) + }, + }, + { + name: "digest_calendars are rejected for microsoft accounts (v1 constraint)", + yaml: ` +accounts: + - id: work-ms + provider: microsoft + email: a@example.com + digest_calendars: [second] +`, + wantErr: "microsoft supports only the primary calendar", + }, + { + name: "digest_calendars duplicating calendars is rejected", + yaml: ` +accounts: + - id: personal + provider: google + email: user@gmail.com + calendars: [primary, team@group.calendar.google.com] + digest_calendars: [team@group.calendar.google.com] +`, + wantErr: "duplicates calendars", + }, + { + name: "duplicate entries within digest_calendars are rejected", + yaml: ` +accounts: + - id: personal + provider: google + email: user@gmail.com + digest_calendars: [gomi@x, gomi@x] +`, + wantErr: "duplicate digest_calendars entry", + }, + { + name: "empty string in digest_calendars is rejected", + yaml: ` +accounts: + - id: personal + provider: google + email: user@gmail.com + digest_calendars: [""] +`, + wantErr: "digest_calendars entries must not be empty", + }, { name: "microsoft non-primary blocker_calendar is rejected (v1 constraint)", yaml: ` diff --git a/internal/engine/notify.go b/internal/engine/notify.go index 090d88f..74ffa39 100644 --- a/internal/engine/notify.go +++ b/internal/engine/notify.go @@ -93,7 +93,13 @@ func (e *Engine) collectDigest(ctx context.Context, day time.Time) ([]DigestEntr failed = append(failed, acct.ID) continue } - for _, calID := range acct.Calendars { + // digest_calendars は監視対象(Calendars)には含まれない通知専用カレンダー。 + // ダイジェストのライブ取得にだけ acct.Calendars の後ろに連結して参加させる + // (フィルタ・dedupe・failed 集約は既存のループをそのまま流用する。スペック 2 章)。 + digestCalIDs := make([]string, 0, len(acct.Calendars)+len(acct.DigestCalendars)) + digestCalIDs = append(digestCalIDs, acct.Calendars...) + digestCalIDs = append(digestCalIDs, acct.DigestCalendars...) + for _, calID := range digestCalIDs { if ctx.Err() != nil { return entries, failed } diff --git a/internal/engine/notify_test.go b/internal/engine/notify_test.go index a1c9ff9..b896f06 100644 --- a/internal/engine/notify_test.go +++ b/internal/engine/notify_test.go @@ -204,6 +204,62 @@ func TestCheckRemindersCarriesDisplayFields(t *testing.T) { require.Equal(t, "https://cal/x", fn.reminders[0].entry.HTMLLink) } +// digest_calendars は監視対象(Calendars)に含まれない通知専用カレンダー。 +// ダイジェストのライブ取得には参加する(スペック 2 章)。 +func TestCollectDigestIncludesDigestCalendars(t *testing.T) { + e, f, _ := digestEngine(t) + day := time.Date(2026, 7, 5, 0, 0, 0, 0, jstLoc) + e.Cfg.Accounts[0].DigestCalendars = []string{"digest-cal"} + digestRef := model.CalendarRef{AccountID: "a", CalendarID: "digest-cal"} + + f.SetFullState(refA, nil) + f.SetFullState(digestRef, []model.NormalizedEvent{ + timedEvent("gomi", "gomi@x", "ゴミの日", time.Date(2026, 7, 5, 9, 0, 0, 0, jstLoc), true), + }) + + entries, failed := e.collectDigest(context.Background(), day) + require.Empty(t, failed) + var titles []string + for _, en := range entries { + titles = append(titles, en.Title) + } + require.Equal(t, []string{"ゴミの日"}, titles) +} + +// 核心の回帰テスト: digest_calendars 上の busy イベントは tick を回しても +// 監視対象にならず、どのアカウントにもブロッカーが作られない(スペック 2 章)。 +func TestTickNeverDistributesDigestCalendarEvents(t *testing.T) { + e, f := newTestEngine(t) + e.Cfg.Accounts[0].DigestCalendars = []string{"digest-cal"} + digestRef := model.CalendarRef{AccountID: "a", CalendarID: "digest-cal"} + f.SetFullState(digestRef, []model.NormalizedEvent{busyEvent("gomi")}) + // 監視対象の primary は空にしておき、配布があるとすれば digest-cal 由来だけと + // わかるようにする。 + f.SetFullState(refA, nil) + + e.tick(context.Background(), map[string]bool{}, map[model.CalendarRef]int{}) + + require.Empty(t, f.Blockers(refA)) + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) +} + +// digest_calendars の取得失敗は通常の calendars と同じくアカウント単位で +// failed に集約される(スペック 5 章)。 +func TestCollectDigestReportsFailedDigestCalendars(t *testing.T) { + e, f, _ := digestEngine(t) + day := time.Date(2026, 7, 5, 0, 0, 0, 0, jstLoc) + e.Cfg.Accounts[0].DigestCalendars = []string{"digest-cal"} + digestRef := model.CalendarRef{AccountID: "a", CalendarID: "digest-cal"} + + f.SetFullState(refA, nil) + f.FailNext(digestRef, errors.New("boom")) + + entries, failed := e.collectDigest(context.Background(), day) + require.Equal(t, []string{"a"}, failed) + require.Empty(t, entries) +} + func TestCollectDigestReportsFailedAccounts(t *testing.T) { e, f, _ := digestEngine(t) day := time.Date(2026, 7, 5, 0, 0, 0, 0, jstLoc)