From 6a75978c79a1d8d80c13e0e2701224b97eecd2bf Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 28 Mar 2026 16:39:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20remove=20b?= =?UTF-8?q?uilt-in=20feishu=20workspace=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + cmd/anna/commands.go | 44 +- cmd/anna/commands_test.go | 4 +- cmd/anna/gateway.go | 11 +- docs/content/docs/channels/feishu.ja.md | 278 ++----- docs/content/docs/channels/feishu.md | 272 ++----- docs/content/docs/channels/feishu.zh.md | 278 ++----- docs/src/routes/oauth/callback.tsx | 60 +- internal/admin/ui/pages/channels.templ | 3 + internal/admin/ui/pages/channels_templ.go | 14 +- .../builtin/anna/references/channels.md | 5 +- .../builtin/anna/references/configuration.md | 2 + internal/channel/feishu/feishu.go | 11 - internal/channel/feishu/handler.go | 14 - internal/channel/feishu/oauth.go | 130 --- internal/channel/feishu/oauth_test.go | 31 - internal/db/queries/feishu_tokens.sql | 15 - internal/db/schemas/main.sql | 1 - internal/db/schemas/tables/feishu_tokens.sql | 9 - internal/db/sqlc/feishu_tokens.sql.go | 68 -- internal/db/sqlc/models.go | 10 - internal/feishutool/bitable.go | 739 ------------------ internal/feishutool/bitable_test.go | 298 ------- internal/feishutool/calendar.go | 644 --------------- internal/feishutool/calendar_test.go | 247 ------ internal/feishutool/chat.go | 265 ------- internal/feishutool/chat_test.go | 122 --- internal/feishutool/client.go | 357 --------- internal/feishutool/client_test.go | 59 -- internal/feishutool/client_uat_test.go | 200 ----- internal/feishutool/context.go | 44 -- internal/feishutool/context_test.go | 59 -- internal/feishutool/doc.go | 174 ----- internal/feishutool/doc_test.go | 82 -- internal/feishutool/drive.go | 335 -------- internal/feishutool/drive_test.go | 146 ---- internal/feishutool/helpers.go | 173 ---- internal/feishutool/helpers_test.go | 103 --- internal/feishutool/im.go | 417 ---------- internal/feishutool/im_test.go | 194 ----- internal/feishutool/search.go | 169 ---- internal/feishutool/search_test.go | 99 --- internal/feishutool/sheets.go | 299 ------- internal/feishutool/sheets_test.go | 120 --- internal/feishutool/task.go | 583 -------------- internal/feishutool/task_test.go | 268 ------- internal/feishutool/token_store.go | 189 ----- internal/feishutool/token_store_test.go | 304 ------- internal/feishutool/user.go | 221 ------ internal/feishutool/user_test.go | 156 ---- internal/feishutool/wiki.go | 360 --------- internal/feishutool/wiki_test.go | 140 ---- 52 files changed, 258 insertions(+), 8570 deletions(-) delete mode 100644 internal/channel/feishu/oauth.go delete mode 100644 internal/channel/feishu/oauth_test.go delete mode 100644 internal/db/queries/feishu_tokens.sql delete mode 100644 internal/db/schemas/tables/feishu_tokens.sql delete mode 100644 internal/db/sqlc/feishu_tokens.sql.go delete mode 100644 internal/feishutool/bitable.go delete mode 100644 internal/feishutool/bitable_test.go delete mode 100644 internal/feishutool/calendar.go delete mode 100644 internal/feishutool/calendar_test.go delete mode 100644 internal/feishutool/chat.go delete mode 100644 internal/feishutool/chat_test.go delete mode 100644 internal/feishutool/client.go delete mode 100644 internal/feishutool/client_test.go delete mode 100644 internal/feishutool/client_uat_test.go delete mode 100644 internal/feishutool/context.go delete mode 100644 internal/feishutool/context_test.go delete mode 100644 internal/feishutool/doc.go delete mode 100644 internal/feishutool/doc_test.go delete mode 100644 internal/feishutool/drive.go delete mode 100644 internal/feishutool/drive_test.go delete mode 100644 internal/feishutool/helpers.go delete mode 100644 internal/feishutool/helpers_test.go delete mode 100644 internal/feishutool/im.go delete mode 100644 internal/feishutool/im_test.go delete mode 100644 internal/feishutool/search.go delete mode 100644 internal/feishutool/search_test.go delete mode 100644 internal/feishutool/sheets.go delete mode 100644 internal/feishutool/sheets_test.go delete mode 100644 internal/feishutool/task.go delete mode 100644 internal/feishutool/task_test.go delete mode 100644 internal/feishutool/token_store.go delete mode 100644 internal/feishutool/token_store_test.go delete mode 100644 internal/feishutool/user.go delete mode 100644 internal/feishutool/user_test.go delete mode 100644 internal/feishutool/wiki.go delete mode 100644 internal/feishutool/wiki_test.go diff --git a/README.md b/README.md index 5da7e1bc..64772cf3 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ One bot per platform. Agent selection is handled via the `/agent` command rather Every channel supports `/new`, `/compact`, `/model`, `/agent`, `/whoami`, model switching, access control, and image input. +Lark workspace automation is no longer built in as `feishu_*` tools. If you want those workflows, install a `lark-cli` skill yourself and use it with `lark-cli` for calendar, docs, tasks, sheets, drive, and other workspace actions. + ## Scheduler You don't write cron expressions by hand. You just tell Anna what you need. diff --git a/cmd/anna/commands.go b/cmd/anna/commands.go index c5f3a034..63787f2b 100644 --- a/cmd/anna/commands.go +++ b/cmd/anna/commands.go @@ -9,8 +9,6 @@ import ( "path/filepath" "time" - lark "github.com/larksuite/oapi-sdk-go/v3" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ucli "github.com/urfave/cli/v2" "github.com/vaayne/anna/internal/agent" "github.com/vaayne/anna/internal/agent/runner" @@ -19,7 +17,6 @@ import ( "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" - "github.com/vaayne/anna/internal/feishutool" "github.com/vaayne/anna/internal/memory" memorytool "github.com/vaayne/anna/internal/memory/tool" pluginmgr "github.com/vaayne/anna/internal/plugin" @@ -56,8 +53,7 @@ type setupResult struct { extraTools []agenttool.Tool notifier *channel.Dispatcher pluginMgr *pluginmgr.Manager - fsClient *feishutool.Client // feishu client for OAuth (nil if not configured) - cliUserID int64 // resolved CLI user for session creation + cliUserID int64 // resolved CLI user for session creation } func setup(parent context.Context, gateway bool) (*setupResult, error) { @@ -128,43 +124,6 @@ func setup(parent context.Context, gateway bool) (*setupResult, error) { memorytool.NewMemoryTool(memoryEngine, userMemoryStore), ) - // Feishu tools: load config early (like scheduler/memory), create client - // and tools if configured, so all agents have access to Feishu APIs. - var fsClient *feishutool.Client - if fsCfg := loadChannelConfig[feishuChannelConfig](store, "feishu"); fsCfg != nil && fsCfg.AppID != "" && fsCfg.AppSecret != "" { - // Create token store for UAT token management. - tokenStore, tsErr := feishutool.NewSQLiteTokenStore(db, fsCfg.AppSecret) - if tsErr != nil { - slog.Warn("feishu token store creation failed, UAT disabled", "error", tsErr) - } - - var clientOpts []feishutool.ClientOption - if tokenStore != nil { - clientOpts = append(clientOpts, feishutool.WithTokenStore(tokenStore)) - } - - larkClient := lark.NewClient(fsCfg.AppID, fsCfg.AppSecret, - lark.WithLogLevel(larkcore.LogLevelWarn), - lark.WithEnableTokenCache(true), - ) - fsClient = feishutool.NewClient(larkClient, clientOpts...) - fsClient.SetAppCredentials(fsCfg.AppID, fsCfg.AppSecret) - sharedTools = append(sharedTools, - feishutool.NewUserTool(fsClient), - feishutool.NewCalendarTool(fsClient), - feishutool.NewTaskTool(fsClient), - feishutool.NewBitableTool(fsClient), - feishutool.NewChatTool(fsClient), - feishutool.NewIMTool(fsClient), - feishutool.NewDocTool(fsClient), - feishutool.NewWikiTool(fsClient), - feishutool.NewSheetsTool(fsClient), - feishutool.NewDriveTool(fsClient), - feishutool.NewSearchTool(fsClient), - ) - slog.Info("feishu tools loaded", "uat_enabled", tokenStore != nil) - } - // Collect built-in tool names for plugin collision detection. builtinReg := agenttool.NewRegistry("") builtinNames := builtinReg.BuiltinNames() @@ -254,7 +213,6 @@ func setup(parent context.Context, gateway bool) (*setupResult, error) { extraTools: sharedTools, notifier: dispatcher, pluginMgr: pm, - fsClient: fsClient, cliUserID: cliUserID, }, nil } diff --git a/cmd/anna/commands_test.go b/cmd/anna/commands_test.go index b43560ad..6619a3dc 100644 --- a/cmd/anna/commands_test.go +++ b/cmd/anna/commands_test.go @@ -73,7 +73,7 @@ func TestRunGatewayNoServices(t *testing.T) { if err == nil { t.Fatal("expected error for no configured services") } - if !strings.Contains(err.Error(), "no gateway services configured") { - t.Errorf("err = %q, want contains 'no gateway services configured'", err.Error()) + if !strings.Contains(err.Error(), "no services to run") { + t.Errorf("err = %q, want contains 'no services to run'", err.Error()) } } diff --git a/cmd/anna/gateway.go b/cmd/anna/gateway.go index 815106d6..6692e700 100644 --- a/cmd/anna/gateway.go +++ b/cmd/anna/gateway.go @@ -160,13 +160,6 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc if fsCfg != nil && fsCfg.AppID != "" && fsCfg.AppSecret != "" { slog.Info("starting feishu bot") - fsOpts := []feishu.BotOption{ - feishu.WithAuth(as, engine, linkCodes), - } - if s.fsClient != nil { - fsOpts = append(fsOpts, feishu.WithFeishuClient(s.fsClient)) - } - fsBot, err := feishu.New(feishu.Config{ AppID: fsCfg.AppID, AppSecret: fsCfg.AppSecret, @@ -174,9 +167,8 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc VerificationToken: fsCfg.VerificationToken, GroupMode: fsCfg.GroupMode, Groups: fsCfg.Groups, - RedirectURI: fsCfg.RedirectURI, }, s.poolManager, s.store, listFn, switchFn, - fsOpts..., + feishu.WithAuth(as, engine, linkCodes), ) if err != nil { return fmt.Errorf("create feishu bot: %w", err) @@ -347,7 +339,6 @@ type feishuChannelConfig struct { VerificationToken string `json:"verification_token"` GroupMode string `json:"group_mode"` Groups map[string]feishu.GroupConfig `json:"groups"` - RedirectURI string `json:"redirect_uri"` EnableNotify bool `json:"enable_notify"` } diff --git a/docs/content/docs/channels/feishu.ja.md b/docs/content/docs/channels/feishu.ja.md index 7973501b..ee8175aa 100644 --- a/docs/content/docs/channels/feishu.ja.md +++ b/docs/content/docs/channels/feishu.ja.md @@ -2,247 +2,121 @@ title: Feishu Bot --- -anna には、WebSocket 経由で接続する Feishu (Lark) ボットが含まれています(永続的な接続、公開 URL は不要)。ボットは 11 の Feishu ワークスペースツール、ユーザー OAuth、ストリーミングレスポンス、ネイティブスレッド対応、グループごとの設定をサポートしています。 +anna には WebSocket 接続の Feishu(Lark)ボットが含まれているため、公開 webhook は不要です。現在の Feishu 連携はチャット専用です。メッセージ、ストリーミング返信、スレッド、グループ、通知は anna が処理し、カレンダーやドキュメントなどのワークスペース操作は `lark-cli` に移行しました。 ## セットアップ -1. [Feishu Open Platform](https://open.feishu.cn/) で Feishu アプリを作成します -2. アプリ設定で **Bot** 機能を有効にします -3. **Event Subscriptions** で以下のイベントを追加します: - - `im.message.receive_v1`(必須)— メッセージの受信 - - `im.message.reaction.created_v1`(オプション)— リアクションの受信 -4. **Permissions & Scopes** で[必要な権限](#必要な権限)セクションに記載されている権限を追加します -5. アプリ設定から App ID、App Secret、Encrypt Key、Verification Token を取得します -6. `anna --open` を実行して管理パネルを起動します -7. 管理パネルで、AI プロバイダーを追加してから、アプリ認証情報を使用して Feishu チャンネルを設定します -8. デーモンを起動します: +1. [Feishu Open Platform](https://open.feishu.cn/) でアプリを作成します。 +2. **Bot** 機能を有効にします。 +3. **Event Subscriptions** で次を追加します。 + - `im.message.receive_v1` + - リアクションイベントが必要なら `im.message.reaction.created_v1` +4. App ID、App Secret、Encrypt Key、Verification Token を控えます。 +5. `anna --open` を実行し、管理画面で Feishu チャンネルを設定します。 +6. anna を起動します。 ```bash anna ``` -すべてのチャンネル設定(認証情報、グループモード、許可された ID など)は管理パネルから管理されます。環境変数は、プロバイダー API キー(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)と `ANNA_HOME` に制限されています。 +## Lark ワークスペース自動化 -## ユーザー OAuth 認証(UAT) +以前の組み込み `feishu_*` ツールと `/auth` フローは削除されました。 -多くの Feishu ツール(カレンダー、タスクなど)は、ボットではなく個々のユーザーの代わりに操作できます。これには各ユーザーが OAuth でアプリを認証する必要があります。 +カレンダー、タスク、Docs、Wiki、Sheets、Drive、連絡先などの操作は、必要に応じて自分で `lark-cli` skill を追加し、外部の [`lark-cli`](https://github.com/larksuite/cli) と組み合わせて使ってください。 -### 仕組み +代表的な初期設定: -1. **ユーザーが `/auth` を送信** — ボットとのチャットで -2. **ボットがインタラクティブカードで返信** — 認証リンク付き -3. **ユーザーがリンクをクリック** — ブラウザで Feishu OAuth ページが開きます -4. **ユーザーが承認** — OAuth ページで権限リクエストを承認します -5. **Feishu がコールバックページにリダイレクト** — `anna.vaayne.com/oauth/callback` に認証コードと「コマンドをコピー」ボタンが表示されます -6. **ユーザーがコピーして `/auth ` を送信** — 認証コードをボットに送り返します -7. **ボットがコードを交換** — アクセストークンとリフレッシュトークンに交換し、暗号化してデータベースに保存します -8. **完了** — ユーザーの ID がツール呼び出しで利用可能になります - -### トークンのライフサイクル - -- **アクセストークン** は約 2 時間で有効期限が切れます。ボットが自動的に透過的にリフレッシュします。 -- **リフレッシュトークン** は約 30 日で有効期限が切れます。その後、ユーザーは `/auth` で再認証する必要があります。 -- **ストレージ**: トークンは AES-256-GCM で暗号化して保存されます。暗号化キーは HKDF を使用して App Secret から派生されます。 -- **フォールバック**: ユーザーが認証していない場合、ツールはボットトークンにフォールバックします(権限は制限されます)。 - -### Feishu アプリの OAuth 設定 - -`/auth` フローを機能させるには、Feishu アプリを設定します: - -1. Feishu Open Platform コンソールの **Security Settings** に移動します -2. `https://anna.vaayne.com/oauth/callback` を **Redirect URL** として追加します -3. **Permissions & Scopes** で、使用するツールに必要なユーザーレベルの権限を申請します([必要な権限](#必要な権限)を参照) - -ドキュメントサイトをセルフホストしている場合、または別のコールバック URL を使用する場合は、Feishu チャンネル設定で `redirect_uri` を設定し、その URL を Feishu アプリに登録してください。 - -### 認証の取り消し - -ユーザーは Feishu アカウント設定の **Connected Apps** からいつでも認証を取り消すことができます。ボットは期限切れのトークンを検出し、必要に応じて再認証を促します。 - -## Feishu ワークスペースツール - -Feishu チャンネルが設定されると、anna は LLM エージェントが呼び出せる 11 のマルチアクションツールを登録します。これらのツールは、ユーザーがどのチャンネルからチャットしているかに関係なく、すべてのエージェントで利用できます。 - -| ツール | アクション | 説明 | -|--------|------------|------| -| `feishu_user` | get_user, search_user | open_id、メール、電話番号でユーザーを検索 | -| `feishu_calendar` | イベントの作成/一覧/取得/更新/削除, add_attendees, freebusy | 繰り返しイベント対応のカレンダー管理 | -| `feishu_task` | タスクの作成/一覧/取得/更新/完了, タスクリスト, サブタスク | Task v2 API によるタスク管理 | -| `feishu_bitable` | アプリ/テーブル/レコード/フィールドの CRUD, バッチ操作, 検索/フィルター | Bitable データベース操作 | -| `feishu_chat` | 検索, 情報, メンバーの追加/削除/一覧 | チャットとグループ管理 | -| `feishu_im` | メッセージの送信/返信/読取/取得/転送, リアクション | メッセージングとリアクション | -| `feishu_doc` | ドキュメント作成, コンテンツ/生コンテンツ取得 | ドキュメントの作成と読取 | -| `feishu_wiki` | スペース, ノードの CRUD, 移動/コピー | ナレッジベース管理 | -| `feishu_sheets` | スプレッドシートの作成/取得, シート一覧, 範囲の読書き | スプレッドシート操作 | -| `feishu_drive` | ファイルの一覧/コピー/移動/削除, フォルダ作成, メタデータ取得 | ファイルとフォルダ管理 | -| `feishu_search` | ドキュメント/Wiki の検索 | フィルター付きグローバルドキュメント検索 | - -ツールは利用可能な場合はユーザーの OAuth トークンを使用し(ユーザーのカレンダーにイベントを作成するなどのユーザースコープの操作用)、それ以外の場合はボットトークンにフォールバックします。 - -## マルチユーザーサポート - -各 Feishu ユーザーは、プラットフォーム ID から自動的に解決されます。セッションは、エージェントごとにユーザーごとにスコープされます。手動でのユーザー設定は不要です。 - -## ストリーミングレスポンス - -ボットは Feishu の CardKit 2.0 API を使用して、3 つのフェーズでストリーミングレスポンスを行います: - -1. **思考中** — 「Thinking...」の初期カードを即座に送信 -2. **生成中** — LLM がトークンを生成するにつれてカードを徐々に更新 -3. **完了** — レスポンス時間フッター付きの最終カード(例: _Response time: 3.2s_) +```bash +command -v lark-cli +npm install -g @larksuite/cli +lark-cli config init --new +lark-cli auth login --recommend +lark-cli auth status +``` -### ツールインジケーター +ユーザーが追加した `lark-cli` skill は、旧 `feishu_calendar`、`feishu_task`、`feishu_im`、`feishu_doc`、`feishu_wiki`、`feishu_sheets`、`feishu_drive`、`feishu_bitable`、`feishu_user`、`feishu_search` の用途を置き換えられます。 -ツール実行中、ストリームは絵文字インジケーターでステータスを表示します: +## マルチユーザー対応 -| Tool | Emoji | -| -------- | ---------------- | -| `bash` | lightning | -| `read` | book | -| `write` | pencil | -| `edit` | wrench | -| `search` | magnifying glass | +Feishu ユーザーはプラットフォーム ID から自動的に解決されます。セッションはユーザーごと・agent ごとに分離されるため、記憶や既定 agent はユーザー単位で保持されます。 -## サポートされているメッセージタイプ +## ストリーミング返信 -| タイプ | 動作 | -| --------------------- | ----------------------------------------------------- | -| テキスト | 抽出されて LLM に送信 | -| 画像 | ダウンロード、base64 エンコード、マルチモーダル入力 | -| リッチテキスト (Post) | 完全なコンテキストのために生の JSON が LLM に渡される | -| 音声 | 再生時間付きの説明テキストを LLM に送信 | -| 動画 | 再生時間付きの説明テキストを LLM に送信 | -| ファイル | ファイル名とメタデータを LLM に送信 | -| スタンプ | 説明テキストを LLM に送信 | -| 位置情報 | 名前と座標を LLM に送信 | -| チャット/ユーザー共有 | チャットまたはユーザー ID を LLM に送信 | -| 転送メッセージ | 要約説明を LLM に送信 | +ボットはメッセージをその場で更新しながら返信します。 -## スレッドサポート +1. まずプレースホルダーを送信 +2. 生成中に内容を更新 +3. 最終結果と処理時間を表示 -ボットは Feishu のネイティブスレッドをサポートしています。ユーザーがスレッド内でメッセージを送信すると、ボットは: +ツール実行中の状態もストリーム内で簡潔に表示されます。 -- そのスレッド用に**独立したエージェントセッション**を作成(グループの会話から分離) -- 同じスレッド内で返信 -- スレッド固有の会話履歴を維持 +## 対応メッセージ型 -これにより、同じグループチャット内で干渉なしに複数の並行会話が可能になります。 +| 種類 | 動作 | +| --- | --- | +| テキスト | そのまま LLM に送信 | +| 画像 | ダウンロードしてマルチモーダル入力として送信 | +| Post | リッチテキスト JSON をそのまま転送 | +| 音声 | 長さ付きの説明文に変換 | +| 動画 | 長さ付きの説明文に変換 | +| ファイル | ファイル情報付きの説明文に変換 | +| スタンプ | 説明文に変換 | +| 位置情報 | 可能なら座標付き説明文に変換 | +| 共有チャット/共有ユーザー | 説明文に変換 | +| 結合転送 | 要約マーカーに変換 | -## 中止 / キャンセル +## ネイティブスレッド -アクティブなストリーミングレスポンスをキャンセルするには、以下のいずれかのメッセージを送信します: +Feishu スレッド内でメッセージが送られた場合、anna は同じスレッド内で返信し、そのスレッド root 単位でセッションを分けます。スレッド外は親チャットのセッションを使います。 -- `cancel`、`stop`、`abort`(英語、大文字小文字不問) -- `取消`、`停止`(中国語) +## グループ挙動 -ボットはアクティブなストリームを即座にキャンセルし、「Cancelled.」と返信します。 +`group_mode` でグループ内の応答条件を制御します。 -## グループサポート +- `mention`: @ されたときだけ返信 +- `always`: すべてのメッセージに返信 +- `disabled`: グループでは返信しない -起動時に、ボットは Feishu Bot Info API 経由で自身の `open_id` を取得します。これにより、グループでの信頼性の高い @メンション検出が可能になり、ボットが自分自身のメッセージに応答すること(無限ループ)を防ぎます。 +`groups` を使えば特定グループごとの上書きもできます。 -グループチャットでは、ボットは @メンションに応答します。管理パネルでグループモードを設定します: +## コマンド -- `mention` — @メンションに応答(デフォルト) -- `always` — すべてのグループメッセージに応答 -- `disabled` — グループメッセージを完全に無視 +Feishu では標準チャットコマンドを利用できます。 -### グループごとの設定 +| コマンド | 説明 | +| --- | --- | +| `/new` | 新しいセッションを開始 | +| `/compact` | 現在の履歴を圧縮 | +| `/model` | モデル一覧または切り替え | +| `/agent` | agent 一覧または切り替え | +| `/whoami` | 自分のプラットフォーム ID を表示 | -Feishu チャンネルの JSON 設定に `groups` マップを追加することで、特定のグループチャットの設定を上書きできます。各キーは Feishu の chat_id です(例: `oc_abc123`): +## 設定例 ```json { - "app_id": "cli_xxx", - "app_secret": "xxx", + "app_id": "FEISHU_APP_ID", + "app_secret": "FEISHU_APP_SECRET", + "encrypt_key": "", + "verification_token": "", + "group_mode": "mention", + "enable_notify": false, "groups": { - "oc_abc123": { + "oc_example": { "group_mode": "always", - "system_prompt": "あなたはチーム Alpha のプロジェクト管理アシスタントです。" - }, - "oc_def456": { - "group_mode": "mention", - "system_prompt": "あなたはテクニカルサポートボットです。簡潔に回答してください。" + "system_prompt": "このグループではインフラ担当として答えてください。" } } } ``` | フィールド | 説明 | -|------------|------| -| `group_mode` | このチャットのグローバルグループモードを上書き | -| `system_prompt` | このチャットのすべてのメッセージにシステムプロンプトを前置 | -| `tool_allow` | 将来の使用のために予約: ツール名のホワイトリスト | -| `tool_deny` | 将来の使用のために予約: ツール名のブラックリスト | - -## リアクション - -ユーザーがメッセージに絵文字でリアクションすると、ボットはリアクションの説明をエージェントに送信します(例: `[User reacted with THUMBSUP on message om_xxx]`)。エージェントは必要に応じて応答できます。 - -LLM は `feishu_im` ツールの `add_reaction` と `remove_reaction` アクションを使用してリアクションを追加または削除することもできます。 - -## アクセス制御 - -管理パネルで許可された open_id を追加することで、ボットとやり取りできるユーザーを制限します。空のままにするとすべてのユーザーを許可します。open_id を取得するには `/whoami` コマンドを使用します。 - -## 通知 - -管理パネルでプロアクティブ通知(スケジューラー結果、エージェントによってトリガーされたアラート)用のデフォルト通知チャットを設定します。 - -## コマンド - -これらのコマンドをテキストメッセージとしてボットに送信します: - -| コマンド | 説明 | -| ------------------- | ------------------------------------------ | -| `/start` or `/help` | ウェルカムとヘルプ | -| `/new` | 新しいセッションを開始 | -| `/compact` | 会話履歴を圧縮 | -| `/model` | 利用可能なモデルを一覧表示 | -| `/model ` | 番号でモデルに切り替え | -| `/model ` | 名前でモデルをフィルタリング | -| `/auth` | OAuth 認証を開始(認証カードを取得) | -| `/auth ` | 認証コードで OAuth を完了 | -| `/whoami` | 設定用のユーザー ID を表示 | - -## 必要な権限 - -### ボット権限(常に必要) - -| スコープ | 説明 | -|----------|------| -| `im:message` | メッセージの送受信 | -| `im:message:send_as_bot` | ボットとしてメッセージを送信 | -| `im:resource` | メッセージリソース(画像、ファイル)へのアクセス | -| `im:chat` | チャット情報へのアクセス | -| `contact:user.base:readonly` | 基本ユーザー情報の読取 | - -### ユーザー権限(OAuth ツール用) - -有効にするツールに応じて以下を追加します: - -| ツール | スコープ | -|--------|----------| -| カレンダー | `calendar:calendar`, `calendar:calendar:readonly` | -| タスク | `task:task`, `task:task:readonly` | -| Bitable | `bitable:app`, `bitable:app:readonly` | -| ドキュメント | `docx:document`, `docx:document:readonly` | -| Wiki | `wiki:wiki`, `wiki:wiki:readonly` | -| スプレッドシート | `sheets:spreadsheet`, `sheets:spreadsheet:readonly` | -| ドライブ | `drive:drive`, `drive:drive:readonly` | - -## 設定リファレンス - -以下のすべての設定は、`anna --open` 管理パネルから管理されます。 - -| フィールド | 説明 | デフォルト | -| -------------------- | ----------------------------------------------- | ---------- | -| `app_id` | Feishu App ID | (必須) | -| `app_secret` | Feishu App Secret | (必須) | -| `encrypt_key` | イベント暗号化キー(Events & Callbacks から) | (任意) | -| `verification_token` | イベント検証トークン(Events & Callbacks から) | (任意) | -| `notify_chat` | プロアクティブ通知用のチャット ID | (任意) | -| `group_mode` | グループ動作: `mention`、`always`、`disabled` | `mention` | -| `allowed_ids` | 許可されたユーザー open_id(空 = すべて) | `[]` | -| `groups` | グループごとの設定上書き(上記参照) | `{}` | -| `redirect_uri` | OAuth リダイレクト URI | `https://anna.vaayne.com/oauth/callback` | +| --- | --- | +| `app_id` | Feishu アプリの App ID | +| `app_secret` | Feishu アプリの App Secret | +| `encrypt_key` | 任意のイベント暗号化キー | +| `verification_token` | 任意のイベント検証トークン | +| `group_mode` | 既定のグループ挙動。`mention`、`always`、`disabled` | +| `enable_notify` | scheduler や `notify` の出力先として Feishu を許可 | +| `groups` | Feishu `chat_id` ごとの上書き設定 | diff --git a/docs/content/docs/channels/feishu.md b/docs/content/docs/channels/feishu.md index e0de8fe4..1bc18e95 100644 --- a/docs/content/docs/channels/feishu.md +++ b/docs/content/docs/channels/feishu.md @@ -2,247 +2,121 @@ title: Feishu Bot --- -anna includes a Feishu (Lark) bot that connects via WebSocket (persistent connection, no public URL required). The bot supports 11 Feishu workspace tools, user OAuth, streaming responses, native threading, and per-group configuration. +anna includes a Feishu (Lark) bot that connects over WebSocket, so you do not need a public webhook URL. The Feishu integration is now chat-only: messages, streaming responses, threads, groups, and notifications stay in anna, while Lark workspace automation moved to `lark-cli`. ## Setup -1. Create a Feishu app at [Feishu Open Platform](https://open.feishu.cn/) -2. Enable the **Bot** capability in your app settings -3. Under **Event Subscriptions**, add these events: - - `im.message.receive_v1` (required) -- receive messages - - `im.message.reaction.created_v1` (optional) -- receive reactions -4. Under **Permissions & Scopes**, add the scopes listed in the [Required Permissions](#required-permissions) section below -5. Get your App ID, App Secret, Encrypt Key, and Verification Token from the app settings -6. Run `anna --open` to launch the admin panel -7. In the admin panel: add an AI provider, then configure the Feishu channel with your app credentials -8. Start the daemon: +1. Create a Feishu app at [Feishu Open Platform](https://open.feishu.cn/). +2. Enable the **Bot** capability. +3. Under **Event Subscriptions**, add: + - `im.message.receive_v1` + - `im.message.reaction.created_v1` if you want reaction events +4. Copy your App ID, App Secret, Encrypt Key, and Verification Token. +5. Run `anna --open` and configure the Feishu channel in the admin panel. +6. Start anna: ```bash anna ``` -All channel configuration (credentials, group mode, allowed IDs, etc.) is managed through the admin panel. Environment variables are limited to provider API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) and `ANNA_HOME`. +## Lark Workspace Automation -## User OAuth (UAT) +The old built-in `feishu_*` tools and `/auth` flow were removed. -Many Feishu tools (calendar, tasks, etc.) can operate on behalf of individual users rather than the bot. This requires each user to authorize the app via OAuth. +For calendar, tasks, docs, wiki, sheets, drive, contacts, and other workspace operations, install a `lark-cli` skill if you want one, and use it with the external [`lark-cli`](https://github.com/larksuite/cli) tool. -### How It Works +Typical setup: -1. **User sends `/auth`** in a chat with the bot -2. **Bot replies with an interactive card** containing an authorization link -3. **User clicks the link**, which opens the Feishu OAuth page in their browser -4. **User approves** the permission request on the OAuth page -5. **Feishu redirects to the callback page** at `anna.vaayne.com/oauth/callback` which displays the authorization code and a "Copy Command" button -6. **User copies and sends `/auth `** back to the bot -7. **Bot exchanges the code** for access and refresh tokens, stores them encrypted in the database -8. **Done** -- the user's identity is now available for tool calls - -### Token Lifecycle - -- **Access tokens** expire after ~2 hours. The bot auto-refreshes them transparently. -- **Refresh tokens** expire after ~30 days. After that, the user must re-authorize with `/auth`. -- **Storage**: tokens are encrypted at rest using AES-256-GCM. The encryption key is derived from the app secret via HKDF. -- **Fallback**: if a user hasn't authorized, tools fall back to the bot token (with reduced permissions). - -### Feishu App OAuth Configuration - -For the `/auth` flow to work, configure your Feishu app: - -1. Go to **Security Settings** in the Feishu Open Platform console -2. Add `https://anna.vaayne.com/oauth/callback` as a **Redirect URL** -3. Under **Permissions & Scopes**, request the user-level scopes needed by the tools you want to use (see [Required Permissions](#required-permissions)) - -If you're self-hosting the docs site or want a different callback URL, set `redirect_uri` in the Feishu channel config and register that URL in your Feishu app instead. - -### Revoking Authorization - -Users can revoke their authorization at any time from their Feishu account settings under **Connected Apps**. The bot will detect expired tokens and prompt re-authorization when needed. - -## Feishu Workspace Tools - -When a Feishu channel is configured, anna registers 11 multi-action tools that the LLM agent can call. These tools are available to all agents regardless of which channel the user is chatting from. - -| Tool | Actions | Description | -|------|---------|-------------| -| `feishu_user` | get_user, search_user | Look up users by open_id, email, or mobile | -| `feishu_calendar` | create/list/get/update/delete events, add_attendees, freebusy | Full calendar management with recurring event support | -| `feishu_task` | create/list/get/update/complete tasks, tasklists, subtasks | Task management with Task v2 API | -| `feishu_bitable` | app/table/record/field CRUD, batch ops, search/filter | Database operations with Bitable | -| `feishu_chat` | search, info, add/remove/list members | Chat and group management | -| `feishu_im` | send/reply/read/get/forward messages, reactions | Messaging and reactions | -| `feishu_doc` | create doc, get content/raw content | Document creation and reading | -| `feishu_wiki` | spaces, nodes CRUD, move/copy | Knowledge base management | -| `feishu_sheets` | create/get spreadsheet, list sheets, read/write ranges | Spreadsheet operations | -| `feishu_drive` | list/copy/move/delete files, create folder, get meta | File and folder management | -| `feishu_search` | search docs/wiki | Global document search with filters | +```bash +command -v lark-cli +npm install -g @larksuite/cli +lark-cli config init --new +lark-cli auth login --recommend +lark-cli auth status +``` -Tools use the user's OAuth token when available (for user-scoped operations like creating events on their calendar) and fall back to the bot token otherwise. +A user-installed `lark-cli` skill can map the retired `feishu_calendar`, `feishu_task`, `feishu_im`, `feishu_doc`, `feishu_wiki`, `feishu_sheets`, `feishu_drive`, `feishu_bitable`, `feishu_user`, and `feishu_search` workflows to `lark-cli` services. ## Multi-User Support -Each Feishu user is automatically resolved from their platform identity. Sessions are scoped per user per agent. No manual user setup is required. +Each Feishu user is resolved from platform identity automatically. Sessions are scoped per user and per agent, so different users keep separate memory and default-agent state. ## Streaming Responses -The bot uses Feishu's CardKit 2.0 API for streaming responses with three phases: - -1. **Thinking** -- an initial "Thinking..." card is sent immediately -2. **Generating** -- the card is progressively updated with content as the LLM generates tokens -3. **Complete** -- the final card includes a response time footer (e.g., _Response time: 3.2s_) - -### Tool Indicators +The bot streams responses by editing messages in place: -During tool execution, the stream shows status with emoji indicators: +1. Send an initial placeholder quickly. +2. Update the visible response while the model is generating. +3. Finish with the complete response and elapsed time footer. -| Tool | Emoji | -| -------- | ---------------- | -| `bash` | lightning | -| `read` | book | -| `write` | pencil | -| `edit` | wrench | -| `search` | magnifying glass | +Tool activity from the runner is summarized inline during streaming. ## Supported Message Types -| Type | Behavior | -| ---------------- | ---------------------------------------------------- | -| Text | Extracted and sent to the LLM | -| Image | Downloaded, base64-encoded, sent as multimodal input | -| Post (rich text) | Raw JSON passed to the LLM for full context | -| Audio | Descriptive text with duration sent to LLM | -| Video | Descriptive text with duration sent to LLM | -| File | File name and metadata sent to LLM | -| Sticker | Descriptive text sent to LLM | -| Location | Name and coordinates sent to LLM | -| Shared chat/user | Chat or user ID sent to LLM | -| Forwarded msgs | Summary description sent to LLM | +| Type | Behavior | +| --- | --- | +| Text | Sent to the LLM as text | +| Image | Downloaded and passed as multimodal input | +| Post | Raw rich-text JSON is forwarded | +| Audio | Sent as descriptive text with duration | +| Video | Sent as descriptive text with duration | +| File | Sent as descriptive text with file metadata | +| Sticker | Sent as descriptive text | +| Location | Sent as descriptive text with coordinates when present | +| Shared chat/user | Sent as descriptive text | +| Forwarded messages | Sent as a summary marker | -## Thread Support +## Native Threading -The bot supports native Feishu threading. When a user sends a message in a thread, the bot: +When a user messages inside a Feishu thread, anna keeps the response in that thread and scopes the session to the thread root. Replies outside threads stay in the parent chat session. -- Creates a **separate agent session** for that thread (isolated from the group conversation) -- Replies within the same thread -- Maintains thread-specific conversation history +## Group Behavior -This allows multiple parallel conversations in the same group chat without interference. +`group_mode` controls whether anna responds in groups: -## Abort / Cancel +- `mention`: respond only when the bot is mentioned +- `always`: respond to every message +- `disabled`: never respond in groups -To cancel an active streaming response, send one of these messages: +You can also set per-group overrides with the `groups` map in channel config. -- `cancel`, `stop`, `abort` (English, case-insensitive) -- `取消`, `停止` (Chinese) - -The bot immediately cancels the active stream and replies with "Cancelled." - -## Group Support - -On startup, the bot fetches its own `open_id` via the Feishu Bot Info API. This enables reliable @mention detection in groups and prevents the bot from responding to its own messages (infinite loop protection). - -In group chats, the bot responds to @mentions. Set the group mode in the admin panel: +## Commands -- `mention` -- respond to @mentions (default) -- `always` -- respond to all group messages -- `disabled` -- ignore group messages entirely +Feishu supports the standard chat commands: -### Per-Group Configuration +| Command | Description | +| --- | --- | +| `/new` | Start a fresh session | +| `/compact` | Compact session history | +| `/model` | List or switch models | +| `/agent` | List or switch agents | +| `/whoami` | Show your platform identity | -You can override settings for specific group chats by adding a `groups` map to the Feishu channel config JSON. Each key is a Feishu chat_id (e.g., `oc_abc123`): +## Config Reference ```json { - "app_id": "cli_xxx", - "app_secret": "xxx", + "app_id": "FEISHU_APP_ID", + "app_secret": "FEISHU_APP_SECRET", + "encrypt_key": "", + "verification_token": "", + "group_mode": "mention", + "enable_notify": false, "groups": { - "oc_abc123": { + "oc_example": { "group_mode": "always", - "system_prompt": "You are a project management assistant for Team Alpha." - }, - "oc_def456": { - "group_mode": "mention", - "system_prompt": "You are a technical support bot. Answer concisely." + "system_prompt": "Answer as the infra assistant for this group." } } } ``` | Field | Description | -|-------|-------------| -| `group_mode` | Override the global group mode for this chat | -| `system_prompt` | Prepend a system prompt to every message in this chat | -| `tool_allow` | Reserved for future use: allowlist of tool names | -| `tool_deny` | Reserved for future use: denylist of tool names | - -## Reactions - -When a user reacts to a message with an emoji, the bot sends a description of the reaction to the agent (e.g., `[User reacted with THUMBSUP on message om_xxx]`). The agent can then respond if appropriate. - -The LLM can also add or remove reactions via the `feishu_im` tool's `add_reaction` and `remove_reaction` actions. - -## Access Control - -Restrict which users can interact with the bot by adding allowed open_ids in the admin panel. Leave empty to allow all users. Use the `/whoami` command to get your open_id. - -## Notifications - -Configure a default notification chat in the admin panel for proactive notifications (scheduler results, agent-triggered alerts). - -## Commands - -Send these commands as text messages to the bot: - -| Command | Description | -| ------------------- | ----------------------------------------------- | -| `/start` or `/help` | Welcome and help | -| `/new` | Start a fresh session | -| `/compact` | Compress conversation history | -| `/model` | List available models | -| `/model ` | Switch to model by number | -| `/model ` | Filter models by name | -| `/auth` | Start OAuth authorization (get auth card) | -| `/auth ` | Complete OAuth with authorization code | -| `/whoami` | Show your user ID for config | - -## Required Permissions - -### Bot Permissions (always needed) - -| Scope | Description | -|-------|-------------| -| `im:message` | Send and receive messages | -| `im:message:send_as_bot` | Send messages as bot | -| `im:resource` | Access message resources (images, files) | -| `im:chat` | Access chat info | -| `contact:user.base:readonly` | Read basic user info | - -### User Permissions (for OAuth tools) - -Add these based on which tools you want to enable: - -| Tool | Scopes | -|------|--------| -| Calendar | `calendar:calendar`, `calendar:calendar:readonly` | -| Tasks | `task:task`, `task:task:readonly` | -| Bitable | `bitable:app`, `bitable:app:readonly` | -| Docs | `docx:document`, `docx:document:readonly` | -| Wiki | `wiki:wiki`, `wiki:wiki:readonly` | -| Sheets | `sheets:spreadsheet`, `sheets:spreadsheet:readonly` | -| Drive | `drive:drive`, `drive:drive:readonly` | - -## Configuration Reference - -All settings below are managed through the admin panel (`anna --open`). - -| Field | Description | Default | -| -------------------- | -------------------------------------------------- | ---------- | -| `app_id` | Feishu App ID | (required) | -| `app_secret` | Feishu App Secret | (required) | -| `encrypt_key` | Event encrypt key (from Events & Callbacks) | (optional) | -| `verification_token` | Event verification token (from Events & Callbacks) | (optional) | -| `notify_chat` | Chat ID for proactive notifications | (optional) | -| `group_mode` | Group behavior: `mention`, `always`, `disabled` | `mention` | -| `allowed_ids` | User open_ids allowed (empty = all) | `[]` | -| `groups` | Per-group config overrides (see above) | `{}` | -| `redirect_uri` | OAuth redirect URI | `https://anna.vaayne.com/oauth/callback` | +| --- | --- | +| `app_id` | Feishu app ID | +| `app_secret` | Feishu app secret | +| `encrypt_key` | Optional event encryption key | +| `verification_token` | Optional event verification token | +| `group_mode` | Default group behavior: `mention`, `always`, or `disabled` | +| `enable_notify` | Allow scheduler and notify output to target Feishu | +| `groups` | Optional per-chat overrides keyed by Feishu `chat_id` | diff --git a/docs/content/docs/channels/feishu.zh.md b/docs/content/docs/channels/feishu.zh.md index d3cc927a..3c2dc72a 100644 --- a/docs/content/docs/channels/feishu.zh.md +++ b/docs/content/docs/channels/feishu.zh.md @@ -1,248 +1,122 @@ --- -title: 飞书机器人 +title: Feishu Bot --- -anna 包含一个通过 WebSocket 连接的飞书(Lark)机器人(持久连接,无需公网 URL)。机器人支持 11 个飞书工作区工具、用户 OAuth 授权、流式响应、原生话题支持和按群配置。 +anna 内置了通过 WebSocket 连接的 Feishu(Lark)机器人,因此不需要公网 webhook。现在 Feishu 集成只负责聊天通道:消息、流式回复、线程、群聊和通知仍然由 anna 处理;日历、文档、任务等工作区自动化已经迁移到 `lark-cli`。 ## 设置 -1. 在[飞书开放平台](https://open.feishu.cn/)创建一个飞书应用 -2. 在应用设置中启用**机器人**能力 -3. 在**事件订阅**下,添加以下事件: - - `im.message.receive_v1`(必需)— 接收消息 - - `im.message.reaction.created_v1`(可选)— 接收表情回复 -4. 在**权限管理**下,添加[所需权限](#所需权限)部分列出的权限 -5. 从应用设置中获取你的 App ID、App Secret、Encrypt Key 和 Verification Token -6. 运行 `anna --open` 启动管理面板 -7. 在管理面板中:添加一个 AI 提供商,然后使用你的应用凭据配置飞书频道 -8. 启动服务: +1. 在 [飞书开放平台](https://open.feishu.cn/) 创建应用。 +2. 启用 **Bot** 能力。 +3. 在 **事件订阅** 中添加: + - `im.message.receive_v1` + - 如果需要表情事件,再添加 `im.message.reaction.created_v1` +4. 复制 App ID、App Secret、Encrypt Key 和 Verification Token。 +5. 运行 `anna --open`,在管理面板里配置 Feishu 频道。 +6. 启动 anna: ```bash anna ``` -所有频道配置(凭据、群组模式、允许的 ID 等)都通过管理面板管理。环境变量仅限于提供商 API 密钥(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)和 `ANNA_HOME`。 +## Lark 工作区自动化 -## 用户 OAuth 授权(UAT) +旧的内置 `feishu_*` 工具和 `/auth` 流程已经移除。 -许多飞书工具(日历、任务等)可以代表用户操作,而不仅仅是机器人。这需要每个用户通过 OAuth 授权应用。 +如果你要操作日历、任务、文档、知识库、表格、云盘、联系人等工作区数据,请按需自行安装 `lark-cli` skill,并配合外部 [`lark-cli`](https://github.com/larksuite/cli) 工具。 -### 工作原理 +常见初始化流程: -1. **用户发送 `/auth`** — 在与机器人的对话中 -2. **机器人回复一张交互卡片** — 包含授权链接 -3. **用户点击链接** — 在浏览器中打开飞书 OAuth 授权页面 -4. **用户同意授权** — 在 OAuth 页面上批准权限请求 -5. **飞书重定向到回调页面** — `anna.vaayne.com/oauth/callback` 显示授权码和"复制命令"按钮 -6. **用户复制并发送 `/auth `** — 将授权码发回机器人 -7. **机器人交换令牌** — 将授权码兑换为访问令牌和刷新令牌,加密存储在数据库中 -8. **完成** — 用户的身份现在可用于工具调用 - -### 令牌生命周期 - -- **访问令牌** 约 2 小时后过期。机器人会自动透明地刷新。 -- **刷新令牌** 约 30 天后过期。之后用户需要重新使用 `/auth` 授权。 -- **存储**:令牌使用 AES-256-GCM 加密存储。加密密钥通过 HKDF 从 App Secret 派生。 -- **降级**:如果用户未授权,工具会回退到机器人令牌(权限较低)。 - -### 飞书应用 OAuth 配置 - -要使 `/auth` 流程正常工作,需要配置你的飞书应用: - -1. 进入飞书开放平台控制台的**安全设置** -2. 添加 `https://anna.vaayne.com/oauth/callback` 作为**重定向 URL** -3. 在**权限管理**下,申请工具所需的用户级权限(参见[所需权限](#所需权限)) - -如果你自托管文档站点或需要不同的回调 URL,请在飞书频道配置中设置 `redirect_uri`,并在飞书应用中注册该 URL。 - -### 撤销授权 - -用户可以随时在飞书账号设置的**已连接的应用**中撤销授权。机器人会检测到过期的令牌,并在需要时提示重新授权。 - -## 飞书工作区工具 - -配置飞书频道后,anna 会注册 11 个多操作工具供 LLM 代理调用。这些工具对所有代理可用,不受用户当前聊天频道的限制。 - -| 工具 | 操作 | 描述 | -|------|------|------| -| `feishu_user` | get_user, search_user | 按 open_id、邮箱或手机号查找用户 | -| `feishu_calendar` | 创建/列出/获取/更新/删除事件, add_attendees, freebusy | 完整的日历管理,支持重复事件 | -| `feishu_task` | 创建/列出/获取/更新/完成任务, 任务列表, 子任务 | 使用 Task v2 API 的任务管理 | -| `feishu_bitable` | 应用/表/记录/字段增删改查, 批量操作, 搜索/过滤 | 多维表格数据库操作 | -| `feishu_chat` | 搜索, 信息, 添加/移除/列出成员 | 聊天和群组管理 | -| `feishu_im` | 发送/回复/读取/获取/转发消息, 表情回复 | 消息和表情回复 | -| `feishu_doc` | 创建文档, 获取内容/原始内容 | 文档创建和读取 | -| `feishu_wiki` | 空间, 节点增删改查, 移动/复制 | 知识库管理 | -| `feishu_sheets` | 创建/获取电子表格, 列出工作表, 读写范围 | 电子表格操作 | -| `feishu_drive` | 列出/复制/移动/删除文件, 创建文件夹, 获取元数据 | 文件和文件夹管理 | -| `feishu_search` | 搜索文档/知识库 | 全局文档搜索,支持过滤 | +```bash +command -v lark-cli +npm install -g @larksuite/cli +lark-cli config init --new +lark-cli auth login --recommend +lark-cli auth status +``` -工具会优先使用用户的 OAuth 令牌(用于用户级操作,如在用户日历上创建事件),无令牌时回退到机器人令牌。 +用户自行安装的 `lark-cli` skill 可以覆盖原来的 `feishu_calendar`、`feishu_task`、`feishu_im`、`feishu_doc`、`feishu_wiki`、`feishu_sheets`、`feishu_drive`、`feishu_bitable`、`feishu_user` 和 `feishu_search` 等工作流。 ## 多用户支持 -每个飞书用户会从其平台身份自动解析。会话按用户和代理分别管理。无需手动设置用户。 +每个 Feishu 用户都会通过平台身份自动解析。会话按用户和 agent 隔离,因此不同用户拥有各自独立的记忆和默认 agent 状态。 -## 流式响应 +## 流式回复 -机器人使用飞书的 CardKit 2.0 API 进行流式响应,分为三个阶段: +机器人会通过原地编辑消息来实现流式输出: -1. **思考中** — 立即发送一张"Thinking..."初始卡片 -2. **生成中** — 随着 LLM 生成令牌,卡片逐步更新内容 -3. **完成** — 最终卡片包含响应时间底栏(例如 _Response time: 3.2s_) +1. 先快速发送占位回复 +2. 模型生成时持续更新内容 +3. 最终写入完整回复和耗时信息 -### 工具指示器 - -在工具执行期间,流式消息会显示带有表情符号的状态指示器: - -| 工具 | 表情符号 | -| -------- | -------- | -| `bash` | 闪电 | -| `read` | 书本 | -| `write` | 铅笔 | -| `edit` | 扳手 | -| `search` | 放大镜 | +执行工具时的状态也会在流式消息中简要显示。 ## 支持的消息类型 -| 类型 | 行为 | -| ------------------ | --------------------------------------- | -| 文本 | 提取并发送给 LLM | -| 图片 | 下载、base64 编码、作为多模态输入发送 | -| 富文本消息(Post) | 将原始 JSON 传递给 LLM 以获取完整上下文 | -| 音频 | 带时长的描述文本发送给 LLM | -| 视频 | 带时长的描述文本发送给 LLM | -| 文件 | 文件名和元数据发送给 LLM | -| 表情贴纸 | 描述文本发送给 LLM | -| 位置 | 名称和坐标发送给 LLM | -| 分享聊天/用户 | 聊天或用户 ID 发送给 LLM | -| 合并转发 | 摘要描述发送给 LLM | - -## 话题支持 - -机器人支持飞书原生话题。当用户在话题中发送消息时,机器人会: +| 类型 | 行为 | +| --- | --- | +| 文本 | 作为普通文本发送给 LLM | +| 图片 | 下载后作为多模态输入发送 | +| 富文本 Post | 原始 JSON 直接传给 LLM | +| 音频 | 转成带时长的描述文本 | +| 视频 | 转成带时长的描述文本 | +| 文件 | 转成带文件信息的描述文本 | +| 表情贴纸 | 转成描述文本 | +| 位置 | 尽量附带坐标信息的描述文本 | +| 分享的群聊/用户 | 转成描述文本 | +| 合并转发 | 转成摘要标记 | -- 为该话题创建**独立的代理会话**(与群聊对话隔离) -- 在同一话题内回复 -- 维护话题专属的对话历史 +## 原生线程 -这允许在同一群聊中进行多个并行对话而互不干扰。 +如果用户在 Feishu 线程中发消息,anna 会在线程内回复,并把会话作用域绑定到该线程根消息。线程外消息仍然使用父聊天会话。 -## 取消 / 中止 +## 群组行为 -要取消正在进行的流式响应,发送以下任一消息: +`group_mode` 控制 anna 在群聊中的响应方式: -- `cancel`、`stop`、`abort`(英文,不区分大小写) -- `取消`、`停止`(中文) +- `mention`:只有被 @ 时才回复 +- `always`:回复所有消息 +- `disabled`:从不在群里回复 -机器人会立即取消活跃的流式响应并回复"Cancelled."。 +你也可以通过 `groups` 字段为特定群单独覆盖配置。 -## 群组支持 - -启动时,机器人通过飞书 Bot Info API 获取自己的 `open_id`。这使得在群组中可以可靠地检测 @提及,并防止机器人响应自己的消息(无限循环保护)。 - -在群聊中,机器人响应 @提及。在管理面板中设置群组模式: +## 命令 -- `mention` — 响应 @提及(默认) -- `always` — 响应所有群组消息 -- `disabled` — 完全忽略群组消息 +Feishu 支持标准聊天命令: -### 按群配置 +| 命令 | 说明 | +| --- | --- | +| `/new` | 开启新会话 | +| `/compact` | 压缩当前会话历史 | +| `/model` | 列出或切换模型 | +| `/agent` | 列出或切换 agent | +| `/whoami` | 显示你的平台身份 | -你可以通过在飞书频道配置 JSON 中添加 `groups` 映射来覆盖特定群聊的设置。每个键是飞书的 chat_id(例如 `oc_abc123`): +## 配置参考 ```json { - "app_id": "cli_xxx", - "app_secret": "xxx", + "app_id": "FEISHU_APP_ID", + "app_secret": "FEISHU_APP_SECRET", + "encrypt_key": "", + "verification_token": "", + "group_mode": "mention", + "enable_notify": false, "groups": { - "oc_abc123": { + "oc_example": { "group_mode": "always", - "system_prompt": "你是 Alpha 团队的项目管理助手。" - }, - "oc_def456": { - "group_mode": "mention", - "system_prompt": "你是技术支持机器人,请简洁回答。" + "system_prompt": "这个群里请作为基础设施助手回复。" } } } ``` -| 字段 | 描述 | -|------|------| -| `group_mode` | 覆盖该群聊的全局群组模式 | -| `system_prompt` | 在该群聊中为每条消息添加系统提示前缀 | -| `tool_allow` | 预留:工具名称白名单 | -| `tool_deny` | 预留:工具名称黑名单 | - -## 表情回复 - -当用户对消息添加表情回复时,机器人会将表情回复的描述发送给代理(例如 `[User reacted with THUMBSUP on message om_xxx]`)。代理可以据此做出响应。 - -LLM 也可以通过 `feishu_im` 工具的 `add_reaction` 和 `remove_reaction` 操作来添加或移除表情回复。 - -## 访问控制 - -通过在管理面板中添加允许的 open_id 来限制哪些用户可以与机器人交互。留空则允许所有用户。使用 `/whoami` 命令获取你的 open_id。 - -## 通知 - -在管理面板中配置默认通知聊天,用于主动通知(调度器结果、代理触发的警报)。 - -## 命令 - -将这些命令作为文本消息发送给机器人: - -| 命令 | 描述 | -| ------------------- | ---------------------------------- | -| `/start` 或 `/help` | 欢迎和帮助信息 | -| `/new` | 开始新的会话 | -| `/compact` | 压缩对话历史 | -| `/model` | 列出可用模型 | -| `/model ` | 按编号切换模型 | -| `/model ` | 按名称过滤模型 | -| `/auth` | 开始 OAuth 授权(获取授权卡片) | -| `/auth ` | 使用授权码完成 OAuth | -| `/whoami` | 显示你的用户 ID 用于配置 | - -## 所需权限 - -### 机器人权限(始终需要) - -| 权限 | 描述 | -|------|------| -| `im:message` | 发送和接收消息 | -| `im:message:send_as_bot` | 以机器人身份发送消息 | -| `im:resource` | 访问消息资源(图片、文件) | -| `im:chat` | 访问聊天信息 | -| `contact:user.base:readonly` | 读取基本用户信息 | - -### 用户权限(用于 OAuth 工具) - -根据你要启用的工具添加以下权限: - -| 工具 | 权限 | -|------|------| -| 日历 | `calendar:calendar`, `calendar:calendar:readonly` | -| 任务 | `task:task`, `task:task:readonly` | -| 多维表格 | `bitable:app`, `bitable:app:readonly` | -| 文档 | `docx:document`, `docx:document:readonly` | -| 知识库 | `wiki:wiki`, `wiki:wiki:readonly` | -| 电子表格 | `sheets:spreadsheet`, `sheets:spreadsheet:readonly` | -| 云空间 | `drive:drive`, `drive:drive:readonly` | - -## 配置参考 - -以下所有设置都通过 `anna --open` 管理面板管理。 - -| 字段 | 描述 | 默认值 | -| -------------------- | ----------------------------------------- | --------- | -| `app_id` | 飞书 App ID | (必需) | -| `app_secret` | 飞书 App Secret | (必需) | -| `encrypt_key` | 事件加密密钥(来自事件与回调) | (可选) | -| `verification_token` | 事件验证令牌(来自事件与回调) | (可选) | -| `notify_chat` | 用于主动通知的聊天 ID | (可选) | -| `group_mode` | 群组行为:`mention`、`always`、`disabled` | `mention` | -| `allowed_ids` | 允许的用户 open_id(空 = 所有人) | `[]` | -| `groups` | 按群配置覆盖(参见上方) | `{}` | -| `redirect_uri` | OAuth 重定向 URI | `https://anna.vaayne.com/oauth/callback` | +| 字段 | 说明 | +| --- | --- | +| `app_id` | 飞书应用 App ID | +| `app_secret` | 飞书应用 App Secret | +| `encrypt_key` | 可选的事件加密密钥 | +| `verification_token` | 可选的事件校验 token | +| `group_mode` | 默认群聊行为:`mention`、`always` 或 `disabled` | +| `enable_notify` | 允许调度器和 `notify` 输出发送到 Feishu | +| `groups` | 按 Feishu `chat_id` 配置的群级覆盖项 | diff --git a/docs/src/routes/oauth/callback.tsx b/docs/src/routes/oauth/callback.tsx index af404fee..53df2cbf 100644 --- a/docs/src/routes/oauth/callback.tsx +++ b/docs/src/routes/oauth/callback.tsx @@ -3,16 +3,11 @@ import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/oauth/callback')({ component: OAuthCallback, head: () => ({ - meta: [{ title: 'Authorization Code - Anna' }], + meta: [{ title: 'Lark CLI Authentication - Anna' }], }), }); function OAuthCallback() { - const code = - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('code') - : null; - return (
- {code ? : } +
); } -function SuccessView({ code }: { code: string }) { - const copyCode = () => { - navigator.clipboard.writeText(`/auth ${code}`); - }; - +function InfoView() { return ( <> -
+

- Authorization Successful + Feishu OAuth Was Removed

- Copy the command below and send it to your Anna bot in Feishu: + Anna no longer uses the old Feishu bot OAuth callback flow for workspace + tools.

- /auth {code} + lark-cli config init --new +
+ lark-cli auth login --recommend
-

- After sending the command, you can close this page. -

- - ); -} - -function ErrorView() { - return ( - <> -
-

- No Authorization Code -

-

- No authorization code was found in the URL. Please go back to your Feishu - chat and send /auth to start the authorization flow again. + Add a lark-cli skill yourself if you want Lark workspace + actions, and keep Feishu configured only as a chat channel.

); diff --git a/internal/admin/ui/pages/channels.templ b/internal/admin/ui/pages/channels.templ index 68129b63..3df0c202 100644 --- a/internal/admin/ui/pages/channels.templ +++ b/internal/admin/ui/pages/channels.templ @@ -106,6 +106,9 @@ templ ChannelsPage() { /> Notifications +

+ Feishu is chat-only. Add a lark-cli skill yourself if you want Lark workspace automation. +

@ui.FormField("App ID") { diff --git a/internal/admin/ui/pages/channels_templ.go b/internal/admin/ui/pages/channels_templ.go index 1205fa1b..27d14044 100644 --- a/internal/admin/ui/pages/channels_templ.go +++ b/internal/admin/ui/pages/channels_templ.go @@ -263,7 +263,7 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Feishu is chat-only. Add a lark-cli skill yourself if you want Lark workspace automation.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -466,7 +466,7 @@ func channelBlock(name string, platform string) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 186, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 189, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -479,7 +479,7 @@ func channelBlock(name string, platform string) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs("channelData." + platform + ".enabled ? 'badge-success' : 'badge-ghost'") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 189, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 192, Col: 86} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -492,7 +492,7 @@ func channelBlock(name string, platform string) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs("channelData." + platform + ".enabled ? 'on' : 'off'") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 190, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 193, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -505,7 +505,7 @@ func channelBlock(name string, platform string) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs("channelData." + platform + ".enabled") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 196, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 199, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -518,7 +518,7 @@ func channelBlock(name string, platform string) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs("channelData." + platform + ".enabled = $event.target.checked") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 197, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 200, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -531,7 +531,7 @@ func channelBlock(name string, platform string) templ.Component { var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs("channelData." + platform + ".enabled") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 203, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 206, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { diff --git a/internal/agent/runner/builtin/anna/references/channels.md b/internal/agent/runner/builtin/anna/references/channels.md index 8f11f8d0..85feb618 100644 --- a/internal/agent/runner/builtin/anna/references/channels.md +++ b/internal/agent/runner/builtin/anna/references/channels.md @@ -105,13 +105,14 @@ Feishu channel config (JSON): 5. Start: `anna` -Connects via WebSocket (no public URL or webhook needed). Feishu currently uses the default agent only. +Connects via WebSocket (no public URL or webhook needed). ### Feishu features - Edit-in-place streaming for progressive responses - Private (p2p) and group @mention support -- Commands: `/new`, `/compact`, `/model`, `/whoami` +- Commands: `/new`, `/compact`, `/model`, `/agent`, `/whoami` +- Chat transport only. Workspace automation moved out of builtin `feishu_*` tools; add a `lark-cli` skill yourself if you want that workflow. ## WeChat bot (iLink) diff --git a/internal/agent/runner/builtin/anna/references/configuration.md b/internal/agent/runner/builtin/anna/references/configuration.md index 31806036..1ec3b685 100644 --- a/internal/agent/runner/builtin/anna/references/configuration.md +++ b/internal/agent/runner/builtin/anna/references/configuration.md @@ -54,6 +54,8 @@ Access control is handled by RBAC (auth_identities + policy engine). Notificatio **Feishu config fields:** `app_id`, `app_secret`, `encrypt_key`, `verification_token`, `group_mode`, `enable_notify` +Feishu is a chat channel only. Lark workspace operations no longer ship as built-in `feishu_*` tools; add a `lark-cli` skill yourself if you want that workflow. + ## Settings (key-value) Global settings are stored in the `settings` table as JSON values: diff --git a/internal/channel/feishu/feishu.go b/internal/channel/feishu/feishu.go index 57365923..fd5550a8 100644 --- a/internal/channel/feishu/feishu.go +++ b/internal/channel/feishu/feishu.go @@ -21,7 +21,6 @@ import ( "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" - "github.com/vaayne/anna/internal/feishutool" ) const feishuMaxMessageLen = 4000 @@ -51,14 +50,12 @@ type Config struct { VerificationToken string `json:"verification_token"` GroupMode string `json:"group_mode"` // "mention" | "always" | "disabled" Groups map[string]GroupConfig `json:"groups"` // per-group overrides keyed by chat_id - RedirectURI string `json:"redirect_uri"` // OAuth redirect URI (default: https://anna.vaayne.com/oauth/callback) } // Bot wraps a Feishu bot with agent pool integration. type Bot struct { client *lark.Client wsClient *larkws.Client - fsClient *feishutool.Client // feishutool client for OAuth operations poolManager *agent.PoolManager store config.Store authStore auth.AuthStore @@ -95,14 +92,6 @@ func WithAuth(authStore auth.AuthStore, engine *auth.PolicyEngine, linkCodes *au } } -// WithFeishuClient configures the bot with a feishutool.Client for OAuth -// and UAT token operations. -func WithFeishuClient(c *feishutool.Client) BotOption { - return func(b *Bot) { - b.fsClient = c - } -} - // New creates a Feishu bot. Call Start to begin receiving events. func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn ModelListFunc, switchFn ModelSwitchFunc, opts ...BotOption) (*Bot, error) { if cfg.AppID == "" || cfg.AppSecret == "" { diff --git a/internal/channel/feishu/handler.go b/internal/channel/feishu/handler.go index 6e63f1a4..6f4e27fb 100644 --- a/internal/channel/feishu/handler.go +++ b/internal/channel/feishu/handler.go @@ -13,7 +13,6 @@ import ( "github.com/vaayne/anna/internal/agent/runner" "github.com/vaayne/anna/internal/ai" "github.com/vaayne/anna/internal/channel" - "github.com/vaayne/anna/internal/feishutool" ) // onReaction handles incoming Feishu reaction events. @@ -149,14 +148,6 @@ func (b *Bot) onMessage(ctx context.Context, event *larkim.P2MessageReceiveV1) e } } - // Handle /auth command before general command dispatch since it needs - // messageID for sending interactive cards (not just text replies). - if text != "" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(text)), "/auth") { - authArgs := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "/auth")) - b.handleAuthCommand(openID, chatID, messageID, authArgs) - return nil - } - if text != "" { if handled := b.handleCommand(rc, text, openID, replyFn); handled { return nil @@ -417,11 +408,6 @@ func (b *Bot) handleMessage(rc *channel.ResolvedChat, openID, chatID, messageID, defer b.unregisterStream(key) defer cancel() - // Inject Feishu context so tools can access open_id, chat_id, message_id. - ctx = feishutool.WithOpenID(ctx, openID) - ctx = feishutool.WithChatID(ctx, chatID) - ctx = feishutool.WithMessageID(ctx, messageID) - events, sessionID, err := rc.Chat(ctx, content) if err != nil { logger().Error("chat failed", "open_id", openID, "error", err) diff --git a/internal/channel/feishu/oauth.go b/internal/channel/feishu/oauth.go deleted file mode 100644 index c4e51196..00000000 --- a/internal/channel/feishu/oauth.go +++ /dev/null @@ -1,130 +0,0 @@ -package feishu - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - "github.com/vaayne/anna/internal/feishutool" -) - -// handleAuthCommand handles the /auth command. -// If text is "/auth" alone, it sends an OAuth authorization card. -// If text is "/auth ", it exchanges the code for tokens. -func (b *Bot) handleAuthCommand(openID, chatID, messageID string, args string) { - if b.fsClient == nil { - b.replyText(b.ctx, messageID, "Feishu OAuth is not configured.") - return - } - - args = strings.TrimSpace(args) - - if args == "" { - // Send auth card with authorization URL. - b.sendAuthCard(openID, chatID, messageID) - return - } - - // Treat args as authorization code. - b.exchangeAuthCode(openID, messageID, args) -} - -const defaultRedirectURI = "https://anna.vaayne.com/oauth/callback" - -// sendAuthCard sends an interactive card with the OAuth authorization URL. -func (b *Bot) sendAuthCard(openID, chatID, messageID string) { - redirectURI := b.cfg.RedirectURI - if redirectURI == "" { - redirectURI = defaultRedirectURI - } - authURL := feishutool.AuthURL(b.cfg.AppID, redirectURI, openID) - - card := map[string]any{ - "schema": "2.0", - "header": map[string]any{ - "title": map[string]any{ - "tag": "plain_text", - "content": "Authorization Required", - }, - "template": "blue", - }, - "body": map[string]any{ - "elements": []map[string]any{ - { - "tag": "markdown", - "content": fmt.Sprintf("To use Feishu tools that require your identity (calendar, tasks, etc.), you need to authorize this app.\n\n**[Click here to authorize](%s)**\n\nAfter authorization, copy the code from the page and send it back with `/auth `.", authURL), - }, - }, - }, - } - - cardJSON, err := json.Marshal(card) - if err != nil { - logger().Error("marshal auth card failed", "error", err) - b.replyText(b.ctx, messageID, fmt.Sprintf("Please visit this URL to authorize:\n%s", authURL)) - return - } - - b.sendCard(b.ctx, messageID, string(cardJSON)) -} - -// exchangeAuthCode exchanges an authorization code for tokens and stores them. -// It verifies the token's owner matches the requesting user to prevent -// cross-account token binding. -func (b *Bot) exchangeAuthCode(openID, messageID, code string) { - token, err := b.fsClient.ExchangeCode(b.ctx, code) - if err != nil { - logger().Error("exchange auth code failed", "open_id", openID, "error", err) - b.replyText(b.ctx, messageID, fmt.Sprintf("Authorization failed: %v\n\nPlease try `/auth` again.", err)) - return - } - - // Verify the token belongs to the requesting user by calling the - // user info endpoint with the new access token. - tokenOpenID, err := b.fsClient.GetTokenOwner(b.ctx, token.AccessToken) - if err != nil { - logger().Error("verify token owner failed", "open_id", openID, "error", err) - b.replyText(b.ctx, messageID, "Authorization failed: could not verify token owner. Please try `/auth` again.") - return - } - if tokenOpenID != openID { - logger().Warn("OAuth token owner mismatch", "expected", openID, "got", tokenOpenID) - b.replyText(b.ctx, messageID, "Authorization failed: the authorization code belongs to a different user. Please use your own authorization link.") - return - } - - ts := b.fsClient.TokenStore() - if ts == nil { - b.replyText(b.ctx, messageID, "Token storage is not configured.") - return - } - - if err := ts.Set(b.ctx, openID, token); err != nil { - logger().Error("store token failed", "open_id", openID, "error", err) - b.replyText(b.ctx, messageID, "Failed to store authorization token. Please try again.") - return - } - - b.replyText(b.ctx, messageID, "Authorization successful! You can now use Feishu tools with your identity.") -} - -// sendCard sends an interactive card as a reply to a message. -func (b *Bot) sendCard(ctx context.Context, messageID, cardJSON string) { - resp, err := b.client.Im.Message.Reply(ctx, - larkim.NewReplyMessageReqBuilder(). - MessageId(messageID). - Body(larkim.NewReplyMessageReqBodyBuilder(). - MsgType(larkim.MsgTypeInteractive). - Content(cardJSON). - Build()). - Build()) - if err != nil { - logger().Error("send card failed", "message_id", messageID, "error", err) - return - } - if !resp.Success() { - logger().Error("send card failed", "message_id", messageID, "code", resp.Code, "msg", resp.Msg) - } -} diff --git a/internal/channel/feishu/oauth_test.go b/internal/channel/feishu/oauth_test.go deleted file mode 100644 index 4771c7a4..00000000 --- a/internal/channel/feishu/oauth_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package feishu - -import ( - "testing" - - "github.com/vaayne/anna/internal/feishutool" -) - -func TestAuthURL(t *testing.T) { - url := feishutool.AuthURL("cli_test123", "https://example.com/callback", "ou_abc") - if url == "" { - t.Fatal("AuthURL returned empty string") - } - - expected := "https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=cli_test123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=ou_abc" - if url != expected { - t.Errorf("AuthURL = %q, want %q", url, expected) - } -} - -func TestAuthURLEmptyRedirect(t *testing.T) { - url := feishutool.AuthURL("cli_test123", "", "state123") - if url == "" { - t.Fatal("AuthURL returned empty string") - } - // Should still produce a valid URL structure (no redirect_uri when empty). - expected := "https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=cli_test123&state=state123" - if url != expected { - t.Errorf("AuthURL = %q, want %q", url, expected) - } -} diff --git a/internal/db/queries/feishu_tokens.sql b/internal/db/queries/feishu_tokens.sql deleted file mode 100644 index 95dc6f8e..00000000 --- a/internal/db/queries/feishu_tokens.sql +++ /dev/null @@ -1,15 +0,0 @@ --- name: GetFeishuToken :one -SELECT * FROM feishu_tokens WHERE open_id = ?; - --- name: UpsertFeishuToken :exec -INSERT INTO feishu_tokens (open_id, access_token, refresh_token, expires_at, refresh_expires_at) -VALUES (?, ?, ?, ?, ?) -ON CONFLICT(open_id) DO UPDATE SET - access_token = excluded.access_token, - refresh_token = excluded.refresh_token, - expires_at = excluded.expires_at, - refresh_expires_at = excluded.refresh_expires_at, - updated_at = datetime('now'); - --- name: DeleteFeishuToken :exec -DELETE FROM feishu_tokens WHERE open_id = ?; diff --git a/internal/db/schemas/main.sql b/internal/db/schemas/main.sql index 97250544..2aa6cf7d 100644 --- a/internal/db/schemas/main.sql +++ b/internal/db/schemas/main.sql @@ -17,4 +17,3 @@ -- atlas:import tables/auth_policies.sql -- atlas:import tables/auth_user_agents.sql -- atlas:import tables/auth_sessions.sql --- atlas:import tables/feishu_tokens.sql diff --git a/internal/db/schemas/tables/feishu_tokens.sql b/internal/db/schemas/tables/feishu_tokens.sql deleted file mode 100644 index 87df0f61..00000000 --- a/internal/db/schemas/tables/feishu_tokens.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE feishu_tokens ( - open_id TEXT PRIMARY KEY, - access_token TEXT NOT NULL, -- AES-256-GCM encrypted, base64 - refresh_token TEXT NOT NULL, -- AES-256-GCM encrypted, base64 - expires_at TEXT NOT NULL, -- ISO 8601 - refresh_expires_at TEXT NOT NULL, -- ISO 8601 - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/internal/db/sqlc/feishu_tokens.sql.go b/internal/db/sqlc/feishu_tokens.sql.go deleted file mode 100644 index a43c1314..00000000 --- a/internal/db/sqlc/feishu_tokens.sql.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: feishu_tokens.sql - -package sqlc - -import ( - "context" -) - -const deleteFeishuToken = `-- name: DeleteFeishuToken :exec -DELETE FROM feishu_tokens WHERE open_id = ? -` - -func (q *Queries) DeleteFeishuToken(ctx context.Context, openID string) error { - _, err := q.db.ExecContext(ctx, deleteFeishuToken, openID) - return err -} - -const getFeishuToken = `-- name: GetFeishuToken :one -SELECT open_id, access_token, refresh_token, expires_at, refresh_expires_at, created_at, updated_at FROM feishu_tokens WHERE open_id = ? -` - -func (q *Queries) GetFeishuToken(ctx context.Context, openID string) (FeishuToken, error) { - row := q.db.QueryRowContext(ctx, getFeishuToken, openID) - var i FeishuToken - err := row.Scan( - &i.OpenID, - &i.AccessToken, - &i.RefreshToken, - &i.ExpiresAt, - &i.RefreshExpiresAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const upsertFeishuToken = `-- name: UpsertFeishuToken :exec -INSERT INTO feishu_tokens (open_id, access_token, refresh_token, expires_at, refresh_expires_at) -VALUES (?, ?, ?, ?, ?) -ON CONFLICT(open_id) DO UPDATE SET - access_token = excluded.access_token, - refresh_token = excluded.refresh_token, - expires_at = excluded.expires_at, - refresh_expires_at = excluded.refresh_expires_at, - updated_at = datetime('now') -` - -type UpsertFeishuTokenParams struct { - OpenID string `json:"open_id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresAt string `json:"expires_at"` - RefreshExpiresAt string `json:"refresh_expires_at"` -} - -func (q *Queries) UpsertFeishuToken(ctx context.Context, arg UpsertFeishuTokenParams) error { - _, err := q.db.ExecContext(ctx, upsertFeishuToken, - arg.OpenID, - arg.AccessToken, - arg.RefreshToken, - arg.ExpiresAt, - arg.RefreshExpiresAt, - ) - return err -} diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index a628cef9..1fe68791 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -136,16 +136,6 @@ type CtxSummaryParent struct { Ordinal int64 `json:"ordinal"` } -type FeishuToken struct { - OpenID string `json:"open_id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresAt string `json:"expires_at"` - RefreshExpiresAt string `json:"refresh_expires_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - type SchedJob struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/feishutool/bitable.go b/internal/feishutool/bitable.go deleted file mode 100644 index f9d89ee0..00000000 --- a/internal/feishutool/bitable.go +++ /dev/null @@ -1,739 +0,0 @@ -package feishutool - -import ( - "context" - "encoding/json" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkbitable "github.com/larksuite/oapi-sdk-go/v3/service/bitable/v1" - "github.com/vaayne/anna/internal/toolspec" -) - -const maxBatchSize = 500 - -var bitableInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create_app", "list_tables", "create_table", "list_records", "create_record", "update_record", "delete_record", "batch_create_records", "batch_update_records", "batch_delete_records", "list_fields", "create_field"], - "description": "The action to perform" - }, - "app_token": { - "type": "string", - "description": "Bitable app token (required for most actions except create_app)" - }, - "table_id": { - "type": "string", - "description": "Table ID (required for record/field operations)" - }, - "record_id": { - "type": "string", - "description": "Record ID (required for update_record, delete_record)" - }, - "name": { - "type": "string", - "description": "Name/title for create_app or create_table" - }, - "fields": { - "type": "object", - "additionalProperties": true, - "description": "Record fields for create/update. Keys are field names, values depend on field type: text=string, number=number, select=string, multi-select=string[], date=number(ms timestamp), checkbox=boolean, person=[{id:'ou_xxx'}]" - }, - "records": { - "type": "array", - "items": { - "type": "object", - "properties": { - "record_id": {"type": "string"}, - "fields": {"type": "object", "additionalProperties": true} - } - }, - "description": "Records array for batch operations (max 500)" - }, - "record_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "Record IDs for batch_delete_records (max 500)" - }, - "view_id": { - "type": "string", - "description": "View ID for list_records (optional, improves performance)" - }, - "field_names": { - "type": "array", - "items": {"type": "string"}, - "description": "Field names to return for list_records (optional, returns all if omitted)" - }, - "filter": { - "type": "object", - "properties": { - "conjunction": {"type": "string", "enum": ["and", "or"]}, - "conditions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field_name": {"type": "string"}, - "operator": {"type": "string", "enum": ["is", "isNot", "contains", "doesNotContain", "isEmpty", "isNotEmpty", "isGreater", "isGreaterEqual", "isLess", "isLessEqual"]}, - "value": {"type": "array", "items": {"type": "string"}} - } - } - } - }, - "description": "Filter for list_records. Example: {conjunction:'and', conditions:[{field_name:'Status', operator:'is', value:['Done']}]}" - }, - "sort": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field_name": {"type": "string"}, - "desc": {"type": "boolean"} - } - }, - "description": "Sort rules for list_records" - }, - "automatic_fields": { - "type": "boolean", - "description": "Include auto fields (created_time, last_modified_time, etc.) in list_records" - }, - "field_type": { - "type": "number", - "description": "Field type for create_field. 1=Text, 2=Number, 3=Select, 4=MultiSelect, 5=Date, 7=Checkbox, 11=Person, 13=Phone, 15=URL, 17=Attachment, 18=Link, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedBy, 1004=ModifiedBy, 1005=AutoNumber" - }, - "field_property": { - "type": "object", - "additionalProperties": true, - "description": "Field property configuration for create_field (type-specific)" - }, - "page_size": { - "type": "number", - "description": "Page size (default 50, max 500)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// BitableTool provides Feishu Bitable (multidimensional spreadsheet) management. -type BitableTool struct { - client *Client -} - -// NewBitableTool creates a feishu_bitable tool. -func NewBitableTool(client *Client) *BitableTool { - return &BitableTool{client: client} -} - -func (t *BitableTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_bitable", - Description: `Manage Feishu/Lark Bitable (multidimensional spreadsheets). Uses user token when available. - -Actions: -- create_app: Create a new Bitable app. Requires name. -- list_tables: List tables in a Bitable app. Requires app_token. -- create_table: Create a table. Requires app_token and name. -- list_records: List/search records (uses Search API). Requires app_token and table_id. Optional: view_id, field_names, filter, sort, automatic_fields, page_size, page_token. -- create_record: Create a single record. Requires app_token, table_id, and fields object. -- update_record: Update a record. Requires app_token, table_id, record_id, and fields object. -- delete_record: Delete a record. Requires app_token, table_id, and record_id. -- batch_create_records: Create multiple records (max 500). Requires app_token, table_id, and records array with fields. -- batch_update_records: Update multiple records (max 500). Requires app_token, table_id, and records array with record_id and fields. -- batch_delete_records: Delete multiple records (max 500). Requires app_token, table_id, and record_ids array. -- list_fields: List fields (columns) of a table. Requires app_token and table_id. -- create_field: Create a field. Requires app_token, table_id, name, and field_type. - -Field types for records: text=string, number=number, select=string, multi-select=string[], date=number(ms timestamp), checkbox=boolean, person=[{id:'ou_xxx'}], attachment=[{file_token:'xxx'}].`, - InputSchema: bitableInputSchema, - } -} - -func (t *BitableTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "create_app": - return t.createApp(ctx, args) - case "list_tables": - return t.listTables(ctx, args) - case "create_table": - return t.createTable(ctx, args) - case "list_records": - return t.listRecords(ctx, args) - case "create_record": - return t.createRecord(ctx, args) - case "update_record": - return t.updateRecord(ctx, args) - case "delete_record": - return t.deleteRecord(ctx, args) - case "batch_create_records": - return t.batchCreateRecords(ctx, args) - case "batch_update_records": - return t.batchUpdateRecords(ctx, args) - case "batch_delete_records": - return t.batchDeleteRecords(ctx, args) - case "list_fields": - return t.listFields(ctx, args) - case "create_field": - return t.createField(ctx, args) - default: - return "", fmt.Errorf("feishu_bitable: unknown action %q", action) - } -} - -func (t *BitableTool) createApp(ctx context.Context, args map[string]any) (string, error) { - name := stringArg(args, "name") - if name == "" { - return "", fmt.Errorf("feishu_bitable create_app: name is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.App.Create(ctx, - larkbitable.NewCreateAppReqBuilder(). - ReqApp(larkbitable.NewReqAppBuilder().Name(name).Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create app: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create app: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"app": resp.Data.App} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable create_app: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) listTables(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - if appToken == "" { - return "", fmt.Errorf("feishu_bitable list_tables: app_token is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkbitable.NewListAppTableReqBuilder().AppToken(appToken) - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Bitable.AppTable.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list tables: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list tables: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = paginatedResultMap("tables", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable list_tables: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) createTable(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - name := stringArg(args, "name") - if appToken == "" || name == "" { - return "", fmt.Errorf("feishu_bitable create_table: app_token and name are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTable.Create(ctx, - larkbitable.NewCreateAppTableReqBuilder(). - AppToken(appToken). - Body(larkbitable.NewCreateAppTableReqBodyBuilder(). - Table(larkbitable.NewReqTableBuilder().Name(name).Build()). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create table: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create table: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"table_id": resp.Data.TableId} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable create_table: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) listRecords(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - if appToken == "" || tableID == "" { - return "", fmt.Errorf("feishu_bitable list_records: app_token and table_id are required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - bodyBuilder := larkbitable.NewSearchAppTableRecordReqBodyBuilder() - - if vid := stringArg(args, "view_id"); vid != "" { - bodyBuilder.ViewId(vid) - } - if fns := toStringSlice(args, "field_names"); len(fns) > 0 { - bodyBuilder.FieldNames(fns) - } - if filter := mapArg(args, "filter"); filter != nil { - bodyBuilder.Filter(buildBitableFilter(filter)) - } - if sortRaw := sliceArg(args, "sort"); len(sortRaw) > 0 { - bodyBuilder.Sort(buildBitableSort(sortRaw)) - } - if af, ok := boolArg(args, "automatic_fields"); ok { - bodyBuilder.AutomaticFields(af) - } - - reqBuilder := larkbitable.NewSearchAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - UserIdType("open_id"). - Body(bodyBuilder.Build()) - - if ps := intArg(args, "page_size"); ps > 0 { - reqBuilder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - reqBuilder.PageToken(pt) - } - - resp, err := t.client.Lark().Bitable.AppTableRecord.Search(ctx, reqBuilder.Build(), opts...) - if err != nil { - return fmt.Errorf("list records: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list records: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = paginatedResultMap("records", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - if resp.Data.Total != nil { - result["total"] = *resp.Data.Total - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable list_records: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) createRecord(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - fields := mapArg(args, "fields") - if appToken == "" || tableID == "" { - return "", fmt.Errorf("feishu_bitable create_record: app_token and table_id are required") - } - if len(fields) == 0 { - return "", fmt.Errorf("feishu_bitable create_record: fields is required and cannot be empty") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTableRecord.Create(ctx, - larkbitable.NewCreateAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - UserIdType("open_id"). - AppTableRecord(larkbitable.NewAppTableRecordBuilder().Fields(fields).Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create record: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create record: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"record": resp.Data.Record} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable create_record: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) updateRecord(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - recordID := stringArg(args, "record_id") - fields := mapArg(args, "fields") - if appToken == "" || tableID == "" || recordID == "" { - return "", fmt.Errorf("feishu_bitable update_record: app_token, table_id, and record_id are required") - } - if len(fields) == 0 { - return "", fmt.Errorf("feishu_bitable update_record: fields is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTableRecord.Update(ctx, - larkbitable.NewUpdateAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - RecordId(recordID). - UserIdType("open_id"). - AppTableRecord(larkbitable.NewAppTableRecordBuilder().Fields(fields).Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("update record: %w", err) - } - if !resp.Success() { - return fmt.Errorf("update record: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"record": resp.Data.Record} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable update_record: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) deleteRecord(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - recordID := stringArg(args, "record_id") - if appToken == "" || tableID == "" || recordID == "" { - return "", fmt.Errorf("feishu_bitable delete_record: app_token, table_id, and record_id are required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTableRecord.Delete(ctx, - larkbitable.NewDeleteAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - RecordId(recordID). - Build(), - opts...) - if err != nil { - return fmt.Errorf("delete record: %w", err) - } - if !resp.Success() { - return fmt.Errorf("delete record: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable delete_record: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true}) -} - -func (t *BitableTool) batchCreateRecords(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - if appToken == "" || tableID == "" { - return "", fmt.Errorf("feishu_bitable batch_create_records: app_token and table_id are required") - } - records := sliceArg(args, "records") - if len(records) == 0 { - return "", fmt.Errorf("feishu_bitable batch_create_records: records is required") - } - if len(records) > maxBatchSize { - return "", fmt.Errorf("feishu_bitable batch_create_records: max %d records per batch, got %d", maxBatchSize, len(records)) - } - - tableRecords := buildBitableRecords(records) - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTableRecord.BatchCreate(ctx, - larkbitable.NewBatchCreateAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - UserIdType("open_id"). - Body(larkbitable.NewBatchCreateAppTableRecordReqBodyBuilder(). - Records(tableRecords). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("batch create records: %w", err) - } - if !resp.Success() { - return fmt.Errorf("batch create records: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"records": resp.Data.Records} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable batch_create_records: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) batchUpdateRecords(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - if appToken == "" || tableID == "" { - return "", fmt.Errorf("feishu_bitable batch_update_records: app_token and table_id are required") - } - records := sliceArg(args, "records") - if len(records) == 0 { - return "", fmt.Errorf("feishu_bitable batch_update_records: records is required") - } - if len(records) > maxBatchSize { - return "", fmt.Errorf("feishu_bitable batch_update_records: max %d records per batch, got %d", maxBatchSize, len(records)) - } - - tableRecords := buildBitableRecordsWithID(records) - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTableRecord.BatchUpdate(ctx, - larkbitable.NewBatchUpdateAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - UserIdType("open_id"). - Body(larkbitable.NewBatchUpdateAppTableRecordReqBodyBuilder(). - Records(tableRecords). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("batch update records: %w", err) - } - if !resp.Success() { - return fmt.Errorf("batch update records: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"records": resp.Data.Records} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable batch_update_records: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) batchDeleteRecords(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - if appToken == "" || tableID == "" { - return "", fmt.Errorf("feishu_bitable batch_delete_records: app_token and table_id are required") - } - recordIDs := toStringSlice(args, "record_ids") - if len(recordIDs) == 0 { - return "", fmt.Errorf("feishu_bitable batch_delete_records: record_ids is required") - } - if len(recordIDs) > maxBatchSize { - return "", fmt.Errorf("feishu_bitable batch_delete_records: max %d records per batch, got %d", maxBatchSize, len(recordIDs)) - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Bitable.AppTableRecord.BatchDelete(ctx, - larkbitable.NewBatchDeleteAppTableRecordReqBuilder(). - AppToken(appToken). - TableId(tableID). - Body(larkbitable.NewBatchDeleteAppTableRecordReqBodyBuilder(). - Records(recordIDs). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("batch delete records: %w", err) - } - if !resp.Success() { - return fmt.Errorf("batch delete records: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable batch_delete_records: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true}) -} - -func (t *BitableTool) listFields(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - if appToken == "" || tableID == "" { - return "", fmt.Errorf("feishu_bitable list_fields: app_token and table_id are required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkbitable.NewListAppTableFieldReqBuilder(). - AppToken(appToken). - TableId(tableID) - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Bitable.AppTableField.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list fields: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list fields: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = paginatedResultMap("fields", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable list_fields: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *BitableTool) createField(ctx context.Context, args map[string]any) (string, error) { - appToken := stringArg(args, "app_token") - tableID := stringArg(args, "table_id") - name := stringArg(args, "name") - fieldType := intArg(args, "field_type") - if appToken == "" || tableID == "" || name == "" || fieldType == 0 { - return "", fmt.Errorf("feishu_bitable create_field: app_token, table_id, name, and field_type are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - fieldBuilder := larkbitable.NewAppTableFieldBuilder(). - FieldName(name). - Type(fieldType) - - // Pass through field_property if provided (type-specific config). - if prop := mapArg(args, "field_property"); prop != nil { - propField := &larkbitable.AppTableFieldProperty{} - // Serialize then deserialize to populate the struct dynamically. - propBytes, _ := json.Marshal(prop) - _ = json.Unmarshal(propBytes, propField) - fieldBuilder.Property(propField) - } - - resp, err := t.client.Lark().Bitable.AppTableField.Create(ctx, - larkbitable.NewCreateAppTableFieldReqBuilder(). - AppToken(appToken). - TableId(tableID). - AppTableField(fieldBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create field: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create field: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"field": resp.Data.Field} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_bitable create_field: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -// buildBitableFilter converts a filter map to a FilterInfo struct. -func buildBitableFilter(filter map[string]any) *larkbitable.FilterInfo { - conjunction, _ := filter["conjunction"].(string) - if conjunction == "" { - conjunction = "and" - } - - builder := larkbitable.NewFilterInfoBuilder().Conjunction(conjunction) - - if conds, ok := filter["conditions"].([]any); ok { - var conditions []*larkbitable.Condition - for _, c := range conds { - if cm, ok := c.(map[string]any); ok { - cond := buildBitableCondition(cm) - if cond != nil { - conditions = append(conditions, cond) - } - } - } - builder.Conditions(conditions) - } - - return builder.Build() -} - -func buildBitableCondition(m map[string]any) *larkbitable.Condition { - fieldName, _ := m["field_name"].(string) - operator, _ := m["operator"].(string) - if fieldName == "" || operator == "" { - return nil - } - - builder := larkbitable.NewConditionBuilder(). - FieldName(fieldName). - Operator(operator) - - // isEmpty/isNotEmpty need value=[] even without actual values. - vals := toStringSlice(m, "value") - if vals == nil && (operator == "isEmpty" || operator == "isNotEmpty") { - vals = []string{} - } - if vals != nil { - builder.Value(vals) - } - - return builder.Build() -} - -func buildBitableSort(raw []any) []*larkbitable.Sort { - var sorts []*larkbitable.Sort - for _, item := range raw { - if m, ok := item.(map[string]any); ok { - fn, _ := m["field_name"].(string) - desc, _ := m["desc"].(bool) - if fn != "" { - sorts = append(sorts, larkbitable.NewSortBuilder(). - FieldName(fn).Desc(desc).Build()) - } - } - } - return sorts -} - -func buildBitableRecords(raw []any) []*larkbitable.AppTableRecord { - var records []*larkbitable.AppTableRecord - for _, item := range raw { - if m, ok := item.(map[string]any); ok { - fields, _ := m["fields"].(map[string]any) - if len(fields) > 0 { - records = append(records, larkbitable.NewAppTableRecordBuilder(). - Fields(fields).Build()) - } - } - } - return records -} - -func buildBitableRecordsWithID(raw []any) []*larkbitable.AppTableRecord { - var records []*larkbitable.AppTableRecord - for _, item := range raw { - if m, ok := item.(map[string]any); ok { - recID, _ := m["record_id"].(string) - fields, _ := m["fields"].(map[string]any) - if recID != "" && len(fields) > 0 { - records = append(records, larkbitable.NewAppTableRecordBuilder(). - RecordId(recID).Fields(fields).Build()) - } - } - } - return records -} diff --git a/internal/feishutool/bitable_test.go b/internal/feishutool/bitable_test.go deleted file mode 100644 index 0862c193..00000000 --- a/internal/feishutool/bitable_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestBitableToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - def := tool.Definition() - if def.Name != "feishu_bitable" { - t.Fatalf("expected name feishu_bitable, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "create_app": true, - "list_tables": true, - "create_table": true, - "list_records": true, - "create_record": true, - "update_record": true, - "delete_record": true, - "batch_create_records": true, - "batch_update_records": true, - "batch_delete_records": true, - "list_fields": true, - "create_field": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestBitableToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestBitableToolCreateAppMissingName(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_app", - }) - if err == nil { - t.Fatal("expected error for missing name") - } -} - -func TestBitableToolListTablesMissingToken(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "list_tables", - }) - if err == nil { - t.Fatal("expected error for missing app_token") - } -} - -func TestBitableToolCreateTableMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_table", - "app_token": "tok_123", - }) - if err == nil { - t.Fatal("expected error for missing name") - } -} - -func TestBitableToolCreateRecordMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - // Missing fields. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_record", - "app_token": "tok_123", - "table_id": "tbl_123", - }) - if err == nil { - t.Fatal("expected error for missing fields") - } -} - -func TestBitableToolUpdateRecordMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "update_record", - "app_token": "tok_123", - "table_id": "tbl_123", - }) - if err == nil { - t.Fatal("expected error for missing record_id") - } -} - -func TestBitableToolDeleteRecordMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "delete_record", - "app_token": "tok_123", - "table_id": "tbl_123", - }) - if err == nil { - t.Fatal("expected error for missing record_id") - } -} - -func TestBitableToolBatchCreateRecordsValidation(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - // Missing records. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "batch_create_records", - "app_token": "tok_123", - "table_id": "tbl_123", - }) - if err == nil { - t.Fatal("expected error for missing records") - } - - // Over batch limit. - bigRecords := make([]any, 501) - for i := range bigRecords { - bigRecords[i] = map[string]any{"fields": map[string]any{"name": "x"}} - } - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "batch_create_records", - "app_token": "tok_123", - "table_id": "tbl_123", - "records": bigRecords, - }) - if err == nil { - t.Fatal("expected error for exceeding batch limit") - } -} - -func TestBitableToolBatchDeleteRecordsValidation(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "batch_delete_records", - "app_token": "tok_123", - "table_id": "tbl_123", - }) - if err == nil { - t.Fatal("expected error for missing record_ids") - } -} - -func TestBitableToolListFieldsMissingToken(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "list_fields", - }) - if err == nil { - t.Fatal("expected error for missing app_token/table_id") - } -} - -func TestBitableToolCreateFieldMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewBitableTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_field", - "app_token": "tok_123", - "table_id": "tbl_123", - "name": "Col1", - }) - if err == nil { - t.Fatal("expected error for missing field_type") - } -} - -func TestBuildBitableFilter(t *testing.T) { - filter := map[string]any{ - "conjunction": "and", - "conditions": []any{ - map[string]any{ - "field_name": "Status", - "operator": "is", - "value": []any{"Done"}, - }, - map[string]any{ - "field_name": "Name", - "operator": "isEmpty", - // value intentionally omitted — should auto-fill. - }, - }, - } - - result := buildBitableFilter(filter) - if result == nil { - t.Fatal("expected non-nil filter") - } - if result.Conjunction == nil || *result.Conjunction != "and" { - t.Fatal("expected conjunction 'and'") - } - if len(result.Conditions) != 2 { - t.Fatalf("expected 2 conditions, got %d", len(result.Conditions)) - } - // isEmpty condition should have value=[] (not nil). - cond := result.Conditions[1] - if cond.Value == nil { - t.Fatal("isEmpty condition should have non-nil value") - } -} - -func TestBuildBitableSort(t *testing.T) { - raw := []any{ - map[string]any{"field_name": "Created", "desc": true}, - map[string]any{"field_name": "Name", "desc": false}, - map[string]any{"bad": "entry"}, - } - - sorts := buildBitableSort(raw) - if len(sorts) != 2 { - t.Fatalf("expected 2 sorts, got %d", len(sorts)) - } -} - -func TestBuildBitableRecords(t *testing.T) { - raw := []any{ - map[string]any{"fields": map[string]any{"Name": "A"}}, - map[string]any{"fields": map[string]any{"Name": "B"}}, - map[string]any{}, // no fields, skipped - } - - records := buildBitableRecords(raw) - if len(records) != 2 { - t.Fatalf("expected 2 records, got %d", len(records)) - } -} - -func TestBuildBitableRecordsWithID(t *testing.T) { - raw := []any{ - map[string]any{"record_id": "rec1", "fields": map[string]any{"Name": "A"}}, - map[string]any{"fields": map[string]any{"Name": "B"}}, // no ID, skipped - } - - records := buildBitableRecordsWithID(raw) - if len(records) != 1 { - t.Fatalf("expected 1 record with ID, got %d", len(records)) - } -} diff --git a/internal/feishutool/calendar.go b/internal/feishutool/calendar.go deleted file mode 100644 index ea3490f4..00000000 --- a/internal/feishutool/calendar.go +++ /dev/null @@ -1,644 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - "strconv" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkcalendar "github.com/larksuite/oapi-sdk-go/v3/service/calendar/v4" - "github.com/vaayne/anna/internal/toolspec" -) - -var calendarInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create_event", "list_events", "get_event", "update_event", "delete_event", "add_attendees", "freebusy"], - "description": "The action to perform" - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID. If omitted, the bot's primary calendar is used." - }, - "event_id": { - "type": "string", - "description": "Event ID (required for get/update/delete/add_attendees)" - }, - "summary": { - "type": "string", - "description": "Event title (required for create_event)" - }, - "description": { - "type": "string", - "description": "Event description" - }, - "start_time": { - "type": "string", - "description": "Start time in ISO 8601 format, e.g. '2024-01-01T09:00:00+08:00' (required for create_event, list_events, freebusy)" - }, - "end_time": { - "type": "string", - "description": "End time in ISO 8601 format (required for create_event, list_events, freebusy)" - }, - "visibility": { - "type": "string", - "enum": ["default", "public", "private"], - "description": "Event visibility (default: 'default')" - }, - "free_busy_status": { - "type": "string", - "enum": ["busy", "free"], - "description": "Busy/free status (default: 'busy')" - }, - "attendee_ability": { - "type": "string", - "enum": ["none", "can_see_others", "can_invite_others", "can_modify_event"], - "description": "Attendee permission level" - }, - "location": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "address": {"type": "string"} - }, - "description": "Event location" - }, - "reminders": { - "type": "array", - "items": {"type": "object", "properties": {"minutes": {"type": "number"}}}, - "description": "Reminder list. minutes > 0 = before event, minutes < 0 = after start" - }, - "recurrence": { - "type": "string", - "description": "RRULE recurrence rule (RFC 5545), e.g. 'FREQ=DAILY;INTERVAL=1'" - }, - "attendees": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": {"type": "string", "enum": ["user", "chat", "resource", "third_party"]}, - "id": {"type": "string", "description": "open_id, chat_id, resource_id, or email"} - } - }, - "description": "Attendees to add. type='user' uses open_id, type='third_party' uses email." - }, - "user_open_id": { - "type": "string", - "description": "Current user's open_id (from context). For create_event, automatically added as attendee so the event appears in user's calendar." - }, - "user_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "User open_ids to check availability (for freebusy action)" - }, - "need_notification": { - "type": "boolean", - "description": "Whether to notify attendees on delete (default: true)" - }, - "page_size": { - "type": "number", - "description": "Page size for list operations" - }, - "page_token": { - "type": "string", - "description": "Pagination token for list operations" - } - }, - "required": ["action"] -}`) - -// CalendarTool provides Feishu calendar event management. -type CalendarTool struct { - client *Client -} - -// NewCalendarTool creates a feishu_calendar tool. -func NewCalendarTool(client *Client) *CalendarTool { - return &CalendarTool{client: client} -} - -func (t *CalendarTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_calendar", - Description: `Manage Feishu/Lark calendar events. Uses user token when available. - -Actions: -- create_event: Create a calendar event. Requires summary, start_time, end_time. Pass user_open_id to add the user as attendee (otherwise the event only appears on the bot's calendar). -- list_events: List events in a time range (uses instance_view, auto-expands recurring events). Requires start_time, end_time. Time range must be < 40 days. -- get_event: Get event details by event_id. -- update_event: Update event fields (summary, description, start_time, end_time, location). Requires event_id. -- delete_event: Delete an event by event_id. Set need_notification=false to suppress notifications. -- add_attendees: Add attendees to an existing event. Requires event_id and attendees array. -- freebusy: Check user availability. Requires start_time, end_time, and user_ids array. - -Time format: ISO 8601 with timezone, e.g. '2024-01-01T09:00:00+08:00'. Timestamps use Unix seconds internally.`, - InputSchema: calendarInputSchema, - } -} - -func (t *CalendarTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "create_event": - return t.createEvent(ctx, args) - case "list_events": - return t.listEvents(ctx, args) - case "get_event": - return t.getEvent(ctx, args) - case "update_event": - return t.updateEvent(ctx, args) - case "delete_event": - return t.deleteEvent(ctx, args) - case "add_attendees": - return t.addAttendees(ctx, args) - case "freebusy": - return t.freeBusy(ctx, args) - default: - return "", fmt.Errorf("feishu_calendar: unknown action %q", action) - } -} - -func (t *CalendarTool) resolveCalendarID(ctx context.Context, args map[string]any, opts ...larkcore.RequestOptionFunc) (string, error) { - if id := stringArg(args, "calendar_id"); id != "" { - return id, nil - } - // Resolve primary calendar. - resp, err := t.client.Lark().Calendar.Calendar.Primary(ctx, - larkcalendar.NewPrimaryCalendarReqBuilder().Build(), - opts...) - if err != nil { - return "", fmt.Errorf("resolve primary calendar: %w", err) - } - if !resp.Success() { - return "", fmt.Errorf("resolve primary calendar: %s", FormatLarkError(resp.Code, resp.Msg)) - } - if resp.Data != nil && resp.Data.Calendars != nil { - for _, c := range resp.Data.Calendars { - if c.Calendar != nil && c.Calendar.CalendarId != nil { - return *c.Calendar.CalendarId, nil - } - } - } - return "", fmt.Errorf("could not determine primary calendar") -} - -func (t *CalendarTool) createEvent(ctx context.Context, args map[string]any) (string, error) { - summary := stringArg(args, "summary") - if summary == "" { - return "", fmt.Errorf("feishu_calendar create_event: summary is required") - } - startStr := stringArg(args, "start_time") - endStr := stringArg(args, "end_time") - if startStr == "" || endStr == "" { - return "", fmt.Errorf("feishu_calendar create_event: start_time and end_time are required") - } - startUnix, err := ParseTimeToUnix(startStr) - if err != nil { - return "", fmt.Errorf("feishu_calendar create_event: invalid start_time: %w", err) - } - endUnix, err := ParseTimeToUnix(endStr) - if err != nil { - return "", fmt.Errorf("feishu_calendar create_event: invalid end_time: %w", err) - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - calID, err := t.resolveCalendarID(ctx, args, opts...) - if err != nil { - return err - } - - event := buildCalendarEvent(args, summary, startUnix, endUnix) - resp, err := t.client.Lark().Calendar.CalendarEvent.Create(ctx, - larkcalendar.NewCreateCalendarEventReqBuilder(). - CalendarId(calID). - CalendarEvent(event). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create event: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create event: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = map[string]any{"event": resp.Data.Event} - - // Add attendees (including user_open_id from args or context). - if resp.Data.Event != nil && resp.Data.Event.EventId != nil { - eventID := *resp.Data.Event.EventId - attendees := buildAttendeeList(ctx, args) - if len(attendees) > 0 { - attErr := t.addAttendeesToEvent(ctx, calID, eventID, attendees, opts...) - if attErr != nil { - result["attendee_warning"] = attErr.Error() - } else { - result["attendees_added"] = len(attendees) - } - } - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar create_event: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *CalendarTool) listEvents(ctx context.Context, args map[string]any) (string, error) { - startStr := stringArg(args, "start_time") - endStr := stringArg(args, "end_time") - if startStr == "" || endStr == "" { - return "", fmt.Errorf("feishu_calendar list_events: start_time and end_time are required") - } - startUnix, err := ParseTimeToUnix(startStr) - if err != nil { - return "", fmt.Errorf("feishu_calendar list_events: invalid start_time: %w", err) - } - endUnix, err := ParseTimeToUnix(endStr) - if err != nil { - return "", fmt.Errorf("feishu_calendar list_events: invalid end_time: %w", err) - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - calID, err := t.resolveCalendarID(ctx, args, opts...) - if err != nil { - return err - } - - resp, err := t.client.Lark().Calendar.CalendarEvent.InstanceView(ctx, - larkcalendar.NewInstanceViewCalendarEventReqBuilder(). - CalendarId(calID). - StartTime(strconv.FormatInt(startUnix, 10)). - EndTime(strconv.FormatInt(endUnix, 10)). - UserIdType("open_id"). - Build(), - opts...) - if err != nil { - return fmt.Errorf("list events: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list events: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = map[string]any{ - "events": resp.Data.Items, - "has_more": false, - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar list_events: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *CalendarTool) getEvent(ctx context.Context, args map[string]any) (string, error) { - eventID := stringArg(args, "event_id") - if eventID == "" { - return "", fmt.Errorf("feishu_calendar get_event: event_id is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - calID, err := t.resolveCalendarID(ctx, args, opts...) - if err != nil { - return err - } - - resp, err := t.client.Lark().Calendar.CalendarEvent.Get(ctx, - larkcalendar.NewGetCalendarEventReqBuilder(). - CalendarId(calID). - EventId(eventID). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get event: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get event: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"event": resp.Data.Event} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar get_event: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *CalendarTool) updateEvent(ctx context.Context, args map[string]any) (string, error) { - eventID := stringArg(args, "event_id") - if eventID == "" { - return "", fmt.Errorf("feishu_calendar update_event: event_id is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - calID, err := t.resolveCalendarID(ctx, args, opts...) - if err != nil { - return err - } - - event := buildCalendarEventPatch(args) - resp, err := t.client.Lark().Calendar.CalendarEvent.Patch(ctx, - larkcalendar.NewPatchCalendarEventReqBuilder(). - CalendarId(calID). - EventId(eventID). - CalendarEvent(event). - Build(), - opts...) - if err != nil { - return fmt.Errorf("update event: %w", err) - } - if !resp.Success() { - return fmt.Errorf("update event: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"event": resp.Data.Event} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar update_event: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *CalendarTool) deleteEvent(ctx context.Context, args map[string]any) (string, error) { - eventID := stringArg(args, "event_id") - if eventID == "" { - return "", fmt.Errorf("feishu_calendar delete_event: event_id is required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - calID, err := t.resolveCalendarID(ctx, args, opts...) - if err != nil { - return err - } - - builder := larkcalendar.NewDeleteCalendarEventReqBuilder(). - CalendarId(calID). - EventId(eventID) - - if v, ok := boolArg(args, "need_notification"); ok { - if v { - builder.NeedNotification("true") - } else { - builder.NeedNotification("false") - } - } - - resp, err := t.client.Lark().Calendar.CalendarEvent.Delete(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("delete event: %w", err) - } - if !resp.Success() { - return fmt.Errorf("delete event: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar delete_event: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true, "event_id": eventID}) -} - -func (t *CalendarTool) addAttendees(ctx context.Context, args map[string]any) (string, error) { - eventID := stringArg(args, "event_id") - if eventID == "" { - return "", fmt.Errorf("feishu_calendar add_attendees: event_id is required") - } - attendees := buildAttendeeList(ctx, args) - if len(attendees) == 0 { - return "", fmt.Errorf("feishu_calendar add_attendees: attendees is required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - calID, err := t.resolveCalendarID(ctx, args, opts...) - if err != nil { - return err - } - return t.addAttendeesToEvent(ctx, calID, eventID, attendees, opts...) - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar add_attendees: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true, "attendees_added": len(attendees)}) -} - -func (t *CalendarTool) freeBusy(ctx context.Context, args map[string]any) (string, error) { - startStr := stringArg(args, "start_time") - endStr := stringArg(args, "end_time") - if startStr == "" || endStr == "" { - return "", fmt.Errorf("feishu_calendar freebusy: start_time and end_time are required") - } - startUnix, err := ParseTimeToUnix(startStr) - if err != nil { - return "", fmt.Errorf("feishu_calendar freebusy: invalid start_time: %w", err) - } - endUnix, err := ParseTimeToUnix(endStr) - if err != nil { - return "", fmt.Errorf("feishu_calendar freebusy: invalid end_time: %w", err) - } - userIDs := toStringSlice(args, "user_ids") - if len(userIDs) == 0 { - return "", fmt.Errorf("feishu_calendar freebusy: user_ids is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - body := larkcalendar.NewBatchFreebusyReqBodyBuilder(). - TimeMin(strconv.FormatInt(startUnix, 10)). - TimeMax(strconv.FormatInt(endUnix, 10)). - UserIds(userIDs). - Build() - - resp, err := t.client.Lark().Calendar.Freebusy.Batch(ctx, - larkcalendar.NewBatchFreebusyReqBuilder(). - UserIdType("open_id"). - Body(body). - Build(), - opts...) - if err != nil { - return fmt.Errorf("freebusy: %w", err) - } - if !resp.Success() { - return fmt.Errorf("freebusy: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = resp.Data - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_calendar freebusy: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -// addAttendeesToEvent adds attendees to an existing calendar event. -func (t *CalendarTool) addAttendeesToEvent(ctx context.Context, calID, eventID string, attendees []*larkcalendar.CalendarEventAttendee, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Calendar.CalendarEventAttendee.Create(ctx, - larkcalendar.NewCreateCalendarEventAttendeeReqBuilder(). - CalendarId(calID). - EventId(eventID). - UserIdType("open_id"). - Body(larkcalendar.NewCreateCalendarEventAttendeeReqBodyBuilder(). - Attendees(attendees). - NeedNotification(true). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("add attendees: %w", err) - } - if !resp.Success() { - return fmt.Errorf("add attendees: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil -} - -// buildCalendarEvent constructs a CalendarEvent for creation. -func buildCalendarEvent(args map[string]any, summary string, startUnix, endUnix int64) *larkcalendar.CalendarEvent { - startTs := strconv.FormatInt(startUnix, 10) - endTs := strconv.FormatInt(endUnix, 10) - - builder := larkcalendar.NewCalendarEventBuilder(). - Summary(summary). - StartTime(larkcalendar.NewTimeInfoBuilder().Timestamp(startTs).Build()). - EndTime(larkcalendar.NewTimeInfoBuilder().Timestamp(endTs).Build()). - NeedNotification(true) - - if v := stringArg(args, "description"); v != "" { - builder.Description(v) - } - if v := stringArg(args, "visibility"); v != "" { - builder.Visibility(v) - } - if v := stringArg(args, "attendee_ability"); v != "" { - builder.AttendeeAbility(v) - } - if v := stringArg(args, "free_busy_status"); v != "" { - builder.FreeBusyStatus(v) - } - if v := stringArg(args, "recurrence"); v != "" { - builder.Recurrence(v) - } - if loc := mapArg(args, "location"); loc != nil { - locBuilder := larkcalendar.NewEventLocationBuilder() - if name, ok := loc["name"].(string); ok { - locBuilder.Name(name) - } - if addr, ok := loc["address"].(string); ok { - locBuilder.Address(addr) - } - builder.Location(locBuilder.Build()) - } - if rems := sliceArg(args, "reminders"); len(rems) > 0 { - var reminders []*larkcalendar.Reminder - for _, r := range rems { - if rm, ok := r.(map[string]any); ok { - if mins, ok := rm["minutes"].(float64); ok { - reminders = append(reminders, larkcalendar.NewReminderBuilder().Minutes(int(mins)).Build()) - } - } - } - if len(reminders) > 0 { - builder.Reminders(reminders) - } - } - - return builder.Build() -} - -// buildCalendarEventPatch constructs a CalendarEvent for patching (update). -func buildCalendarEventPatch(args map[string]any) *larkcalendar.CalendarEvent { - builder := larkcalendar.NewCalendarEventBuilder() - - if v := stringArg(args, "summary"); v != "" { - builder.Summary(v) - } - if v := stringArg(args, "description"); v != "" { - builder.Description(v) - } - if v := stringArg(args, "start_time"); v != "" { - if ts, err := ParseTimeToUnix(v); err == nil { - builder.StartTime(larkcalendar.NewTimeInfoBuilder().Timestamp(strconv.FormatInt(ts, 10)).Build()) - } - } - if v := stringArg(args, "end_time"); v != "" { - if ts, err := ParseTimeToUnix(v); err == nil { - builder.EndTime(larkcalendar.NewTimeInfoBuilder().Timestamp(strconv.FormatInt(ts, 10)).Build()) - } - } - if loc := mapArg(args, "location"); loc != nil { - locBuilder := larkcalendar.NewEventLocationBuilder() - if name, ok := loc["name"].(string); ok { - locBuilder.Name(name) - } - if addr, ok := loc["address"].(string); ok { - locBuilder.Address(addr) - } - builder.Location(locBuilder.Build()) - } - - return builder.Build() -} - -// buildAttendeeList constructs attendees from args and user context. -func buildAttendeeList(ctx context.Context, args map[string]any) []*larkcalendar.CalendarEventAttendee { - var attendees []*larkcalendar.CalendarEventAttendee - - if raw := sliceArg(args, "attendees"); len(raw) > 0 { - for _, item := range raw { - if m, ok := item.(map[string]any); ok { - att := buildSingleAttendee(m) - if att != nil { - attendees = append(attendees, att) - } - } - } - } - - // Add user_open_id (from args or context) as a user attendee if not already present. - userOpenID := stringArg(args, "user_open_id") - if userOpenID == "" { - userOpenID = OpenIDFromContext(ctx) - } - if userOpenID != "" { - found := false - for _, a := range attendees { - if a.UserId != nil && *a.UserId == userOpenID { - found = true - break - } - } - if !found { - attendees = append(attendees, larkcalendar.NewCalendarEventAttendeeBuilder(). - Type("user").UserId(userOpenID).Build()) - } - } - - return attendees -} - -func buildSingleAttendee(m map[string]any) *larkcalendar.CalendarEventAttendee { - attType, _ := m["type"].(string) - id, _ := m["id"].(string) - if attType == "" || id == "" { - return nil - } - builder := larkcalendar.NewCalendarEventAttendeeBuilder().Type(attType) - switch attType { - case "user": - builder.UserId(id) - case "chat": - builder.ChatId(id) - case "resource": - builder.RoomId(id) - case "third_party": - builder.ThirdPartyEmail(id) - } - return builder.Build() -} diff --git a/internal/feishutool/calendar_test.go b/internal/feishutool/calendar_test.go deleted file mode 100644 index e9bf92e8..00000000 --- a/internal/feishutool/calendar_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestCalendarToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - def := tool.Definition() - if def.Name != "feishu_calendar" { - t.Fatalf("expected name feishu_calendar, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - if _, ok := props["action"]; !ok { - t.Fatal("expected action in properties") - } - // Verify action enum has all expected values. - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "create_event": true, - "list_events": true, - "get_event": true, - "update_event": true, - "delete_event": true, - "add_attendees": true, - "freebusy": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestCalendarToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestCalendarToolCreateEventMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - // Missing summary. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_event", - "start_time": "2024-01-01T09:00:00+08:00", - "end_time": "2024-01-01T10:00:00+08:00", - }) - if err == nil { - t.Fatal("expected error for missing summary") - } - - // Missing start_time. - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "create_event", - "summary": "Test", - }) - if err == nil { - t.Fatal("expected error for missing start_time/end_time") - } -} - -func TestCalendarToolCreateEventInvalidTime(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_event", - "summary": "Test", - "start_time": "not-a-time", - "end_time": "2024-01-01T10:00:00+08:00", - }) - if err == nil { - t.Fatal("expected error for invalid time") - } -} - -func TestCalendarToolGetEventMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_event", - }) - if err == nil { - t.Fatal("expected error for missing event_id") - } -} - -func TestCalendarToolDeleteEventMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "delete_event", - }) - if err == nil { - t.Fatal("expected error for missing event_id") - } -} - -func TestCalendarToolListEventsMissingTime(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "list_events", - }) - if err == nil { - t.Fatal("expected error for missing start_time/end_time") - } -} - -func TestCalendarToolFreebusyMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - // Missing user_ids. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "freebusy", - "start_time": "2024-01-01T09:00:00+08:00", - "end_time": "2024-01-01T18:00:00+08:00", - }) - if err == nil { - t.Fatal("expected error for missing user_ids") - } -} - -func TestCalendarToolAddAttendeesMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewCalendarTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "add_attendees", - }) - if err == nil { - t.Fatal("expected error for missing event_id") - } -} - -func TestBuildAttendeeList(t *testing.T) { - ctx := WithOpenID(context.Background(), "ou_context_user") - args := map[string]any{ - "attendees": []any{ - map[string]any{"type": "user", "id": "ou_user1"}, - map[string]any{"type": "third_party", "id": "test@example.com"}, - }, - } - - attendees := buildAttendeeList(ctx, args) - // Should have 3: ou_user1, test@example.com, ou_context_user (from context). - if len(attendees) != 3 { - t.Fatalf("expected 3 attendees, got %d", len(attendees)) - } -} - -func TestBuildAttendeeListDedup(t *testing.T) { - ctx := WithOpenID(context.Background(), "ou_user1") - args := map[string]any{ - "attendees": []any{ - map[string]any{"type": "user", "id": "ou_user1"}, - }, - } - - attendees := buildAttendeeList(ctx, args) - // Should not duplicate ou_user1. - if len(attendees) != 1 { - t.Fatalf("expected 1 attendee (dedup), got %d", len(attendees)) - } -} - -func TestBuildCalendarEvent(t *testing.T) { - args := map[string]any{ - "description": "Test desc", - "visibility": "public", - "free_busy_status": "free", - "recurrence": "FREQ=DAILY;INTERVAL=1", - "location": map[string]any{"name": "Office", "address": "123 St"}, - "reminders": []any{map[string]any{"minutes": float64(15)}}, - } - - event := buildCalendarEvent(args, "Test Event", 1704067200, 1704070800) - if event == nil { - t.Fatal("expected non-nil event") - } - if event.Summary == nil || *event.Summary != "Test Event" { - t.Fatal("expected summary to be set") - } - if event.Description == nil || *event.Description != "Test desc" { - t.Fatal("expected description to be set") - } - if event.Visibility == nil || *event.Visibility != "public" { - t.Fatal("expected visibility to be public") - } -} - -func TestBuildCalendarEventPatch(t *testing.T) { - args := map[string]any{ - "summary": "Updated Title", - "start_time": "2024-01-01T09:00:00+08:00", - } - - event := buildCalendarEventPatch(args) - if event == nil { - t.Fatal("expected non-nil event") - } - if event.Summary == nil || *event.Summary != "Updated Title" { - t.Fatal("expected summary to be set") - } - if event.StartTime == nil { - t.Fatal("expected start_time to be set") - } -} diff --git a/internal/feishutool/chat.go b/internal/feishutool/chat.go deleted file mode 100644 index c6dbebf9..00000000 --- a/internal/feishutool/chat.go +++ /dev/null @@ -1,265 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - "github.com/vaayne/anna/internal/toolspec" -) - -var chatInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["search_chats", "get_chat", "list_members", "add_members", "remove_members"], - "description": "The action to perform" - }, - "chat_id": { - "type": "string", - "description": "Chat/group ID (oc_xxx format). Required for get_chat, list_members, add_members, remove_members." - }, - "query": { - "type": "string", - "description": "Search keyword for search_chats. Matches group name and member names." - }, - "member_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "User open_ids to add or remove (for add_members, remove_members)" - }, - "page_size": { - "type": "number", - "description": "Page size for list operations (default 20)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// ChatTool provides Feishu chat/group management. -type ChatTool struct { - client *Client -} - -// NewChatTool creates a feishu_chat tool. -func NewChatTool(client *Client) *ChatTool { - return &ChatTool{client: client} -} - -func (t *ChatTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_chat", - Description: `Manage Feishu/Lark chats (groups). Uses user token when available. - -Actions: -- search_chats: Search for chats visible to the user/bot. Requires query. Returns matching chats with name, description, owner info. -- get_chat: Get detailed info for a specific chat. Requires chat_id (oc_xxx format). Returns name, description, avatar, owner, permissions, member count. -- list_members: List members of a chat. Requires chat_id. Returns paginated member list with open_id and name. -- add_members: Add members to a chat. Requires chat_id and member_ids (array of open_ids). -- remove_members: Remove members from a chat. Requires chat_id and member_ids (array of open_ids).`, - InputSchema: chatInputSchema, - } -} - -func (t *ChatTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "search_chats": - return t.searchChats(ctx, args) - case "get_chat": - return t.getChat(ctx, args) - case "list_members": - return t.listMembers(ctx, args) - case "add_members": - return t.addMembers(ctx, args) - case "remove_members": - return t.removeMembers(ctx, args) - default: - return "", fmt.Errorf("feishu_chat: unknown action %q", action) - } -} - -func (t *ChatTool) searchChats(ctx context.Context, args map[string]any) (string, error) { - query := stringArg(args, "query") - if query == "" { - return "", fmt.Errorf("feishu_chat search_chats: query is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkim.NewSearchChatReqBuilder(). - UserIdType("open_id"). - Query(query) - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Im.Chat.Search(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("search chats: %w", err) - } - if !resp.Success() { - return fmt.Errorf("search chats: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("chats", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_chat search_chats: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *ChatTool) getChat(ctx context.Context, args map[string]any) (string, error) { - chatID := stringArg(args, "chat_id") - if chatID == "" { - chatID = ChatIDFromContext(ctx) - } - if chatID == "" { - return "", fmt.Errorf("feishu_chat get_chat: chat_id is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.Chat.Get(ctx, - larkim.NewGetChatReqBuilder(). - ChatId(chatID). - UserIdType("open_id"). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get chat: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get chat: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"chat": resp.Data} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_chat get_chat: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *ChatTool) listMembers(ctx context.Context, args map[string]any) (string, error) { - chatID := stringArg(args, "chat_id") - if chatID == "" { - chatID = ChatIDFromContext(ctx) - } - if chatID == "" { - return "", fmt.Errorf("feishu_chat list_members: chat_id is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkim.NewGetChatMembersReqBuilder(). - ChatId(chatID). - MemberIdType("open_id") - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Im.ChatMembers.Get(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list members: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list members: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("members", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_chat list_members: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *ChatTool) addMembers(ctx context.Context, args map[string]any) (string, error) { - chatID := stringArg(args, "chat_id") - if chatID == "" { - chatID = ChatIDFromContext(ctx) - } - if chatID == "" { - return "", fmt.Errorf("feishu_chat add_members: chat_id is required") - } - memberIDs := toStringSlice(args, "member_ids") - if len(memberIDs) == 0 { - return "", fmt.Errorf("feishu_chat add_members: member_ids is required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.ChatMembers.Create(ctx, - larkim.NewCreateChatMembersReqBuilder(). - ChatId(chatID). - MemberIdType("open_id"). - Body(larkim.NewCreateChatMembersReqBodyBuilder(). - IdList(memberIDs). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("add members: %w", err) - } - if !resp.Success() { - return fmt.Errorf("add members: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_chat add_members: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true, "members_added": len(memberIDs)}) -} - -func (t *ChatTool) removeMembers(ctx context.Context, args map[string]any) (string, error) { - chatID := stringArg(args, "chat_id") - if chatID == "" { - chatID = ChatIDFromContext(ctx) - } - if chatID == "" { - return "", fmt.Errorf("feishu_chat remove_members: chat_id is required") - } - memberIDs := toStringSlice(args, "member_ids") - if len(memberIDs) == 0 { - return "", fmt.Errorf("feishu_chat remove_members: member_ids is required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.ChatMembers.Delete(ctx, - larkim.NewDeleteChatMembersReqBuilder(). - ChatId(chatID). - MemberIdType("open_id"). - Body(larkim.NewDeleteChatMembersReqBodyBuilder(). - IdList(memberIDs). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("remove members: %w", err) - } - if !resp.Success() { - return fmt.Errorf("remove members: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_chat remove_members: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true, "members_removed": len(memberIDs)}) -} diff --git a/internal/feishutool/chat_test.go b/internal/feishutool/chat_test.go deleted file mode 100644 index c659421f..00000000 --- a/internal/feishutool/chat_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestChatToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewChatTool(client) - - def := tool.Definition() - if def.Name != "feishu_chat" { - t.Fatalf("expected name feishu_chat, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "search_chats": true, - "get_chat": true, - "list_members": true, - "add_members": true, - "remove_members": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestChatToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewChatTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestChatToolSearchMissingQuery(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewChatTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "search_chats", - }) - if err == nil { - t.Fatal("expected error for missing query") - } -} - -func TestChatToolGetChatMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewChatTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_chat", - }) - if err == nil { - t.Fatal("expected error for missing chat_id") - } -} - -func TestChatToolAddMembersMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewChatTool(client) - - // Missing chat_id. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "add_members", - "member_ids": []any{"ou_test"}, - }) - if err == nil { - t.Fatal("expected error for missing chat_id") - } - - // Missing member_ids. - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "add_members", - "chat_id": "oc_test", - }) - if err == nil { - t.Fatal("expected error for missing member_ids") - } -} - -func TestChatToolRemoveMembersMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewChatTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "remove_members", - "chat_id": "oc_test", - }) - if err == nil { - t.Fatal("expected error for missing member_ids") - } -} diff --git a/internal/feishutool/client.go b/internal/feishutool/client.go deleted file mode 100644 index cbadc337..00000000 --- a/internal/feishutool/client.go +++ /dev/null @@ -1,357 +0,0 @@ -package feishutool - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strings" - "time" - - lark "github.com/larksuite/oapi-sdk-go/v3" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - "golang.org/x/time/rate" -) - -const defaultRateLimit = 50 // requests per second - -// NeedAuthError is returned when a user access token is required but not available. -// The channel handler should catch this and prompt the user to authorize. -type NeedAuthError struct { - OpenID string -} - -func (e *NeedAuthError) Error() string { - return fmt.Sprintf("feishutool: user %s needs to authorize via OAuth", e.OpenID) -} - -// Client wraps a Lark SDK client with rate limiting and UAT support. -type Client struct { - lark *lark.Client - limiter *rate.Limiter - tokenStore TokenStore - appID string - appSecret string -} - -// ClientOption configures the Client. -type ClientOption func(*Client) - -// WithRateLimit sets the per-second rate limit for API calls. -func WithRateLimit(rps int) ClientOption { - return func(c *Client) { - c.limiter = rate.NewLimiter(rate.Limit(rps), rps) - } -} - -// WithTokenStore sets the token store for UAT resolution. -func WithTokenStore(ts TokenStore) ClientOption { - return func(c *Client) { - c.tokenStore = ts - } -} - -// NewClient creates a feishutool Client wrapping the given Lark SDK client. -func NewClient(larkClient *lark.Client, opts ...ClientOption) *Client { - c := &Client{ - lark: larkClient, - limiter: rate.NewLimiter(rate.Limit(defaultRateLimit), defaultRateLimit), - } - for _, opt := range opts { - opt(c) - } - return c -} - -// SetAppCredentials stores the app credentials needed for token refresh. -func (c *Client) SetAppCredentials(appID, appSecret string) { - c.appID = appID - c.appSecret = appSecret -} - -// Lark returns the underlying Lark SDK client for direct API access. -func (c *Client) Lark() *lark.Client { - return c.lark -} - -// TokenStore returns the configured token store, or nil if none is set. -func (c *Client) TokenStore() TokenStore { - return c.tokenStore -} - -// Wait blocks until the rate limiter allows one request. -// Returns an error if the context is cancelled while waiting. -func (c *Client) Wait(ctx context.Context) error { - if err := c.limiter.Wait(ctx); err != nil { - return fmt.Errorf("feishutool: rate limit wait: %w", err) - } - return nil -} - -// InvokeAsUser resolves the stored UAT for the user identified by open_id -// in the context, auto-refreshes if expired, and calls fn with the user -// access token. Falls back to bot token if no UAT is stored. -// -// If requireAuth is true and no token exists, returns NeedAuthError instead -// of falling back to bot token. -func (c *Client) InvokeAsUser(ctx context.Context, requireAuth bool, fn func(ctx context.Context, token string) error) error { - if err := c.Wait(ctx); err != nil { - return err - } - - openID := OpenIDFromContext(ctx) - if openID == "" || c.tokenStore == nil { - if requireAuth { - return &NeedAuthError{OpenID: openID} - } - // No UAT possible — fall back to bot token (caller uses c.Lark() directly). - return fn(ctx, "") - } - - token, err := c.tokenStore.Get(ctx, openID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - // No stored token — fall back to bot token or require auth. - if requireAuth { - return &NeedAuthError{OpenID: openID} - } - return fn(ctx, "") - } - // Actual error (DB failure, decryption error) — surface it - // instead of silently falling back to bot token. - return fmt.Errorf("feishutool: get token for %s: %w", openID, err) - } - - // Auto-refresh if access token expired but refresh token is still valid. - if token.IsExpired() { - if token.IsRefreshExpired() { - // Both tokens expired — need re-authorization. - _ = c.tokenStore.Delete(ctx, openID) - if requireAuth { - return &NeedAuthError{OpenID: openID} - } - return fn(ctx, "") - } - - refreshed, err := c.refreshToken(ctx, token.RefreshToken) - if err != nil { - slog.Warn("feishutool: token refresh failed, falling back", - "open_id", openID, "error", err) - if requireAuth { - return &NeedAuthError{OpenID: openID} - } - return fn(ctx, "") - } - - if err := c.tokenStore.Set(ctx, openID, refreshed); err != nil { - slog.Warn("feishutool: failed to store refreshed token", - "open_id", openID, "error", err) - } - token = refreshed - } - - return fn(ctx, token.AccessToken) -} - -// refreshToken exchanges a refresh token for a new token pair via the Feishu -// OIDC refresh endpoint. -func (c *Client) refreshToken(ctx context.Context, refreshToken string) (Token, error) { - appToken, err := c.getAppAccessToken(ctx) - if err != nil { - return Token{}, fmt.Errorf("get app access token: %w", err) - } - - bodyMap := map[string]string{ - "grant_type": "refresh_token", - "refresh_token": refreshToken, - } - bodyBytes, _ := json.Marshal(bodyMap) - body := string(bodyBytes) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token", - strings.NewReader(body)) - if err != nil { - return Token{}, err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("Authorization", "Bearer "+appToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return Token{}, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return Token{}, fmt.Errorf("refresh token: HTTP %d", resp.StatusCode) - } - return parseOIDCTokenResponse(resp.Body) -} - -// getAppAccessToken retrieves the app access token (tenant token) via the -// internal Lark SDK API. This is needed for OIDC token operations. -func (c *Client) getAppAccessToken(ctx context.Context) (string, error) { - bodyMap := map[string]string{ - "app_id": c.appID, - "app_secret": c.appSecret, - } - bodyBytes, _ := json.Marshal(bodyMap) - body := string(bodyBytes) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal", - strings.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("app access token: HTTP %d", resp.StatusCode) - } - - var result struct { - Code int `json:"code"` - Msg string `json:"msg"` - AppAccessToken string `json:"app_access_token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("decode response: %w", err) - } - if result.Code != 0 { - return "", fmt.Errorf("app token error (code=%d): %s", result.Code, result.Msg) - } - return result.AppAccessToken, nil -} - -// ExchangeCode exchanges an authorization code for user access tokens via -// the Feishu OIDC token endpoint. -func (c *Client) ExchangeCode(ctx context.Context, code string) (Token, error) { - appToken, err := c.getAppAccessToken(ctx) - if err != nil { - return Token{}, fmt.Errorf("get app access token: %w", err) - } - - bodyMap := map[string]string{ - "grant_type": "authorization_code", - "code": code, - } - bodyBytes, _ := json.Marshal(bodyMap) - body := string(bodyBytes) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token", - strings.NewReader(body)) - if err != nil { - return Token{}, err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("Authorization", "Bearer "+appToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return Token{}, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return Token{}, fmt.Errorf("exchange code: HTTP %d", resp.StatusCode) - } - return parseOIDCTokenResponse(resp.Body) -} - -// parseOIDCTokenResponse parses the common OIDC token response format used -// by both the access_token and refresh_access_token endpoints. -func parseOIDCTokenResponse(body io.Reader) (Token, error) { - var result struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` - RefreshExpiresIn int64 `json:"refresh_expires_in"` - } `json:"data"` - } - if err := json.NewDecoder(body).Decode(&result); err != nil { - return Token{}, fmt.Errorf("decode response: %w", err) - } - if result.Code != 0 { - return Token{}, fmt.Errorf("OIDC error (code=%d): %s", result.Code, result.Msg) - } - - now := time.Now() - return Token{ - AccessToken: result.Data.AccessToken, - RefreshToken: result.Data.RefreshToken, - ExpiresAt: now.Add(time.Duration(result.Data.ExpiresIn) * time.Second), - RefreshExpiresAt: now.Add(time.Duration(result.Data.RefreshExpiresIn) * time.Second), - }, nil -} - -// GetTokenOwner calls the Feishu OIDC userinfo endpoint to retrieve the -// open_id of the user who owns the given access token. Used to verify that -// an exchanged token belongs to the expected user. -func (c *Client) GetTokenOwner(ctx context.Context, accessToken string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - "https://open.feishu.cn/open-apis/authen/v1/user_info", nil) - if err != nil { - return "", err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - - var result struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - OpenID string `json:"open_id"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("decode userinfo: %w", err) - } - if result.Code != 0 { - return "", fmt.Errorf("userinfo error (code=%d): %s", result.Code, result.Msg) - } - return result.Data.OpenID, nil -} - -// AuthURL returns the Feishu OAuth authorization URL for the given app and redirect URI. -func AuthURL(appID, redirectURI, state string) string { - params := url.Values{} - params.Set("app_id", appID) - if redirectURI != "" { - params.Set("redirect_uri", redirectURI) - } - params.Set("state", state) - return "https://open.feishu.cn/open-apis/authen/v1/authorize?" + params.Encode() -} - -// InvokeWithUserToken calls fn with a resolved user access token. -// This is a convenience for SDK calls that accept larkcore.WithUserAccessToken. -func (c *Client) InvokeWithUserToken(ctx context.Context, requireAuth bool, fn func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error) error { - return c.InvokeAsUser(ctx, requireAuth, func(ctx context.Context, token string) error { - if token != "" { - return fn(ctx, larkcore.WithUserAccessToken(token)) - } - return fn(ctx) - }) -} diff --git a/internal/feishutool/client_test.go b/internal/feishutool/client_test.go deleted file mode 100644 index 9186bc2e..00000000 --- a/internal/feishutool/client_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - "time" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestNewClient(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient) - - if c.Lark() != larkClient { - t.Fatal("Lark() should return the same client") - } -} - -func TestClientWait(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient, WithRateLimit(100)) - - ctx := context.Background() - if err := c.Wait(ctx); err != nil { - t.Fatalf("Wait should succeed: %v", err) - } -} - -func TestClientWaitCancelled(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - // Very low rate limit to force waiting. - c := NewClient(larkClient, WithRateLimit(1)) - - // Exhaust the burst. - ctx := context.Background() - _ = c.Wait(ctx) - - // Now cancel context — next wait should fail. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - // This should eventually fail due to context deadline. - err := c.Wait(ctx) - if err == nil { - t.Fatal("expected error from cancelled context") - } -} - -func TestWithRateLimit(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient, WithRateLimit(42)) - - // Verify the limiter was configured (indirectly via successful wait). - ctx := context.Background() - if err := c.Wait(ctx); err != nil { - t.Fatalf("Wait should succeed with custom rate limit: %v", err) - } -} diff --git a/internal/feishutool/client_uat_test.go b/internal/feishutool/client_uat_test.go deleted file mode 100644 index ed0e4fdb..00000000 --- a/internal/feishutool/client_uat_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package feishutool - -import ( - "context" - "database/sql" - "errors" - "testing" - "time" - - lark "github.com/larksuite/oapi-sdk-go/v3" - _ "modernc.org/sqlite" -) - -func TestInvokeAsUserNoStore(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient) // no token store - - ctx := WithOpenID(context.Background(), "ou_test") - var receivedToken string - err := c.InvokeAsUser(ctx, false, func(ctx context.Context, token string) error { - receivedToken = token - return nil - }) - if err != nil { - t.Fatalf("InvokeAsUser: %v", err) - } - if receivedToken != "" { - t.Errorf("expected empty token (bot fallback), got %q", receivedToken) - } -} - -func TestInvokeAsUserNoStoreRequireAuth(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient) // no token store - - ctx := WithOpenID(context.Background(), "ou_test") - err := c.InvokeAsUser(ctx, true, func(ctx context.Context, token string) error { - t.Fatal("fn should not be called when requireAuth and no store") - return nil - }) - - var needAuth *NeedAuthError - if !errors.As(err, &needAuth) { - t.Fatalf("expected NeedAuthError, got %v", err) - } -} - -func TestInvokeAsUserNoOpenID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient) - - ctx := context.Background() // no open_id in context - var receivedToken string - err := c.InvokeAsUser(ctx, false, func(ctx context.Context, token string) error { - receivedToken = token - return nil - }) - if err != nil { - t.Fatalf("InvokeAsUser: %v", err) - } - if receivedToken != "" { - t.Errorf("expected empty token, got %q", receivedToken) - } -} - -func TestInvokeAsUserWithValidToken(t *testing.T) { - db := setupTokenTestDB(t) - store, _ := NewSQLiteTokenStore(db, "secret") - - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient, WithTokenStore(store)) - - ctx := WithOpenID(context.Background(), "ou_valid") - token := Token{ - AccessToken: "valid-access-token", - RefreshToken: "valid-refresh-token", - ExpiresAt: time.Now().Add(time.Hour), - RefreshExpiresAt: time.Now().Add(24 * time.Hour), - } - if err := store.Set(ctx, "ou_valid", token); err != nil { - t.Fatalf("Set: %v", err) - } - - var receivedToken string - err := c.InvokeAsUser(ctx, false, func(ctx context.Context, tkn string) error { - receivedToken = tkn - return nil - }) - if err != nil { - t.Fatalf("InvokeAsUser: %v", err) - } - if receivedToken != "valid-access-token" { - t.Errorf("got token %q, want %q", receivedToken, "valid-access-token") - } -} - -func TestInvokeAsUserBothExpired(t *testing.T) { - db := setupTokenTestDB(t) - store, _ := NewSQLiteTokenStore(db, "secret") - - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient, WithTokenStore(store)) - - ctx := WithOpenID(context.Background(), "ou_expired") - token := Token{ - AccessToken: "old-access", - RefreshToken: "old-refresh", - ExpiresAt: time.Now().Add(-2 * time.Hour), - RefreshExpiresAt: time.Now().Add(-time.Hour), - } - if err := store.Set(ctx, "ou_expired", token); err != nil { - t.Fatalf("Set: %v", err) - } - - // requireAuth=true: should return NeedAuthError when both tokens expired. - err := c.InvokeAsUser(ctx, true, func(ctx context.Context, tkn string) error { - t.Fatal("fn should not be called when both tokens expired and requireAuth=true") - return nil - }) - - var needAuth *NeedAuthError - if !errors.As(err, &needAuth) { - t.Fatalf("expected NeedAuthError, got %v", err) - } - - // requireAuth=false: should fall back to bot token. - var receivedToken string - err = c.InvokeAsUser(ctx, false, func(ctx context.Context, tkn string) error { - receivedToken = tkn - return nil - }) - if err != nil { - t.Fatalf("InvokeAsUser: %v", err) - } - if receivedToken != "" { - t.Errorf("expected empty token (bot fallback), got %q", receivedToken) - } -} - -func TestInvokeAsUserTokenNotFound(t *testing.T) { - db := setupTokenTestDB(t) - store, _ := NewSQLiteTokenStore(db, "secret") - - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient, WithTokenStore(store)) - - ctx := WithOpenID(context.Background(), "ou_notoken") - - // requireAuth=false: falls back to bot token. - var receivedToken string - err := c.InvokeAsUser(ctx, false, func(ctx context.Context, tkn string) error { - receivedToken = tkn - return nil - }) - if err != nil { - t.Fatalf("InvokeAsUser: %v", err) - } - if receivedToken != "" { - t.Errorf("expected empty token, got %q", receivedToken) - } -} - -func TestClientTokenStore(t *testing.T) { - db := setupTokenTestDB(t) - store, _ := NewSQLiteTokenStore(db, "secret") - - larkClient := lark.NewClient("fake_id", "fake_secret") - c := NewClient(larkClient, WithTokenStore(store)) - if c.TokenStore() != store { - t.Error("TokenStore() should return the configured store") - } - - c2 := NewClient(larkClient) - if c2.TokenStore() != nil { - t.Error("TokenStore() should return nil when not configured") - } -} - -func setupTokenTestDB(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - _, err = db.Exec(`CREATE TABLE feishu_tokens ( - open_id TEXT PRIMARY KEY, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expires_at TEXT NOT NULL, - refresh_expires_at TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - )`) - if err != nil { - t.Fatalf("create table: %v", err) - } - return db -} diff --git a/internal/feishutool/context.go b/internal/feishutool/context.go deleted file mode 100644 index 61e43c19..00000000 --- a/internal/feishutool/context.go +++ /dev/null @@ -1,44 +0,0 @@ -package feishutool - -import "context" - -type contextKey string - -const ( - openIDKey contextKey = "feishu_open_id" - chatIDKey contextKey = "feishu_chat_id" - messageIDKey contextKey = "feishu_message_id" -) - -// WithOpenID attaches a Feishu open_id to the context. -func WithOpenID(ctx context.Context, openID string) context.Context { - return context.WithValue(ctx, openIDKey, openID) -} - -// OpenIDFromContext extracts the Feishu open_id from context. -func OpenIDFromContext(ctx context.Context) string { - s, _ := ctx.Value(openIDKey).(string) - return s -} - -// WithChatID attaches a Feishu chat_id to the context. -func WithChatID(ctx context.Context, chatID string) context.Context { - return context.WithValue(ctx, chatIDKey, chatID) -} - -// ChatIDFromContext extracts the Feishu chat_id from context. -func ChatIDFromContext(ctx context.Context) string { - s, _ := ctx.Value(chatIDKey).(string) - return s -} - -// WithMessageID attaches a Feishu message_id to the context. -func WithMessageID(ctx context.Context, messageID string) context.Context { - return context.WithValue(ctx, messageIDKey, messageID) -} - -// MessageIDFromContext extracts the Feishu message_id from context. -func MessageIDFromContext(ctx context.Context) string { - s, _ := ctx.Value(messageIDKey).(string) - return s -} diff --git a/internal/feishutool/context_test.go b/internal/feishutool/context_test.go deleted file mode 100644 index 21b0a4e4..00000000 --- a/internal/feishutool/context_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package feishutool - -import ( - "context" - "testing" -) - -func TestWithOpenID(t *testing.T) { - ctx := context.Background() - if got := OpenIDFromContext(ctx); got != "" { - t.Fatalf("expected empty, got %q", got) - } - - ctx = WithOpenID(ctx, "ou_abc123") - if got := OpenIDFromContext(ctx); got != "ou_abc123" { - t.Fatalf("expected ou_abc123, got %q", got) - } -} - -func TestWithChatID(t *testing.T) { - ctx := context.Background() - if got := ChatIDFromContext(ctx); got != "" { - t.Fatalf("expected empty, got %q", got) - } - - ctx = WithChatID(ctx, "oc_chat456") - if got := ChatIDFromContext(ctx); got != "oc_chat456" { - t.Fatalf("expected oc_chat456, got %q", got) - } -} - -func TestWithMessageID(t *testing.T) { - ctx := context.Background() - if got := MessageIDFromContext(ctx); got != "" { - t.Fatalf("expected empty, got %q", got) - } - - ctx = WithMessageID(ctx, "om_msg789") - if got := MessageIDFromContext(ctx); got != "om_msg789" { - t.Fatalf("expected om_msg789, got %q", got) - } -} - -func TestContextKeysIndependent(t *testing.T) { - ctx := context.Background() - ctx = WithOpenID(ctx, "ou_1") - ctx = WithChatID(ctx, "oc_2") - ctx = WithMessageID(ctx, "om_3") - - if got := OpenIDFromContext(ctx); got != "ou_1" { - t.Fatalf("open_id: expected ou_1, got %q", got) - } - if got := ChatIDFromContext(ctx); got != "oc_2" { - t.Fatalf("chat_id: expected oc_2, got %q", got) - } - if got := MessageIDFromContext(ctx); got != "om_3" { - t.Fatalf("message_id: expected om_3, got %q", got) - } -} diff --git a/internal/feishutool/doc.go b/internal/feishutool/doc.go deleted file mode 100644 index ac936cea..00000000 --- a/internal/feishutool/doc.go +++ /dev/null @@ -1,174 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkdocx "github.com/larksuite/oapi-sdk-go/v3/service/docx/v1" - "github.com/vaayne/anna/internal/toolspec" -) - -var docInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create_doc", "get_doc_content", "get_doc_raw_content"], - "description": "The action to perform" - }, - "document_id": { - "type": "string", - "description": "Document ID (required for get_doc_content, get_doc_raw_content)" - }, - "title": { - "type": "string", - "description": "Document title (for create_doc)" - }, - "folder_token": { - "type": "string", - "description": "Folder token to create the document in (optional for create_doc)" - }, - "page_size": { - "type": "number", - "description": "Page size for block listing (default 500, max 500)" - }, - "page_token": { - "type": "string", - "description": "Pagination token for block listing" - } - }, - "required": ["action"] -}`) - -// DocTool provides Feishu document (Docx) operations. -type DocTool struct { - client *Client -} - -// NewDocTool creates a feishu_doc tool. -func NewDocTool(client *Client) *DocTool { - return &DocTool{client: client} -} - -func (t *DocTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_doc", - Description: `Manage Feishu/Lark documents (Docx API). Uses user token when available. - -Actions: -- create_doc: Create a new document. Optional: title, folder_token. -- get_doc_content: Get document content as structured blocks (JSON). Requires document_id. Returns block tree with types like paragraph, heading, code, table, etc. -- get_doc_raw_content: Get document content as plain text. Requires document_id. Useful for quick text extraction without block structure. - -Feishu documents use a block-based structure. get_doc_content returns the full block tree for programmatic processing. get_doc_raw_content returns plain text for simple reading.`, - InputSchema: docInputSchema, - } -} - -func (t *DocTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "create_doc": - return t.createDoc(ctx, args) - case "get_doc_content": - return t.getDocContent(ctx, args) - case "get_doc_raw_content": - return t.getDocRawContent(ctx, args) - default: - return "", fmt.Errorf("feishu_doc: unknown action %q", action) - } -} - -func (t *DocTool) createDoc(ctx context.Context, args map[string]any) (string, error) { - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkdocx.NewCreateDocumentReqBuilder() - bodyBuilder := larkdocx.NewCreateDocumentReqBodyBuilder() - if title := stringArg(args, "title"); title != "" { - bodyBuilder.Title(title) - } - if folder := stringArg(args, "folder_token"); folder != "" { - bodyBuilder.FolderToken(folder) - } - builder.Body(bodyBuilder.Build()) - - resp, err := t.client.Lark().Docx.Document.Create(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("create doc: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create doc: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"document": resp.Data.Document} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_doc create_doc: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *DocTool) getDocContent(ctx context.Context, args map[string]any) (string, error) { - docID := stringArg(args, "document_id") - if docID == "" { - return "", fmt.Errorf("feishu_doc get_doc_content: document_id is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkdocx.NewListDocumentBlockReqBuilder(). - DocumentId(docID) - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Docx.DocumentBlock.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("get doc content: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get doc content: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("blocks", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_doc get_doc_content: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *DocTool) getDocRawContent(ctx context.Context, args map[string]any) (string, error) { - docID := stringArg(args, "document_id") - if docID == "" { - return "", fmt.Errorf("feishu_doc get_doc_raw_content: document_id is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Docx.Document.RawContent(ctx, - larkdocx.NewRawContentDocumentReqBuilder(). - DocumentId(docID). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get doc raw content: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get doc raw content: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{ - "content": resp.Data.Content, - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_doc get_doc_raw_content: %w", invokeErr) - } - return JSONResultFromAny(result) -} diff --git a/internal/feishutool/doc_test.go b/internal/feishutool/doc_test.go deleted file mode 100644 index 7d077b5b..00000000 --- a/internal/feishutool/doc_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestDocToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDocTool(client) - - def := tool.Definition() - if def.Name != "feishu_doc" { - t.Fatalf("expected name feishu_doc, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "create_doc": true, - "get_doc_content": true, - "get_doc_raw_content": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestDocToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDocTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestDocToolGetContentMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDocTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_doc_content", - }) - if err == nil { - t.Fatal("expected error for missing document_id") - } -} - -func TestDocToolGetRawContentMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDocTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_doc_raw_content", - }) - if err == nil { - t.Fatal("expected error for missing document_id") - } -} diff --git a/internal/feishutool/drive.go b/internal/feishutool/drive.go deleted file mode 100644 index 610e5be0..00000000 --- a/internal/feishutool/drive.go +++ /dev/null @@ -1,335 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkdrive "github.com/larksuite/oapi-sdk-go/v3/service/drive/v1" - "github.com/vaayne/anna/internal/toolspec" -) - -var driveInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["list_files", "get_file_meta", "copy_file", "move_file", "delete_file", "create_folder"], - "description": "The action to perform" - }, - "file_token": { - "type": "string", - "description": "File token (required for copy_file, move_file, delete_file)" - }, - "folder_token": { - "type": "string", - "description": "Folder token for list_files (optional, defaults to root) or target folder for copy_file/create_folder" - }, - "file_type": { - "type": "string", - "enum": ["doc", "sheet", "file", "bitable", "docx", "folder", "mindnote", "slides"], - "description": "File type (required for copy_file, move_file, delete_file)" - }, - "name": { - "type": "string", - "description": "Name for copy_file (target name) or create_folder" - }, - "request_docs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "doc_token": {"type": "string"}, - "doc_type": {"type": "string", "enum": ["doc", "sheet", "file", "bitable", "docx", "folder", "mindnote", "slides"]} - } - }, - "description": "Documents to query metadata for get_file_meta (max 50). Example: [{\"doc_token\":\"xxx\",\"doc_type\":\"sheet\"}]" - }, - "order_by": { - "type": "string", - "enum": ["EditedTime", "CreatedTime"], - "description": "Sort by for list_files" - }, - "direction": { - "type": "string", - "enum": ["ASC", "DESC"], - "description": "Sort direction for list_files" - }, - "page_size": { - "type": "number", - "description": "Page size for list_files (default 200, max 200)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// DriveTool provides Feishu Drive file management. -type DriveTool struct { - client *Client -} - -// NewDriveTool creates a feishu_drive tool. -func NewDriveTool(client *Client) *DriveTool { - return &DriveTool{client: client} -} - -func (t *DriveTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_drive", - Description: `Manage Feishu/Lark Drive files. Uses user token when available. - -Actions: -- list_files: List files in a folder. Optional: folder_token (defaults to root), order_by, direction, page_size, page_token. -- get_file_meta: Batch query file metadata. Requires request_docs array (max 50). Each item needs doc_token and doc_type. -- copy_file: Copy a file. Requires file_token, name, file_type. Optional: folder_token (target folder). -- move_file: Move a file. Requires file_token, file_type, folder_token (target folder). -- delete_file: Delete a file. Requires file_token, file_type. -- create_folder: Create a folder. Requires name. Optional: folder_token (parent folder, defaults to root). - -NOTE: This tool manages Drive files (cloud storage). For reading message attachments, use feishu_im.`, - InputSchema: driveInputSchema, - } -} - -func (t *DriveTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "list_files": - return t.listFiles(ctx, args) - case "get_file_meta": - return t.getFileMeta(ctx, args) - case "copy_file": - return t.copyFile(ctx, args) - case "move_file": - return t.moveFile(ctx, args) - case "delete_file": - return t.deleteFile(ctx, args) - case "create_folder": - return t.createFolder(ctx, args) - default: - return "", fmt.Errorf("feishu_drive: unknown action %q", action) - } -} - -func (t *DriveTool) listFiles(ctx context.Context, args map[string]any) (string, error) { - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkdrive.NewListFileReqBuilder() - if folder := stringArg(args, "folder_token"); folder != "" { - builder.FolderToken(folder) - } - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - if ob := stringArg(args, "order_by"); ob != "" { - builder.OrderBy(ob) - } - if dir := stringArg(args, "direction"); dir != "" { - builder.Direction(dir) - } - - resp, err := t.client.Lark().Drive.File.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list files: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list files: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("files", resp.Data.Files, resp.Data.HasMore, resp.Data.NextPageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_drive list_files: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *DriveTool) getFileMeta(ctx context.Context, args map[string]any) (string, error) { - docs := sliceArg(args, "request_docs") - if len(docs) == 0 { - return "", fmt.Errorf("feishu_drive get_file_meta: request_docs is required") - } - - var requestDocs []*larkdrive.RequestDoc - for _, item := range docs { - if m, ok := item.(map[string]any); ok { - docToken, _ := m["doc_token"].(string) - docType, _ := m["doc_type"].(string) - if docToken != "" && docType != "" { - requestDocs = append(requestDocs, larkdrive.NewRequestDocBuilder(). - DocToken(docToken). - DocType(docType). - Build()) - } - } - } - if len(requestDocs) == 0 { - return "", fmt.Errorf("feishu_drive get_file_meta: no valid docs in request_docs") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Drive.Meta.BatchQuery(ctx, - larkdrive.NewBatchQueryMetaReqBuilder(). - MetaRequest(larkdrive.NewMetaRequestBuilder(). - RequestDocs(requestDocs). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get file meta: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get file meta: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"metas": resp.Data.Metas} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_drive get_file_meta: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *DriveTool) copyFile(ctx context.Context, args map[string]any) (string, error) { - fileToken := stringArg(args, "file_token") - name := stringArg(args, "name") - fileType := stringArg(args, "file_type") - if fileToken == "" || name == "" || fileType == "" { - return "", fmt.Errorf("feishu_drive copy_file: file_token, name, and file_type are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - bodyBuilder := larkdrive.NewCopyFileReqBodyBuilder(). - Name(name). - Type(fileType) - if folder := stringArg(args, "folder_token"); folder != "" { - bodyBuilder.FolderToken(folder) - } - - resp, err := t.client.Lark().Drive.File.Copy(ctx, - larkdrive.NewCopyFileReqBuilder(). - FileToken(fileToken). - Body(bodyBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("copy file: %w", err) - } - if !resp.Success() { - return fmt.Errorf("copy file: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"file": resp.Data.File} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_drive copy_file: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *DriveTool) moveFile(ctx context.Context, args map[string]any) (string, error) { - fileToken := stringArg(args, "file_token") - fileType := stringArg(args, "file_type") - folderToken := stringArg(args, "folder_token") - if fileToken == "" || fileType == "" || folderToken == "" { - return "", fmt.Errorf("feishu_drive move_file: file_token, file_type, and folder_token are required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Drive.File.Move(ctx, - larkdrive.NewMoveFileReqBuilder(). - FileToken(fileToken). - Body(larkdrive.NewMoveFileReqBodyBuilder(). - Type(fileType). - FolderToken(folderToken). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("move file: %w", err) - } - if !resp.Success() { - return fmt.Errorf("move file: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_drive move_file: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true, "file_token": fileToken}) -} - -func (t *DriveTool) deleteFile(ctx context.Context, args map[string]any) (string, error) { - fileToken := stringArg(args, "file_token") - fileType := stringArg(args, "file_type") - if fileToken == "" || fileType == "" { - return "", fmt.Errorf("feishu_drive delete_file: file_token and file_type are required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Drive.File.Delete(ctx, - larkdrive.NewDeleteFileReqBuilder(). - FileToken(fileToken). - Type(fileType). - Build(), - opts...) - if err != nil { - return fmt.Errorf("delete file: %w", err) - } - if !resp.Success() { - return fmt.Errorf("delete file: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_drive delete_file: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true, "file_token": fileToken}) -} - -func (t *DriveTool) createFolder(ctx context.Context, args map[string]any) (string, error) { - name := stringArg(args, "name") - if name == "" { - return "", fmt.Errorf("feishu_drive create_folder: name is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - bodyBuilder := larkdrive.NewCreateFolderFileReqBodyBuilder(). - Name(name) - if folder := stringArg(args, "folder_token"); folder != "" { - bodyBuilder.FolderToken(folder) - } - - resp, err := t.client.Lark().Drive.File.CreateFolder(ctx, - larkdrive.NewCreateFolderFileReqBuilder(). - Body(bodyBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create folder: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create folder: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{ - "token": resp.Data.Token, - "url": resp.Data.Url, - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_drive create_folder: %w", invokeErr) - } - return JSONResultFromAny(result) -} diff --git a/internal/feishutool/drive_test.go b/internal/feishutool/drive_test.go deleted file mode 100644 index a65b44a9..00000000 --- a/internal/feishutool/drive_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestDriveToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - def := tool.Definition() - if def.Name != "feishu_drive" { - t.Fatalf("expected name feishu_drive, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "list_files": true, - "get_file_meta": true, - "copy_file": true, - "move_file": true, - "delete_file": true, - "create_folder": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestDriveToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestDriveToolGetFileMetaMissingDocs(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_file_meta", - }) - if err == nil { - t.Fatal("expected error for missing request_docs") - } - - // Empty docs. - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "get_file_meta", - "request_docs": []any{}, - }) - if err == nil { - t.Fatal("expected error for empty request_docs") - } - - // Invalid docs (no valid fields). - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "get_file_meta", - "request_docs": []any{map[string]any{"invalid": true}}, - }) - if err == nil { - t.Fatal("expected error for invalid request_docs") - } -} - -func TestDriveToolCopyFileMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "copy_file", - "file_token": "test_token", - "name": "copy", - }) - if err == nil { - t.Fatal("expected error for missing file_type") - } -} - -func TestDriveToolMoveFileMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "move_file", - "file_token": "test_token", - }) - if err == nil { - t.Fatal("expected error for missing file_type and folder_token") - } -} - -func TestDriveToolDeleteFileMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "delete_file", - "file_token": "test_token", - }) - if err == nil { - t.Fatal("expected error for missing file_type") - } -} - -func TestDriveToolCreateFolderMissingName(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewDriveTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_folder", - }) - if err == nil { - t.Fatal("expected error for missing name") - } -} diff --git a/internal/feishutool/helpers.go b/internal/feishutool/helpers.go deleted file mode 100644 index d83f5740..00000000 --- a/internal/feishutool/helpers.go +++ /dev/null @@ -1,173 +0,0 @@ -package feishutool - -import ( - "encoding/json" - "fmt" - "strconv" - "time" -) - -// ParseTimeToUnix parses an ISO 8601 time string to Unix seconds. -// Accepts formats: "2006-01-02T15:04:05Z07:00", "2006-01-02 15:04:05", "2006-01-02". -// Falls back to parsing as a raw Unix timestamp string. -func ParseTimeToUnix(s string) (int64, error) { - for _, layout := range []string{ - time.RFC3339, - "2006-01-02 15:04:05", - "2006-01-02", - } { - if t, err := time.Parse(layout, s); err == nil { - return t.Unix(), nil - } - } - // Try raw numeric. - if n, err := strconv.ParseInt(s, 10, 64); err == nil { - return n, nil - } - return 0, fmt.Errorf("feishutool: cannot parse time %q", s) -} - -// ParseTimeToUnixMs parses an ISO 8601 time string to Unix milliseconds. -// Note: if the input is a raw numeric string, ParseTimeToUnix treats it as -// seconds, so this function returns that value * 1000. If you already have -// milliseconds as a raw string, convert directly with strconv.ParseInt. -func ParseTimeToUnixMs(s string) (int64, error) { - unix, err := ParseTimeToUnix(s) - if err != nil { - return 0, err - } - return unix * 1000, nil -} - -// intArg extracts an integer argument from a tool args map. -// Returns 0 if the key is missing or not a numeric type. -func intArg(args map[string]any, key string) int { - switch v := args[key].(type) { - case float64: - return int(v) - case int: - return v - case int64: - return int(v) - default: - return 0 - } -} - -// boolArg extracts a boolean argument from a tool args map. -func boolArg(args map[string]any, key string) (bool, bool) { - v, ok := args[key].(bool) - return v, ok -} - -// mapArg extracts a map argument from a tool args map. -func mapArg(args map[string]any, key string) map[string]any { - m, _ := args[key].(map[string]any) - return m -} - -// sliceArg extracts a slice argument from a tool args map. -func sliceArg(args map[string]any, key string) []any { - s, _ := args[key].([]any) - return s -} - -// FormatLarkError formats a Lark API error into a human-readable string. -func FormatLarkError(code int, msg string) string { - return fmt.Sprintf("Feishu API error (code=%d): %s", code, msg) -} - -// PaginatedResult holds a page of results with an optional continuation token. -type PaginatedResult[T any] struct { - Items []T `json:"items"` - PageToken string `json:"page_token,omitempty"` - HasMore bool `json:"has_more"` - Total int `json:"total,omitempty"` -} - -// JSONResult builds a JSON string from a map for tool return values. -func JSONResult(data map[string]any) (string, error) { - out, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "", fmt.Errorf("feishutool: marshal result: %w", err) - } - return string(out), nil -} - -// JSONResultFromAny builds a JSON string from any serializable value. -func JSONResultFromAny(v any) (string, error) { - out, err := json.MarshalIndent(v, "", " ") - if err != nil { - return "", fmt.Errorf("feishutool: marshal result: %w", err) - } - return string(out), nil -} - -// stringArg extracts a string argument from a tool args map. -func stringArg(args map[string]any, key string) string { - s, _ := args[key].(string) - return s -} - -// derefStr safely dereferences a string pointer. -func derefStr(s *string) string { - if s == nil { - return "" - } - return *s -} - -// derefInt safely dereferences an int pointer. -func derefInt(n *int) int { - if n == nil { - return 0 - } - return *n -} - -// toStringSlice extracts a string slice from a tool args map. -func toStringSlice(args map[string]any, key string) []string { - v, ok := args[key] - if !ok { - return nil - } - switch s := v.(type) { - case []any: - out := make([]string, 0, len(s)) - for _, item := range s { - if str, ok := item.(string); ok { - out = append(out, str) - } - } - return out - case []string: - return s - default: - return nil - } -} - -// mustParseSchema parses a JSON schema string into a map, panicking on error. -// Used for package-level schema initialization where invalid JSON is a bug. -func mustParseSchema(jsonStr string) map[string]any { - var m map[string]any - if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { - panic(fmt.Sprintf("feishutool: invalid schema JSON: %v", err)) - } - return m -} - -// paginatedResultMap builds a standard paginated result map from Lark SDK -// pagination fields. Reduces boilerplate across tool implementations. -func paginatedResultMap(key string, items any, hasMore *bool, pageToken *string) map[string]any { - hm := hasMore != nil && *hasMore - pt := "" - if pageToken != nil { - pt = *pageToken - } - return map[string]any{ - key: items, - "has_more": hm, - "page_token": pt, - } -} diff --git a/internal/feishutool/helpers_test.go b/internal/feishutool/helpers_test.go deleted file mode 100644 index ab8e07ff..00000000 --- a/internal/feishutool/helpers_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package feishutool - -import ( - "strings" - "testing" -) - -func TestParseTimeToUnix(t *testing.T) { - tests := []struct { - input string - want int64 - }{ - {"2024-01-15T10:30:00Z", 1705314600}, - {"2024-01-15 10:30:00", 1705314600}, - {"2024-01-15", 1705276800}, - {"1705314600", 1705314600}, - } - - for _, tt := range tests { - got, err := ParseTimeToUnix(tt.input) - if err != nil { - t.Errorf("ParseTimeToUnix(%q): unexpected error: %v", tt.input, err) - continue - } - if got != tt.want { - t.Errorf("ParseTimeToUnix(%q) = %d, want %d", tt.input, got, tt.want) - } - } -} - -func TestParseTimeToUnixError(t *testing.T) { - _, err := ParseTimeToUnix("not-a-time") - if err == nil { - t.Fatal("expected error for invalid time string") - } -} - -func TestParseTimeToUnixMs(t *testing.T) { - got, err := ParseTimeToUnixMs("2024-01-15T10:30:00Z") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != 1705314600000 { - t.Fatalf("got %d, want 1705314600000", got) - } -} - -func TestFormatLarkError(t *testing.T) { - got := FormatLarkError(99991, "permission denied") - if !strings.Contains(got, "99991") || !strings.Contains(got, "permission denied") { - t.Fatalf("unexpected error format: %s", got) - } -} - -func TestJSONResult(t *testing.T) { - result, err := JSONResult(map[string]any{"name": "alice"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "alice") { - t.Fatalf("result should contain alice: %s", result) - } -} - -func TestJSONResultFromAny(t *testing.T) { - result, err := JSONResultFromAny([]string{"a", "b"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "a") || !strings.Contains(result, "b") { - t.Fatalf("result should contain a and b: %s", result) - } -} - -func TestStringArg(t *testing.T) { - args := map[string]any{"key": "value"} - if got := stringArg(args, "key"); got != "value" { - t.Fatalf("expected value, got %q", got) - } - if got := stringArg(args, "missing"); got != "" { - t.Fatalf("expected empty, got %q", got) - } -} - -func TestDerefStr(t *testing.T) { - if got := derefStr(nil); got != "" { - t.Fatalf("expected empty for nil, got %q", got) - } - s := "hello" - if got := derefStr(&s); got != "hello" { - t.Fatalf("expected hello, got %q", got) - } -} - -func TestDerefInt(t *testing.T) { - if got := derefInt(nil); got != 0 { - t.Fatalf("expected 0 for nil, got %d", got) - } - n := 42 - if got := derefInt(&n); got != 42 { - t.Fatalf("expected 42, got %d", got) - } -} diff --git a/internal/feishutool/im.go b/internal/feishutool/im.go deleted file mode 100644 index 0c977170..00000000 --- a/internal/feishutool/im.go +++ /dev/null @@ -1,417 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - "github.com/vaayne/anna/internal/toolspec" -) - -var imInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["send_message", "reply_message", "read_messages", "get_message", "forward_message", "add_reaction", "remove_reaction"], - "description": "The action to perform" - }, - "receive_id_type": { - "type": "string", - "enum": ["open_id", "chat_id"], - "description": "Receiver ID type for send_message: open_id (DM, ou_xxx) or chat_id (group, oc_xxx)" - }, - "receive_id": { - "type": "string", - "description": "Receiver ID for send_message. Must match receive_id_type." - }, - "message_id": { - "type": "string", - "description": "Message ID (om_xxx). Required for reply_message, get_message, forward_message, add_reaction, remove_reaction." - }, - "msg_type": { - "type": "string", - "enum": ["text", "post", "interactive", "image", "file", "share_chat", "share_user"], - "description": "Message type for send/reply. Most common: text." - }, - "content": { - "type": "string", - "description": "Message content as JSON string. Format depends on msg_type. text: '{\"text\":\"hello\"}', post: '{\"zh_cn\":{\"title\":\"Title\",\"content\":[[{\"tag\":\"text\",\"text\":\"body\"}]]}}'" - }, - "chat_id": { - "type": "string", - "description": "Chat ID for read_messages (oc_xxx) or forward_message target" - }, - "container_id": { - "type": "string", - "description": "Container ID for read_messages (typically chat_id)" - }, - "start_time": { - "type": "string", - "description": "Start time for read_messages (Unix seconds string)" - }, - "end_time": { - "type": "string", - "description": "End time for read_messages (Unix seconds string)" - }, - "sort_type": { - "type": "string", - "enum": ["ByCreateTimeAsc", "ByCreateTimeDesc"], - "description": "Sort order for read_messages (default: ByCreateTimeAsc)" - }, - "reaction_type": { - "type": "string", - "description": "Emoji type for add_reaction/remove_reaction, e.g. 'THUMBSUP', 'SMILE', 'HEART'" - }, - "reaction_id": { - "type": "string", - "description": "Reaction ID for remove_reaction (from reaction list)" - }, - "uuid": { - "type": "string", - "description": "Idempotent UUID for send/reply. Same UUID within 1 hour deduplicates." - }, - "page_size": { - "type": "number", - "description": "Page size for read_messages (default 20, max 50)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// IMTool provides Feishu instant messaging operations. -type IMTool struct { - client *Client -} - -// NewIMTool creates a feishu_im tool. -func NewIMTool(client *Client) *IMTool { - return &IMTool{client: client} -} - -func (t *IMTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_im", - Description: `Send and read Feishu/Lark IM messages, manage reactions. Uses user token when available. - -IMPORTANT: This tool sends messages as the authorized user. Always confirm with the user before sending: 1) who to send to, 2) message content. - -Actions: -- send_message: Send a message to a user (DM) or group chat. Requires receive_id_type, receive_id, msg_type, content. -- reply_message: Reply to a specific message. Requires message_id, msg_type, content. -- read_messages: Read message history from a chat. Requires container_id (chat_id). Optional: start_time, end_time, sort_type, page_size, page_token. -- get_message: Get a single message by ID. Requires message_id. -- forward_message: Forward a message to another chat. Requires message_id and receive_id (target chat_id). -- add_reaction: Add an emoji reaction to a message. Requires message_id and reaction_type (e.g. 'THUMBSUP'). -- remove_reaction: Remove a reaction. Requires message_id and reaction_id. - -Content format (JSON string): text -> '{"text":"hello"}', post -> '{"zh_cn":{"title":"T","content":[[{"tag":"text","text":"body"}]]}}', interactive -> card JSON.`, - InputSchema: imInputSchema, - } -} - -func (t *IMTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "send_message": - return t.sendMessage(ctx, args) - case "reply_message": - return t.replyMessage(ctx, args) - case "read_messages": - return t.readMessages(ctx, args) - case "get_message": - return t.getMessage(ctx, args) - case "forward_message": - return t.forwardMessage(ctx, args) - case "add_reaction": - return t.addReaction(ctx, args) - case "remove_reaction": - return t.removeReaction(ctx, args) - default: - return "", fmt.Errorf("feishu_im: unknown action %q", action) - } -} - -func (t *IMTool) sendMessage(ctx context.Context, args map[string]any) (string, error) { - recvIDType := stringArg(args, "receive_id_type") - recvID := stringArg(args, "receive_id") - msgType := stringArg(args, "msg_type") - content := stringArg(args, "content") - if recvIDType == "" || recvID == "" { - return "", fmt.Errorf("feishu_im send_message: receive_id_type and receive_id are required") - } - if msgType == "" || content == "" { - return "", fmt.Errorf("feishu_im send_message: msg_type and content are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - bodyBuilder := larkim.NewCreateMessageReqBodyBuilder(). - ReceiveId(recvID). - MsgType(msgType). - Content(content) - if uuid := stringArg(args, "uuid"); uuid != "" { - bodyBuilder.Uuid(uuid) - } - - resp, err := t.client.Lark().Im.Message.Create(ctx, - larkim.NewCreateMessageReqBuilder(). - ReceiveIdType(recvIDType). - Body(bodyBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("send message: %w", err) - } - if !resp.Success() { - return fmt.Errorf("send message: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"message": resp.Data} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im send_message: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *IMTool) replyMessage(ctx context.Context, args map[string]any) (string, error) { - messageID := stringArg(args, "message_id") - if messageID == "" { - messageID = MessageIDFromContext(ctx) - } - if messageID == "" { - return "", fmt.Errorf("feishu_im reply_message: message_id is required") - } - msgType := stringArg(args, "msg_type") - content := stringArg(args, "content") - if msgType == "" || content == "" { - return "", fmt.Errorf("feishu_im reply_message: msg_type and content are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - bodyBuilder := larkim.NewReplyMessageReqBodyBuilder(). - MsgType(msgType). - Content(content) - if uuid := stringArg(args, "uuid"); uuid != "" { - bodyBuilder.Uuid(uuid) - } - - resp, err := t.client.Lark().Im.Message.Reply(ctx, - larkim.NewReplyMessageReqBuilder(). - MessageId(messageID). - Body(bodyBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("reply message: %w", err) - } - if !resp.Success() { - return fmt.Errorf("reply message: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"message": resp.Data} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im reply_message: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *IMTool) readMessages(ctx context.Context, args map[string]any) (string, error) { - containerID := stringArg(args, "container_id") - if containerID == "" { - containerID = stringArg(args, "chat_id") - } - if containerID == "" { - containerID = ChatIDFromContext(ctx) - } - if containerID == "" { - return "", fmt.Errorf("feishu_im read_messages: container_id or chat_id is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkim.NewListMessageReqBuilder(). - ContainerIdType("chat"). - ContainerId(containerID) - if st := stringArg(args, "start_time"); st != "" { - builder.StartTime(st) - } - if et := stringArg(args, "end_time"); et != "" { - builder.EndTime(et) - } - if sort := stringArg(args, "sort_type"); sort != "" { - builder.SortType(sort) - } - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Im.Message.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("read messages: %w", err) - } - if !resp.Success() { - return fmt.Errorf("read messages: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("messages", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im read_messages: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *IMTool) getMessage(ctx context.Context, args map[string]any) (string, error) { - messageID := stringArg(args, "message_id") - if messageID == "" { - messageID = MessageIDFromContext(ctx) - } - if messageID == "" { - return "", fmt.Errorf("feishu_im get_message: message_id is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.Message.Get(ctx, - larkim.NewGetMessageReqBuilder(). - MessageId(messageID). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get message: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get message: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"message": resp.Data} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im get_message: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *IMTool) forwardMessage(ctx context.Context, args map[string]any) (string, error) { - messageID := stringArg(args, "message_id") - if messageID == "" { - return "", fmt.Errorf("feishu_im forward_message: message_id is required") - } - recvID := stringArg(args, "receive_id") - if recvID == "" { - recvID = stringArg(args, "chat_id") - } - if recvID == "" { - return "", fmt.Errorf("feishu_im forward_message: receive_id or chat_id is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.Message.Forward(ctx, - larkim.NewForwardMessageReqBuilder(). - MessageId(messageID). - ReceiveIdType("chat_id"). - Body(larkim.NewForwardMessageReqBodyBuilder(). - ReceiveId(recvID). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("forward message: %w", err) - } - if !resp.Success() { - return fmt.Errorf("forward message: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"message": resp.Data} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im forward_message: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *IMTool) addReaction(ctx context.Context, args map[string]any) (string, error) { - messageID := stringArg(args, "message_id") - if messageID == "" { - messageID = MessageIDFromContext(ctx) - } - if messageID == "" { - return "", fmt.Errorf("feishu_im add_reaction: message_id is required") - } - reactionType := stringArg(args, "reaction_type") - if reactionType == "" { - return "", fmt.Errorf("feishu_im add_reaction: reaction_type is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.MessageReaction.Create(ctx, - larkim.NewCreateMessageReactionReqBuilder(). - MessageId(messageID). - Body(larkim.NewCreateMessageReactionReqBodyBuilder(). - ReactionType(larkim.NewEmojiBuilder().EmojiType(reactionType).Build()). - Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("add reaction: %w", err) - } - if !resp.Success() { - return fmt.Errorf("add reaction: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"reaction": resp.Data} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im add_reaction: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *IMTool) removeReaction(ctx context.Context, args map[string]any) (string, error) { - messageID := stringArg(args, "message_id") - if messageID == "" { - messageID = MessageIDFromContext(ctx) - } - if messageID == "" { - return "", fmt.Errorf("feishu_im remove_reaction: message_id is required") - } - reactionID := stringArg(args, "reaction_id") - if reactionID == "" { - return "", fmt.Errorf("feishu_im remove_reaction: reaction_id is required") - } - - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Im.MessageReaction.Delete(ctx, - larkim.NewDeleteMessageReactionReqBuilder(). - MessageId(messageID). - ReactionId(reactionID). - Build(), - opts...) - if err != nil { - return fmt.Errorf("remove reaction: %w", err) - } - if !resp.Success() { - return fmt.Errorf("remove reaction: %s", FormatLarkError(resp.Code, resp.Msg)) - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_im remove_reaction: %w", invokeErr) - } - return JSONResultFromAny(map[string]any{"success": true}) -} diff --git a/internal/feishutool/im_test.go b/internal/feishutool/im_test.go deleted file mode 100644 index 5f291d2f..00000000 --- a/internal/feishutool/im_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestIMToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - def := tool.Definition() - if def.Name != "feishu_im" { - t.Fatalf("expected name feishu_im, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "send_message": true, - "reply_message": true, - "read_messages": true, - "get_message": true, - "forward_message": true, - "add_reaction": true, - "remove_reaction": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestIMToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestIMToolSendMessageMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - // Missing receive_id_type. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "send_message", - "msg_type": "text", - "content": `{"text":"hello"}`, - }) - if err == nil { - t.Fatal("expected error for missing receive_id_type") - } - - // Missing content. - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "send_message", - "receive_id_type": "chat_id", - "receive_id": "oc_test", - "msg_type": "text", - }) - if err == nil { - t.Fatal("expected error for missing content") - } -} - -func TestIMToolReplyMessageMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - // Missing message_id. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "reply_message", - "msg_type": "text", - "content": `{"text":"hello"}`, - }) - if err == nil { - t.Fatal("expected error for missing message_id") - } -} - -func TestIMToolReadMessagesMissingContainer(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "read_messages", - }) - if err == nil { - t.Fatal("expected error for missing container_id") - } -} - -func TestIMToolGetMessageMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_message", - }) - if err == nil { - t.Fatal("expected error for missing message_id") - } -} - -func TestIMToolForwardMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "forward_message", - }) - if err == nil { - t.Fatal("expected error for missing message_id") - } - - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "forward_message", - "message_id": "om_test", - }) - if err == nil { - t.Fatal("expected error for missing receive_id") - } -} - -func TestIMToolAddReactionMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "add_reaction", - "reaction_type": "THUMBSUP", - }) - if err == nil { - t.Fatal("expected error for missing message_id") - } - - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "add_reaction", - "message_id": "om_test", - }) - if err == nil { - t.Fatal("expected error for missing reaction_type") - } -} - -func TestIMToolRemoveReactionMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewIMTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "remove_reaction", - "reaction_id": "test_id", - }) - if err == nil { - t.Fatal("expected error for missing message_id") - } - - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "remove_reaction", - "message_id": "om_test", - }) - if err == nil { - t.Fatal("expected error for missing reaction_id") - } -} diff --git a/internal/feishutool/search.go b/internal/feishutool/search.go deleted file mode 100644 index e598dfa1..00000000 --- a/internal/feishutool/search.go +++ /dev/null @@ -1,169 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larksearch "github.com/larksuite/oapi-sdk-go/v3/service/search/v2" - "github.com/vaayne/anna/internal/toolspec" -) - -var searchInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["search_docs"], - "description": "The action to perform" - }, - "query": { - "type": "string", - "description": "Search keyword. Empty string returns results sorted by recent access." - }, - "doc_types": { - "type": "array", - "items": {"type": "string", "enum": ["DOC", "SHEET", "BITABLE", "MINDNOTE", "FILE", "WIKI", "DOCX", "FOLDER", "SLIDES"]}, - "description": "Filter by document types (optional)" - }, - "creator_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "Filter by creator OpenIDs (optional, max 20)" - }, - "only_title": { - "type": "boolean", - "description": "Search only in titles (default: false, searches title and body)" - }, - "sort_type": { - "type": "string", - "enum": ["DEFAULT_TYPE", "OPEN_TIME", "EDIT_TIME", "EDIT_TIME_ASC", "CREATE_TIME"], - "description": "Sort order. EDIT_TIME=newest edits first (recommended), CREATE_TIME=by creation time." - }, - "page_size": { - "type": "number", - "description": "Page size (default 15, max 20)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// SearchTool provides Feishu document and wiki search. -type SearchTool struct { - client *Client -} - -// NewSearchTool creates a feishu_search tool. -func NewSearchTool(client *Client) *SearchTool { - return &SearchTool{client: client} -} - -func (t *SearchTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_search", - Description: `Search Feishu/Lark documents and wikis. Uses user token when available. - -Searches across all cloud documents and wiki pages accessible to the user. - -Actions: -- search_docs: Global document and wiki search. Optional: query (empty = recent docs), doc_types, creator_ids, only_title, sort_type, page_size, page_token. - -Results include title and summary with highlighted matching keywords (wrapped in tags). -Supports filtering by document type (DOC, SHEET, WIKI, DOCX, BITABLE, etc.), creator, and sort order.`, - InputSchema: searchInputSchema, - } -} - -func (t *SearchTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "search_docs": - return t.searchDocs(ctx, args) - default: - return "", fmt.Errorf("feishu_search: unknown action %q", action) - } -} - -func (t *SearchTool) searchDocs(ctx context.Context, args map[string]any) (string, error) { - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - query := stringArg(args, "query") - - bodyBuilder := larksearch.NewSearchDocWikiReqBodyBuilder(). - Query(query) - - if ps := intArg(args, "page_size"); ps > 0 { - bodyBuilder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - bodyBuilder.PageToken(pt) - } - - // Build doc filter. - docFilter := buildSearchFilter(args) - bodyBuilder.DocFilter(docFilter) - // Apply the same filter to wiki. - wikiFilter := buildSearchWikiFilter(args) - bodyBuilder.WikiFilter(wikiFilter) - - resp, err := t.client.Lark().Search.DocWiki.Search(ctx, - larksearch.NewSearchDocWikiReqBuilder(). - Body(bodyBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("search docs: %w", err) - } - if !resp.Success() { - return fmt.Errorf("search docs: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("results", resp.Data.ResUnits, resp.Data.HasMore, resp.Data.PageToken) - if resp.Data.Total != nil { - result["total"] = *resp.Data.Total - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_search search_docs: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func buildSearchFilter(args map[string]any) *larksearch.DocFilter { - builder := larksearch.NewDocFilterBuilder() - if types := toStringSlice(args, "doc_types"); len(types) > 0 { - builder.DocTypes(types) - } - if ids := toStringSlice(args, "creator_ids"); len(ids) > 0 { - builder.CreatorIds(ids) - } - if v, ok := boolArg(args, "only_title"); ok { - builder.OnlyTitle(v) - } - if sort := stringArg(args, "sort_type"); sort != "" { - builder.SortType(sort) - } - return builder.Build() -} - -func buildSearchWikiFilter(args map[string]any) *larksearch.WikiFilter { - builder := larksearch.NewWikiFilterBuilder() - if types := toStringSlice(args, "doc_types"); len(types) > 0 { - builder.DocTypes(types) - } - if ids := toStringSlice(args, "creator_ids"); len(ids) > 0 { - builder.CreatorIds(ids) - } - if v, ok := boolArg(args, "only_title"); ok { - builder.OnlyTitle(v) - } - if sort := stringArg(args, "sort_type"); sort != "" { - builder.SortType(sort) - } - return builder.Build() -} diff --git a/internal/feishutool/search_test.go b/internal/feishutool/search_test.go deleted file mode 100644 index f6c3f862..00000000 --- a/internal/feishutool/search_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestSearchToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSearchTool(client) - - def := tool.Definition() - if def.Name != "feishu_search" { - t.Fatalf("expected name feishu_search, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "search_docs": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestSearchToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSearchTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestBuildSearchFilter(t *testing.T) { - // With all params. - args := map[string]any{ - "doc_types": []any{"DOC", "SHEET"}, - "creator_ids": []any{"ou_test"}, - "only_title": true, - "sort_type": "EDIT_TIME", - } - filter := buildSearchFilter(args) - if filter == nil { - t.Fatal("expected non-nil filter") - } - if len(filter.DocTypes) != 2 { - t.Fatalf("expected 2 doc types, got %d", len(filter.DocTypes)) - } - if len(filter.CreatorIds) != 1 { - t.Fatalf("expected 1 creator id, got %d", len(filter.CreatorIds)) - } - if filter.OnlyTitle == nil || !*filter.OnlyTitle { - t.Fatal("expected only_title to be true") - } - if filter.SortType == nil || *filter.SortType != "EDIT_TIME" { - t.Fatal("expected sort_type to be EDIT_TIME") - } - - // Empty params. - emptyFilter := buildSearchFilter(map[string]any{}) - if emptyFilter == nil { - t.Fatal("expected non-nil empty filter") - } -} - -func TestBuildSearchWikiFilter(t *testing.T) { - args := map[string]any{ - "doc_types": []any{"WIKI"}, - } - filter := buildSearchWikiFilter(args) - if filter == nil { - t.Fatal("expected non-nil wiki filter") - } - if len(filter.DocTypes) != 1 { - t.Fatalf("expected 1 doc type, got %d", len(filter.DocTypes)) - } -} diff --git a/internal/feishutool/sheets.go b/internal/feishutool/sheets.go deleted file mode 100644 index cef4b6e4..00000000 --- a/internal/feishutool/sheets.go +++ /dev/null @@ -1,299 +0,0 @@ -package feishutool - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larksheets "github.com/larksuite/oapi-sdk-go/v3/service/sheets/v3" - "github.com/vaayne/anna/internal/toolspec" -) - -var sheetsInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create_spreadsheet", "get_spreadsheet", "list_sheets", "read_range", "write_range"], - "description": "The action to perform" - }, - "spreadsheet_token": { - "type": "string", - "description": "Spreadsheet token (required for all actions except create_spreadsheet)" - }, - "title": { - "type": "string", - "description": "Spreadsheet title (for create_spreadsheet)" - }, - "folder_token": { - "type": "string", - "description": "Folder token for create_spreadsheet (optional, defaults to root)" - }, - "range": { - "type": "string", - "description": "Cell range for read/write, e.g. 'sheetId!A1:D10' or just 'sheetId' for entire sheet. sheetId from list_sheets action." - }, - "values": { - "type": "array", - "items": {"type": "array", "items": {}}, - "description": "2D array for write_range. Each inner array is a row. Example: [[\"Name\",\"Age\"],[\"Alice\",25]]" - }, - "value_render_option": { - "type": "string", - "enum": ["ToString", "FormattedValue", "Formula", "UnformattedValue"], - "description": "How to render cell values for read_range (default: ToString)" - } - }, - "required": ["action"] -}`) - -// SheetsTool provides Feishu spreadsheet operations. -type SheetsTool struct { - client *Client -} - -// NewSheetsTool creates a feishu_sheets tool. -func NewSheetsTool(client *Client) *SheetsTool { - return &SheetsTool{client: client} -} - -func (t *SheetsTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_sheets", - Description: `Manage Feishu/Lark spreadsheets. Uses user token when available. - -Spreadsheets (Sheets) are like Excel/Google Sheets. Different from Bitable (which is like Airtable). - -Actions: -- create_spreadsheet: Create a new spreadsheet. Optional: title, folder_token. -- get_spreadsheet: Get spreadsheet metadata. Requires spreadsheet_token. -- list_sheets: List all worksheets in a spreadsheet. Requires spreadsheet_token. Returns sheet_id, title, row/column counts. -- read_range: Read cell data from a range. Requires spreadsheet_token and range (format: 'sheetId!A1:D10' or 'sheetId'). Optional: value_render_option. -- write_range: Write data to a range. Requires spreadsheet_token, range, and values (2D array). Overwrites existing data in the range. - -Get sheet_id from list_sheets, then use it in range like 'abc123!A1:D10'.`, - InputSchema: sheetsInputSchema, - } -} - -func (t *SheetsTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "create_spreadsheet": - return t.createSpreadsheet(ctx, args) - case "get_spreadsheet": - return t.getSpreadsheet(ctx, args) - case "list_sheets": - return t.listSheets(ctx, args) - case "read_range": - return t.readRange(ctx, args) - case "write_range": - return t.writeRange(ctx, args) - default: - return "", fmt.Errorf("feishu_sheets: unknown action %q", action) - } -} - -func (t *SheetsTool) createSpreadsheet(ctx context.Context, args map[string]any) (string, error) { - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - ssBuilder := larksheets.NewSpreadsheetBuilder() - if title := stringArg(args, "title"); title != "" { - ssBuilder.Title(title) - } - if folder := stringArg(args, "folder_token"); folder != "" { - ssBuilder.FolderToken(folder) - } - - resp, err := t.client.Lark().Sheets.Spreadsheet.Create(ctx, - larksheets.NewCreateSpreadsheetReqBuilder(). - Spreadsheet(ssBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create spreadsheet: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create spreadsheet: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"spreadsheet": resp.Data.Spreadsheet} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_sheets create_spreadsheet: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *SheetsTool) getSpreadsheet(ctx context.Context, args map[string]any) (string, error) { - token := stringArg(args, "spreadsheet_token") - if token == "" { - return "", fmt.Errorf("feishu_sheets get_spreadsheet: spreadsheet_token is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Sheets.Spreadsheet.Get(ctx, - larksheets.NewGetSpreadsheetReqBuilder(). - SpreadsheetToken(token). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get spreadsheet: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get spreadsheet: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"spreadsheet": resp.Data.Spreadsheet} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_sheets get_spreadsheet: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *SheetsTool) listSheets(ctx context.Context, args map[string]any) (string, error) { - token := stringArg(args, "spreadsheet_token") - if token == "" { - return "", fmt.Errorf("feishu_sheets list_sheets: spreadsheet_token is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Sheets.SpreadsheetSheet.Query(ctx, - larksheets.NewQuerySpreadsheetSheetReqBuilder(). - SpreadsheetToken(token). - Build(), - opts...) - if err != nil { - return fmt.Errorf("list sheets: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list sheets: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"sheets": resp.Data.Sheets} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_sheets list_sheets: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *SheetsTool) readRange(ctx context.Context, args map[string]any) (string, error) { - token := stringArg(args, "spreadsheet_token") - rangeStr := stringArg(args, "range") - if token == "" || rangeStr == "" { - return "", fmt.Errorf("feishu_sheets read_range: spreadsheet_token and range are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - // Sheets v2 API for reading cell values. - apiPath := fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", - url.PathEscape(token), url.PathEscape(rangeStr)) - - vro := stringArg(args, "value_render_option") - if vro == "" { - vro = "ToString" - } - apiPath += "?valueRenderOption=" + url.QueryEscape(vro) + "&dateTimeRenderOption=FormattedString" - - resp, err := t.client.Lark().Get(ctx, apiPath, nil, larkcore.AccessTokenTypeTenant, opts...) - if err != nil { - return fmt.Errorf("read range: %w", err) - } - - var respData struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - ValueRange struct { - Range string `json:"range"` - Values [][]any `json:"values"` - } `json:"valueRange"` - } `json:"data"` - } - if err := json.Unmarshal(resp.RawBody, &respData); err != nil { - return fmt.Errorf("read range: decode response: %w", err) - } - if respData.Code != 0 { - return fmt.Errorf("read range: %s", FormatLarkError(respData.Code, respData.Msg)) - } - - result = map[string]any{ - "range": respData.Data.ValueRange.Range, - "values": respData.Data.ValueRange.Values, - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_sheets read_range: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *SheetsTool) writeRange(ctx context.Context, args map[string]any) (string, error) { - token := stringArg(args, "spreadsheet_token") - rangeStr := stringArg(args, "range") - values := sliceArg(args, "values") - if token == "" || rangeStr == "" { - return "", fmt.Errorf("feishu_sheets write_range: spreadsheet_token and range are required") - } - if len(values) == 0 { - return "", fmt.Errorf("feishu_sheets write_range: values is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - // Sheets v2 API for writing cell values. - apiPath := fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", - url.PathEscape(token)) - - body := map[string]any{ - "valueRange": map[string]any{ - "range": rangeStr, - "values": values, - }, - } - - resp, err := t.client.Lark().Put(ctx, apiPath, body, larkcore.AccessTokenTypeTenant, opts...) - if err != nil { - return fmt.Errorf("write range: %w", err) - } - - var respData struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - UpdatedRange string `json:"updatedRange"` - UpdatedRows int `json:"updatedRows"` - UpdatedColumns int `json:"updatedColumns"` - UpdatedCells int `json:"updatedCells"` - Revision int `json:"revision"` - } `json:"data"` - } - if err := json.Unmarshal(resp.RawBody, &respData); err != nil { - return fmt.Errorf("write range: decode response: %w", err) - } - if respData.Code != 0 { - return fmt.Errorf("write range: %s", FormatLarkError(respData.Code, respData.Msg)) - } - - result = map[string]any{ - "updated_range": respData.Data.UpdatedRange, - "updated_rows": respData.Data.UpdatedRows, - "updated_columns": respData.Data.UpdatedColumns, - "updated_cells": respData.Data.UpdatedCells, - "revision": respData.Data.Revision, - } - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_sheets write_range: %w", invokeErr) - } - return JSONResultFromAny(result) -} diff --git a/internal/feishutool/sheets_test.go b/internal/feishutool/sheets_test.go deleted file mode 100644 index a9d57167..00000000 --- a/internal/feishutool/sheets_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestSheetsToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSheetsTool(client) - - def := tool.Definition() - if def.Name != "feishu_sheets" { - t.Fatalf("expected name feishu_sheets, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "create_spreadsheet": true, - "get_spreadsheet": true, - "list_sheets": true, - "read_range": true, - "write_range": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestSheetsToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSheetsTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestSheetsToolGetSpreadsheetMissingToken(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSheetsTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_spreadsheet", - }) - if err == nil { - t.Fatal("expected error for missing spreadsheet_token") - } -} - -func TestSheetsToolListSheetsMissingToken(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSheetsTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "list_sheets", - }) - if err == nil { - t.Fatal("expected error for missing spreadsheet_token") - } -} - -func TestSheetsToolReadRangeMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSheetsTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "read_range", - }) - if err == nil { - t.Fatal("expected error for missing spreadsheet_token and range") - } - - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "read_range", - "spreadsheet_token": "test_token", - }) - if err == nil { - t.Fatal("expected error for missing range") - } -} - -func TestSheetsToolWriteRangeMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewSheetsTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "write_range", - "spreadsheet_token": "test_token", - "range": "sheet1!A1:B2", - }) - if err == nil { - t.Fatal("expected error for missing values") - } -} diff --git a/internal/feishutool/task.go b/internal/feishutool/task.go deleted file mode 100644 index 618e7617..00000000 --- a/internal/feishutool/task.go +++ /dev/null @@ -1,583 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - "strconv" - "time" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larktask "github.com/larksuite/oapi-sdk-go/v3/service/task/v2" - "github.com/vaayne/anna/internal/toolspec" -) - -var taskInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create_task", "list_tasks", "get_task", "update_task", "complete_task", "create_tasklist", "list_tasklists", "create_subtask", "list_subtasks"], - "description": "The action to perform" - }, - "task_guid": { - "type": "string", - "description": "Task GUID (required for get/update/complete/create_subtask/list_subtasks)" - }, - "tasklist_guid": { - "type": "string", - "description": "Tasklist GUID (for create_task to assign, or required for list_tasklists tasks)" - }, - "summary": { - "type": "string", - "description": "Task/tasklist title (required for create_task, create_tasklist, create_subtask)" - }, - "description": { - "type": "string", - "description": "Task description" - }, - "due": { - "type": "object", - "properties": { - "timestamp": {"type": "string", "description": "Due time in ISO 8601 format, e.g. '2024-01-01T18:00:00+08:00'"}, - "is_all_day": {"type": "boolean", "description": "Whether this is an all-day task"} - }, - "description": "Task due time (millisecond precision internally)" - }, - "start": { - "type": "object", - "properties": { - "timestamp": {"type": "string", "description": "Start time in ISO 8601 format"}, - "is_all_day": {"type": "boolean"} - }, - "description": "Task start time" - }, - "members": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "description": "Member open_id"}, - "role": {"type": "string", "enum": ["assignee", "follower"], "description": "Member role (default: assignee)"} - } - }, - "description": "Task members (assignee=responsible, follower=watcher)" - }, - "repeat_rule": { - "type": "string", - "description": "RRULE recurrence rule for recurring tasks" - }, - "completed": { - "type": "boolean", - "description": "Filter for completed tasks (for list_tasks)" - }, - "page_size": { - "type": "number", - "description": "Page size (default 50, max 100)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// TaskTool provides Feishu task management via the Task v2 API. -type TaskTool struct { - client *Client -} - -// NewTaskTool creates a feishu_task tool. -func NewTaskTool(client *Client) *TaskTool { - return &TaskTool{client: client} -} - -func (t *TaskTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_task", - Description: `Manage Feishu/Lark tasks (v2 API). Uses user token when available. - -Actions: -- create_task: Create a task. Requires summary. Optional: description, due, start, members, repeat_rule, tasklist_guid. -- list_tasks: List tasks visible to the current user. Optional: completed, page_size, page_token. -- get_task: Get task details. Requires task_guid. -- update_task: Update a task. Requires task_guid plus fields to update (summary, description, due, start, members, repeat_rule). -- complete_task: Mark a task as completed. Requires task_guid. Sets completed_at to current time. -- create_tasklist: Create a task list. Requires summary. -- list_tasklists: List all task lists. -- create_subtask: Create a subtask under a parent task. Requires task_guid (parent) and summary. -- list_subtasks: List subtasks of a task. Requires task_guid. - -Time format: ISO 8601 with timezone, e.g. '2024-01-01T18:00:00+08:00'. Task API uses millisecond timestamps internally.`, - InputSchema: taskInputSchema, - } -} - -func (t *TaskTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "create_task": - return t.createTask(ctx, args) - case "list_tasks": - return t.listTasks(ctx, args) - case "get_task": - return t.getTask(ctx, args) - case "update_task": - return t.updateTask(ctx, args) - case "complete_task": - return t.completeTask(ctx, args) - case "create_tasklist": - return t.createTasklist(ctx, args) - case "list_tasklists": - return t.listTasklists(ctx, args) - case "create_subtask": - return t.createSubtask(ctx, args) - case "list_subtasks": - return t.listSubtasks(ctx, args) - default: - return "", fmt.Errorf("feishu_task: unknown action %q", action) - } -} - -func (t *TaskTool) createTask(ctx context.Context, args map[string]any) (string, error) { - summary := stringArg(args, "summary") - if summary == "" { - return "", fmt.Errorf("feishu_task create_task: summary is required") - } - - inputTask, err := buildInputTask(args, summary) - if err != nil { - return "", fmt.Errorf("feishu_task create_task: %w", err) - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Task.V2.Task.Create(ctx, - larktask.NewCreateTaskReqBuilder(). - UserIdType("open_id"). - InputTask(inputTask). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create task: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create task: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"task": resp.Data.Task} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task create_task: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) listTasks(ctx context.Context, args map[string]any) (string, error) { - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larktask.NewListTaskReqBuilder(). - UserIdType("open_id") - - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - if v, ok := boolArg(args, "completed"); ok { - builder.Completed(v) - } - - resp, err := t.client.Lark().Task.V2.Task.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list tasks: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list tasks: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = paginatedResultMap("tasks", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task list_tasks: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) getTask(ctx context.Context, args map[string]any) (string, error) { - guid := stringArg(args, "task_guid") - if guid == "" { - return "", fmt.Errorf("feishu_task get_task: task_guid is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Task.V2.Task.Get(ctx, - larktask.NewGetTaskReqBuilder(). - TaskGuid(guid). - UserIdType("open_id"). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get task: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get task: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"task": resp.Data.Task} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task get_task: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) updateTask(ctx context.Context, args map[string]any) (string, error) { - guid := stringArg(args, "task_guid") - if guid == "" { - return "", fmt.Errorf("feishu_task update_task: task_guid is required") - } - - updateTask, updateFields, err := buildUpdateTask(args) - if err != nil { - return "", fmt.Errorf("feishu_task update_task: %w", err) - } - if len(updateFields) == 0 { - return "", fmt.Errorf("feishu_task update_task: no fields to update") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - body := larktask.NewPatchTaskReqBodyBuilder(). - Task(updateTask). - UpdateFields(updateFields). - Build() - - resp, err := t.client.Lark().Task.V2.Task.Patch(ctx, - larktask.NewPatchTaskReqBuilder(). - TaskGuid(guid). - UserIdType("open_id"). - Body(body). - Build(), - opts...) - if err != nil { - return fmt.Errorf("update task: %w", err) - } - if !resp.Success() { - return fmt.Errorf("update task: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"task": resp.Data.Task} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task update_task: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) completeTask(ctx context.Context, args map[string]any) (string, error) { - guid := stringArg(args, "task_guid") - if guid == "" { - return "", fmt.Errorf("feishu_task complete_task: task_guid is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - // Use current time as completed_at (milliseconds). - nowMs := fmt.Sprintf("%d", currentTimeMs()) - task := larktask.NewInputTaskBuilder(). - CompletedAt(nowMs). - Build() - body := larktask.NewPatchTaskReqBodyBuilder(). - Task(task). - UpdateFields([]string{"completed_at"}). - Build() - - resp, err := t.client.Lark().Task.V2.Task.Patch(ctx, - larktask.NewPatchTaskReqBuilder(). - TaskGuid(guid). - UserIdType("open_id"). - Body(body). - Build(), - opts...) - if err != nil { - return fmt.Errorf("complete task: %w", err) - } - if !resp.Success() { - return fmt.Errorf("complete task: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"task": resp.Data.Task} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task complete_task: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) createTasklist(ctx context.Context, args map[string]any) (string, error) { - summary := stringArg(args, "summary") - if summary == "" { - return "", fmt.Errorf("feishu_task create_tasklist: summary is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Task.V2.Tasklist.Create(ctx, - larktask.NewCreateTasklistReqBuilder(). - UserIdType("open_id"). - InputTasklist(larktask.NewInputTasklistBuilder().Name(summary).Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create tasklist: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create tasklist: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"tasklist": resp.Data.Tasklist} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task create_tasklist: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) listTasklists(ctx context.Context, args map[string]any) (string, error) { - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larktask.NewListTasklistReqBuilder(). - UserIdType("open_id") - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Task.V2.Tasklist.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list tasklists: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list tasklists: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = paginatedResultMap("tasklists", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task list_tasklists: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) createSubtask(ctx context.Context, args map[string]any) (string, error) { - parentGUID := stringArg(args, "task_guid") - if parentGUID == "" { - return "", fmt.Errorf("feishu_task create_subtask: task_guid (parent) is required") - } - summary := stringArg(args, "summary") - if summary == "" { - return "", fmt.Errorf("feishu_task create_subtask: summary is required") - } - - inputTask, err := buildInputTask(args, summary) - if err != nil { - return "", fmt.Errorf("feishu_task create_subtask: %w", err) - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Task.V2.TaskSubtask.Create(ctx, - larktask.NewCreateTaskSubtaskReqBuilder(). - TaskGuid(parentGUID). - UserIdType("open_id"). - InputTask(inputTask). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create subtask: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create subtask: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"task": resp.Data.Subtask} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task create_subtask: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *TaskTool) listSubtasks(ctx context.Context, args map[string]any) (string, error) { - parentGUID := stringArg(args, "task_guid") - if parentGUID == "" { - return "", fmt.Errorf("feishu_task list_subtasks: task_guid is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larktask.NewListTaskSubtaskReqBuilder(). - TaskGuid(parentGUID). - UserIdType("open_id") - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Task.V2.TaskSubtask.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list subtasks: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list subtasks: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = paginatedResultMap("tasks", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_task list_subtasks: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -// buildInputTask constructs an InputTask from tool args. -func buildInputTask(args map[string]any, summary string) (*larktask.InputTask, error) { - builder := larktask.NewInputTaskBuilder().Summary(summary) - - if desc := stringArg(args, "description"); desc != "" { - builder.Description(desc) - } - if due := mapArg(args, "due"); due != nil { - d, err := buildDue(due) - if err != nil { - return nil, fmt.Errorf("invalid due: %w", err) - } - builder.Due(d) - } - if start := mapArg(args, "start"); start != nil { - s, err := buildStart(start) - if err != nil { - return nil, fmt.Errorf("invalid start: %w", err) - } - builder.Start(s) - } - if members := sliceArg(args, "members"); len(members) > 0 { - builder.Members(buildTaskMembers(members)) - } - if rr := stringArg(args, "repeat_rule"); rr != "" { - builder.RepeatRule(rr) - } - if tl := stringArg(args, "tasklist_guid"); tl != "" { - builder.Tasklists([]*larktask.TaskInTasklistInfo{ - larktask.NewTaskInTasklistInfoBuilder().TasklistGuid(tl).Build(), - }) - } - - return builder.Build(), nil -} - -// buildUpdateTask constructs an InputTask and update_fields list for patch. -func buildUpdateTask(args map[string]any) (*larktask.InputTask, []string, error) { - builder := larktask.NewInputTaskBuilder() - var fields []string - - if v := stringArg(args, "summary"); v != "" { - builder.Summary(v) - fields = append(fields, "summary") - } - if v := stringArg(args, "description"); v != "" { - builder.Description(v) - fields = append(fields, "description") - } - if due := mapArg(args, "due"); due != nil { - d, err := buildDue(due) - if err != nil { - return nil, nil, fmt.Errorf("invalid due: %w", err) - } - builder.Due(d) - fields = append(fields, "due") - } - if start := mapArg(args, "start"); start != nil { - s, err := buildStart(start) - if err != nil { - return nil, nil, fmt.Errorf("invalid start: %w", err) - } - builder.Start(s) - fields = append(fields, "start") - } - if members := sliceArg(args, "members"); len(members) > 0 { - builder.Members(buildTaskMembers(members)) - fields = append(fields, "members") - } - if rr := stringArg(args, "repeat_rule"); rr != "" { - builder.RepeatRule(rr) - fields = append(fields, "repeat_rule") - } - - return builder.Build(), fields, nil -} - -func buildDue(m map[string]any) (*larktask.Due, error) { - ts, _ := m["timestamp"].(string) - if ts == "" { - return nil, fmt.Errorf("timestamp is required") - } - msVal, err := ParseTimeToUnixMs(ts) - if err != nil { - return nil, err - } - builder := larktask.NewDueBuilder().Timestamp(strconv.FormatInt(msVal, 10)) - if allDay, ok := m["is_all_day"].(bool); ok { - builder.IsAllDay(allDay) - } - return builder.Build(), nil -} - -func buildStart(m map[string]any) (*larktask.Start, error) { - ts, _ := m["timestamp"].(string) - if ts == "" { - return nil, fmt.Errorf("timestamp is required") - } - msVal, err := ParseTimeToUnixMs(ts) - if err != nil { - return nil, err - } - builder := larktask.NewStartBuilder().Timestamp(strconv.FormatInt(msVal, 10)) - if allDay, ok := m["is_all_day"].(bool); ok { - builder.IsAllDay(allDay) - } - return builder.Build(), nil -} - -func buildTaskMembers(raw []any) []*larktask.Member { - var members []*larktask.Member - for _, item := range raw { - if m, ok := item.(map[string]any); ok { - id, _ := m["id"].(string) - if id == "" { - continue - } - role, _ := m["role"].(string) - if role == "" { - role = "assignee" - } - members = append(members, larktask.NewMemberBuilder(). - Id(id). - Type("user"). - Role(role). - Build()) - } - } - return members -} - -// currentTimeMs returns the current time in Unix milliseconds. -// Extracted as a package-level var for testability. -var currentTimeMs = func() int64 { return time.Now().UnixMilli() } diff --git a/internal/feishutool/task_test.go b/internal/feishutool/task_test.go deleted file mode 100644 index ab93c32c..00000000 --- a/internal/feishutool/task_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestTaskToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - def := tool.Definition() - if def.Name != "feishu_task" { - t.Fatalf("expected name feishu_task, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "create_task": true, - "list_tasks": true, - "get_task": true, - "update_task": true, - "complete_task": true, - "create_tasklist": true, - "list_tasklists": true, - "create_subtask": true, - "list_subtasks": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestTaskToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestTaskToolCreateTaskMissingSummary(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_task", - }) - if err == nil { - t.Fatal("expected error for missing summary") - } -} - -func TestTaskToolGetTaskMissingGUID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_task", - }) - if err == nil { - t.Fatal("expected error for missing task_guid") - } -} - -func TestTaskToolUpdateTaskMissingGUID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "update_task", - }) - if err == nil { - t.Fatal("expected error for missing task_guid") - } -} - -func TestTaskToolUpdateTaskNoFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "update_task", - "task_guid": "some-guid", - }) - if err == nil { - t.Fatal("expected error for no fields to update") - } -} - -func TestTaskToolCompleteTaskMissingGUID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "complete_task", - }) - if err == nil { - t.Fatal("expected error for missing task_guid") - } -} - -func TestTaskToolCreateTasklistMissingSummary(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_tasklist", - }) - if err == nil { - t.Fatal("expected error for missing summary") - } -} - -func TestTaskToolCreateSubtaskMissingFields(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - // Missing task_guid. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_subtask", - "summary": "Sub", - }) - if err == nil { - t.Fatal("expected error for missing task_guid") - } - - // Missing summary. - _, err = tool.Execute(context.Background(), map[string]any{ - "action": "create_subtask", - "task_guid": "some-guid", - }) - if err == nil { - t.Fatal("expected error for missing summary") - } -} - -func TestTaskToolListSubtasksMissingGUID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewTaskTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "list_subtasks", - }) - if err == nil { - t.Fatal("expected error for missing task_guid") - } -} - -func TestBuildInputTask(t *testing.T) { - args := map[string]any{ - "description": "desc", - "repeat_rule": "FREQ=DAILY", - "tasklist_guid": "tl_123", - "due": map[string]any{ - "timestamp": "2024-01-01T18:00:00+08:00", - "is_all_day": false, - }, - "members": []any{ - map[string]any{"id": "ou_user1", "role": "assignee"}, - map[string]any{"id": "ou_user2"}, // default role - }, - } - - task, err := buildInputTask(args, "Test Task") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if task == nil { - t.Fatal("expected non-nil task") - } - if task.Summary == nil || *task.Summary != "Test Task" { - t.Fatal("expected summary to be set") - } - if task.Description == nil || *task.Description != "desc" { - t.Fatal("expected description to be set") - } - if task.Due == nil || task.Due.Timestamp == nil { - t.Fatal("expected due to be set") - } - if len(task.Members) != 2 { - t.Fatalf("expected 2 members, got %d", len(task.Members)) - } - if task.Tasklists == nil || len(task.Tasklists) != 1 { - t.Fatal("expected 1 tasklist") - } -} - -func TestBuildInputTaskInvalidDue(t *testing.T) { - args := map[string]any{ - "due": map[string]any{ - "timestamp": "not-a-time", - }, - } - - _, err := buildInputTask(args, "Test") - if err == nil { - t.Fatal("expected error for invalid due timestamp") - } -} - -func TestBuildUpdateTask(t *testing.T) { - args := map[string]any{ - "summary": "Updated", - "description": "New desc", - } - - task, fields, err := buildUpdateTask(args) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(fields) != 2 { - t.Fatalf("expected 2 update fields, got %d", len(fields)) - } - if task.Summary == nil || *task.Summary != "Updated" { - t.Fatal("expected summary to be set") - } -} - -func TestBuildTaskMembers(t *testing.T) { - raw := []any{ - map[string]any{"id": "ou_1", "role": "follower"}, - map[string]any{"id": "ou_2"}, // default role - map[string]any{"id": ""}, // skipped - map[string]any{"name": "no id"}, // skipped - } - - members := buildTaskMembers(raw) - if len(members) != 2 { - t.Fatalf("expected 2 members, got %d", len(members)) - } - if *members[0].Role != "follower" { - t.Fatalf("expected follower role, got %q", *members[0].Role) - } - if *members[1].Role != "assignee" { - t.Fatalf("expected assignee default role, got %q", *members[1].Role) - } -} diff --git a/internal/feishutool/token_store.go b/internal/feishutool/token_store.go deleted file mode 100644 index 48514ed7..00000000 --- a/internal/feishutool/token_store.go +++ /dev/null @@ -1,189 +0,0 @@ -package feishutool - -import ( - "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "database/sql" - "encoding/base64" - "fmt" - "io" - "time" - - "golang.org/x/crypto/hkdf" - - "github.com/vaayne/anna/internal/db/sqlc" -) - -const ( - // hkdfSalt is the constant salt for HKDF key derivation. - hkdfSalt = "feishu-token-encryption" - // hkdfInfo is the info string for HKDF key derivation. - hkdfInfo = "feishu-uat-v1" - // timeLayout is the ISO 8601 layout used for token expiry times. - timeLayout = time.RFC3339 -) - -// Token holds a Feishu user access token pair with expiry information. -type Token struct { - AccessToken string - RefreshToken string - ExpiresAt time.Time - RefreshExpiresAt time.Time -} - -// IsExpired returns true if the access token has expired. -func (t Token) IsExpired() bool { - return time.Now().After(t.ExpiresAt) -} - -// IsRefreshExpired returns true if the refresh token has expired. -func (t Token) IsRefreshExpired() bool { - return time.Now().After(t.RefreshExpiresAt) -} - -// TokenStore manages Feishu user access tokens. -type TokenStore interface { - Get(ctx context.Context, openID string) (Token, error) - Set(ctx context.Context, openID string, token Token) error - Delete(ctx context.Context, openID string) error -} - -// SQLiteTokenStore implements TokenStore using SQLite with AES-256-GCM encryption. -type SQLiteTokenStore struct { - queries *sqlc.Queries - gcm cipher.AEAD -} - -// NewSQLiteTokenStore creates a new SQLite-backed token store. -// The encryption key is derived from appSecret using HKDF with SHA-256. -func NewSQLiteTokenStore(db *sql.DB, appSecret string) (*SQLiteTokenStore, error) { - key, err := deriveKey(appSecret) - if err != nil { - return nil, fmt.Errorf("feishutool: derive encryption key: %w", err) - } - - block, err := aes.NewCipher(key) - if err != nil { - return nil, fmt.Errorf("feishutool: create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("feishutool: create GCM: %w", err) - } - - return &SQLiteTokenStore{ - queries: sqlc.New(db), - gcm: gcm, - }, nil -} - -// Get retrieves and decrypts a token for the given open_id. -// Returns sql.ErrNoRows if no token is stored for this user. -func (s *SQLiteTokenStore) Get(ctx context.Context, openID string) (Token, error) { - row, err := s.queries.GetFeishuToken(ctx, openID) - if err != nil { - return Token{}, err - } - - accessToken, err := s.decrypt(row.AccessToken) - if err != nil { - return Token{}, fmt.Errorf("feishutool: decrypt access token: %w", err) - } - - refreshToken, err := s.decrypt(row.RefreshToken) - if err != nil { - return Token{}, fmt.Errorf("feishutool: decrypt refresh token: %w", err) - } - - expiresAt, err := time.Parse(timeLayout, row.ExpiresAt) - if err != nil { - return Token{}, fmt.Errorf("feishutool: parse expires_at: %w", err) - } - - refreshExpiresAt, err := time.Parse(timeLayout, row.RefreshExpiresAt) - if err != nil { - return Token{}, fmt.Errorf("feishutool: parse refresh_expires_at: %w", err) - } - - return Token{ - AccessToken: accessToken, - RefreshToken: refreshToken, - ExpiresAt: expiresAt, - RefreshExpiresAt: refreshExpiresAt, - }, nil -} - -// Set encrypts and stores a token for the given open_id. -// Upserts if a token already exists for this user. -func (s *SQLiteTokenStore) Set(ctx context.Context, openID string, token Token) error { - encAccessToken, err := s.encrypt(token.AccessToken) - if err != nil { - return fmt.Errorf("feishutool: encrypt access token: %w", err) - } - - encRefreshToken, err := s.encrypt(token.RefreshToken) - if err != nil { - return fmt.Errorf("feishutool: encrypt refresh token: %w", err) - } - - return s.queries.UpsertFeishuToken(ctx, sqlc.UpsertFeishuTokenParams{ - OpenID: openID, - AccessToken: encAccessToken, - RefreshToken: encRefreshToken, - ExpiresAt: token.ExpiresAt.Format(timeLayout), - RefreshExpiresAt: token.RefreshExpiresAt.Format(timeLayout), - }) -} - -// Delete removes a stored token for the given open_id. -func (s *SQLiteTokenStore) Delete(ctx context.Context, openID string) error { - return s.queries.DeleteFeishuToken(ctx, openID) -} - -// encrypt encrypts plaintext using AES-256-GCM and returns base64-encoded ciphertext. -// The nonce is prepended to the ciphertext before encoding. -func (s *SQLiteTokenStore) encrypt(plaintext string) (string, error) { - nonce := make([]byte, s.gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", fmt.Errorf("generate nonce: %w", err) - } - - ciphertext := s.gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -// decrypt decodes base64 ciphertext and decrypts it using AES-256-GCM. -// Expects nonce to be prepended to the ciphertext. -func (s *SQLiteTokenStore) decrypt(encoded string) (string, error) { - data, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", fmt.Errorf("base64 decode: %w", err) - } - - nonceSize := s.gcm.NonceSize() - if len(data) < nonceSize { - return "", fmt.Errorf("ciphertext too short") - } - - nonce, ciphertext := data[:nonceSize], data[nonceSize:] - plaintext, err := s.gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", fmt.Errorf("decrypt: %w", err) - } - - return string(plaintext), nil -} - -// deriveKey derives a 32-byte AES key from appSecret using HKDF with SHA-256. -func deriveKey(appSecret string) ([]byte, error) { - reader := hkdf.New(sha256.New, []byte(appSecret), []byte(hkdfSalt), []byte(hkdfInfo)) - key := make([]byte, 32) // AES-256 - if _, err := io.ReadFull(reader, key); err != nil { - return nil, err - } - return key, nil -} diff --git a/internal/feishutool/token_store_test.go b/internal/feishutool/token_store_test.go deleted file mode 100644 index b7b27e8a..00000000 --- a/internal/feishutool/token_store_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package feishutool - -import ( - "context" - "database/sql" - "testing" - "time" - - _ "modernc.org/sqlite" -) - -func setupTestDB(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - _, err = db.Exec(`CREATE TABLE feishu_tokens ( - open_id TEXT PRIMARY KEY, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expires_at TEXT NOT NULL, - refresh_expires_at TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - )`) - if err != nil { - t.Fatalf("create table: %v", err) - } - return db -} - -func TestNewSQLiteTokenStore(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "test-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - if store == nil { - t.Fatal("store is nil") - } -} - -func TestTokenStoreSetAndGet(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "test-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - - ctx := context.Background() - now := time.Now().Truncate(time.Second) - token := Token{ - AccessToken: "access-token-123", - RefreshToken: "refresh-token-456", - ExpiresAt: now.Add(2 * time.Hour), - RefreshExpiresAt: now.Add(30 * 24 * time.Hour), - } - - if err := store.Set(ctx, "ou_test123", token); err != nil { - t.Fatalf("Set: %v", err) - } - - got, err := store.Get(ctx, "ou_test123") - if err != nil { - t.Fatalf("Get: %v", err) - } - - if got.AccessToken != token.AccessToken { - t.Errorf("AccessToken = %q, want %q", got.AccessToken, token.AccessToken) - } - if got.RefreshToken != token.RefreshToken { - t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, token.RefreshToken) - } - // Compare with 1-second precision due to RFC3339 formatting. - if got.ExpiresAt.Unix() != token.ExpiresAt.Unix() { - t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, token.ExpiresAt) - } - if got.RefreshExpiresAt.Unix() != token.RefreshExpiresAt.Unix() { - t.Errorf("RefreshExpiresAt = %v, want %v", got.RefreshExpiresAt, token.RefreshExpiresAt) - } -} - -func TestTokenStoreUpsert(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "test-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - - ctx := context.Background() - now := time.Now().Truncate(time.Second) - - token1 := Token{ - AccessToken: "access-1", - RefreshToken: "refresh-1", - ExpiresAt: now.Add(1 * time.Hour), - RefreshExpiresAt: now.Add(24 * time.Hour), - } - if err := store.Set(ctx, "ou_user", token1); err != nil { - t.Fatalf("Set(1): %v", err) - } - - token2 := Token{ - AccessToken: "access-2", - RefreshToken: "refresh-2", - ExpiresAt: now.Add(2 * time.Hour), - RefreshExpiresAt: now.Add(48 * time.Hour), - } - if err := store.Set(ctx, "ou_user", token2); err != nil { - t.Fatalf("Set(2): %v", err) - } - - got, err := store.Get(ctx, "ou_user") - if err != nil { - t.Fatalf("Get: %v", err) - } - if got.AccessToken != "access-2" { - t.Errorf("AccessToken = %q, want %q", got.AccessToken, "access-2") - } -} - -func TestTokenStoreDelete(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "test-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - - ctx := context.Background() - token := Token{ - AccessToken: "access", - RefreshToken: "refresh", - ExpiresAt: time.Now().Add(time.Hour), - RefreshExpiresAt: time.Now().Add(24 * time.Hour), - } - if err := store.Set(ctx, "ou_del", token); err != nil { - t.Fatalf("Set: %v", err) - } - - if err := store.Delete(ctx, "ou_del"); err != nil { - t.Fatalf("Delete: %v", err) - } - - _, err = store.Get(ctx, "ou_del") - if err == nil { - t.Fatal("expected error after delete") - } -} - -func TestTokenStoreGetNotFound(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "test-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - - _, err = store.Get(context.Background(), "nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent token") - } -} - -func TestTokenExpiry(t *testing.T) { - now := time.Now() - - // Valid token. - valid := Token{ - AccessToken: "a", - RefreshToken: "r", - ExpiresAt: now.Add(time.Hour), - RefreshExpiresAt: now.Add(24 * time.Hour), - } - if valid.IsExpired() { - t.Error("valid token should not be expired") - } - if valid.IsRefreshExpired() { - t.Error("valid token refresh should not be expired") - } - - // Expired access token, valid refresh. - expiredAccess := Token{ - AccessToken: "a", - RefreshToken: "r", - ExpiresAt: now.Add(-time.Hour), - RefreshExpiresAt: now.Add(24 * time.Hour), - } - if !expiredAccess.IsExpired() { - t.Error("expired access token should be expired") - } - if expiredAccess.IsRefreshExpired() { - t.Error("refresh token should not be expired") - } - - // Both expired. - allExpired := Token{ - AccessToken: "a", - RefreshToken: "r", - ExpiresAt: now.Add(-2 * time.Hour), - RefreshExpiresAt: now.Add(-time.Hour), - } - if !allExpired.IsExpired() { - t.Error("access token should be expired") - } - if !allExpired.IsRefreshExpired() { - t.Error("refresh token should be expired") - } -} - -func TestEncryptDecryptRoundTrip(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "my-app-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - - testCases := []string{ - "simple-token", - "", - "a", - "very-long-token-" + string(make([]byte, 1000)), - "unicode-日本語-中文-한국어", - "special chars: !@#$%^&*()", - } - - for _, tc := range testCases { - encrypted, err := store.encrypt(tc) - if err != nil { - t.Errorf("encrypt(%q): %v", tc, err) - continue - } - decrypted, err := store.decrypt(encrypted) - if err != nil { - t.Errorf("decrypt(%q): %v", tc, err) - continue - } - if decrypted != tc { - t.Errorf("round-trip failed: got %q, want %q", decrypted, tc) - } - } -} - -func TestEncryptProducesDifferentCiphertext(t *testing.T) { - db := setupTestDB(t) - store, err := NewSQLiteTokenStore(db, "test-secret") - if err != nil { - t.Fatalf("NewSQLiteTokenStore: %v", err) - } - - // Same plaintext should produce different ciphertext due to random nonce. - c1, _ := store.encrypt("same-token") - c2, _ := store.encrypt("same-token") - if c1 == c2 { - t.Error("same plaintext should produce different ciphertext (random nonce)") - } -} - -func TestDecryptWithWrongKey(t *testing.T) { - db := setupTestDB(t) - store1, _ := NewSQLiteTokenStore(db, "secret-1") - store2, _ := NewSQLiteTokenStore(db, "secret-2") - - encrypted, err := store1.encrypt("my-token") - if err != nil { - t.Fatalf("encrypt: %v", err) - } - - _, err = store2.decrypt(encrypted) - if err == nil { - t.Fatal("expected error decrypting with wrong key") - } -} - -func TestDeriveKeyDeterministic(t *testing.T) { - k1, err := deriveKey("test-secret") - if err != nil { - t.Fatalf("deriveKey: %v", err) - } - k2, err := deriveKey("test-secret") - if err != nil { - t.Fatalf("deriveKey: %v", err) - } - - if string(k1) != string(k2) { - t.Error("same input should produce same key") - } - - k3, err := deriveKey("different-secret") - if err != nil { - t.Fatalf("deriveKey: %v", err) - } - if string(k1) == string(k3) { - t.Error("different input should produce different key") - } -} - -func TestNeedAuthError(t *testing.T) { - err := &NeedAuthError{OpenID: "ou_test"} - if err.Error() == "" { - t.Error("error message should not be empty") - } -} diff --git a/internal/feishutool/user.go b/internal/feishutool/user.go deleted file mode 100644 index 18386b1e..00000000 --- a/internal/feishutool/user.go +++ /dev/null @@ -1,221 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" - "github.com/vaayne/anna/internal/toolspec" -) - -var userInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["get_user", "search_user"], - "description": "The action to perform" - }, - "open_id": { - "type": "string", - "description": "The user's open_id (required for get_user)" - }, - "emails": { - "type": "array", - "items": {"type": "string"}, - "description": "Email addresses to search (for search_user, max 50)" - }, - "mobiles": { - "type": "array", - "items": {"type": "string"}, - "description": "Mobile numbers to search (for search_user, max 50). Non-China numbers need '+' country code prefix" - } - }, - "required": ["action"] -}`) - -// UserTool provides Feishu user lookup capabilities. -// Actions: get_user (by open_id), search_user (by email/mobile). -type UserTool struct { - client *Client -} - -// NewUserTool creates a feishu_user tool. -func NewUserTool(client *Client) *UserTool { - return &UserTool{client: client} -} - -func (t *UserTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_user", - Description: `Look up Feishu/Lark users. - -Actions: -- get_user: Get a user's profile by open_id. Returns name, email, department, avatar, status, etc. -- search_user: Find users by email or mobile number. Returns matching user IDs. Useful for resolving a person's identity before other Feishu operations.`, - InputSchema: userInputSchema, - } -} - -func (t *UserTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "get_user": - return t.getUser(ctx, args) - case "search_user": - return t.searchUser(ctx, args) - default: - return "", fmt.Errorf("feishu_user: unknown action %q, expected get_user/search_user", action) - } -} - -func (t *UserTool) getUser(ctx context.Context, args map[string]any) (string, error) { - openID := stringArg(args, "open_id") - if openID == "" { - // Fall back to context open_id. - openID = OpenIDFromContext(ctx) - } - if openID == "" { - return "", fmt.Errorf("feishu_user get_user: open_id is required") - } - - if err := t.client.Wait(ctx); err != nil { - return "", err - } - - resp, err := t.client.Lark().Contact.User.Get(ctx, - larkcontact.NewGetUserReqBuilder(). - UserId(openID). - UserIdType("open_id"). - Build()) - if err != nil { - return "", fmt.Errorf("feishu_user get_user: %w", err) - } - if !resp.Success() { - return "", fmt.Errorf("feishu_user get_user: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - if resp.Data == nil || resp.Data.User == nil { - return "User not found.", nil - } - - return JSONResultFromAny(formatUser(resp.Data.User)) -} - -func (t *UserTool) searchUser(ctx context.Context, args map[string]any) (string, error) { - emails := toStringSlice(args, "emails") - mobiles := toStringSlice(args, "mobiles") - - if len(emails) == 0 && len(mobiles) == 0 { - return "", fmt.Errorf("feishu_user search_user: at least one of emails or mobiles is required") - } - - if err := t.client.Wait(ctx); err != nil { - return "", err - } - - bodyBuilder := larkcontact.NewBatchGetIdUserReqBodyBuilder() - if len(emails) > 0 { - bodyBuilder.Emails(emails) - } - if len(mobiles) > 0 { - bodyBuilder.Mobiles(mobiles) - } - - resp, err := t.client.Lark().Contact.User.BatchGetId(ctx, - larkcontact.NewBatchGetIdUserReqBuilder(). - UserIdType("open_id"). - Body(bodyBuilder.Build()). - Build()) - if err != nil { - return "", fmt.Errorf("feishu_user search_user: %w", err) - } - if !resp.Success() { - return "", fmt.Errorf("feishu_user search_user: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - if resp.Data == nil || len(resp.Data.UserList) == 0 { - return "No users found.", nil - } - - results := make([]map[string]any, 0, len(resp.Data.UserList)) - for _, u := range resp.Data.UserList { - m := map[string]any{} - if u.UserId != nil { - m["open_id"] = *u.UserId - } - if u.Email != nil { - m["email"] = *u.Email - } - if u.Mobile != nil { - m["mobile"] = *u.Mobile - } - results = append(results, m) - } - - return JSONResultFromAny(results) -} - -// formatUser extracts key fields from a Lark User into a clean map. -func formatUser(u *larkcontact.User) map[string]any { - m := map[string]any{} - if v := derefStr(u.OpenId); v != "" { - m["open_id"] = v - } - if v := derefStr(u.UserId); v != "" { - m["user_id"] = v - } - if v := derefStr(u.Name); v != "" { - m["name"] = v - } - if v := derefStr(u.EnName); v != "" { - m["en_name"] = v - } - if v := derefStr(u.Nickname); v != "" { - m["nickname"] = v - } - if v := derefStr(u.Email); v != "" { - m["email"] = v - } - if v := derefStr(u.Mobile); v != "" { - m["mobile"] = v - } - if u.Avatar != nil { - if v := derefStr(u.Avatar.AvatarOrigin); v != "" { - m["avatar"] = v - } - } - if u.Status != nil { - status := map[string]any{} - if u.Status.IsFrozen != nil { - status["is_frozen"] = *u.Status.IsFrozen - } - if u.Status.IsResigned != nil { - status["is_resigned"] = *u.Status.IsResigned - } - if u.Status.IsActivated != nil { - status["is_activated"] = *u.Status.IsActivated - } - m["status"] = status - } - if v := derefStr(u.Description); v != "" { - m["description"] = v - } - if v := derefStr(u.City); v != "" { - m["city"] = v - } - if v := derefStr(u.Country); v != "" { - m["country"] = v - } - if v := derefInt(u.Gender); v != 0 { - switch v { - case 1: - m["gender"] = "male" - case 2: - m["gender"] = "female" - default: - m["gender"] = "other" - } - } - return m -} diff --git a/internal/feishutool/user_test.go b/internal/feishutool/user_test.go deleted file mode 100644 index f0f1f0c9..00000000 --- a/internal/feishutool/user_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" - larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" -) - -func TestUserToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewUserTool(client) - - def := tool.Definition() - if def.Name != "feishu_user" { - t.Fatalf("expected name feishu_user, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - // Verify schema has required action field. - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - if _, ok := props["action"]; !ok { - t.Fatal("expected action in properties") - } -} - -func TestUserToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewUserTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestUserToolGetUserMissingOpenID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewUserTool(client) - - // No open_id in args or context. - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_user", - }) - if err == nil { - t.Fatal("expected error for missing open_id") - } -} - -func TestUserToolSearchUserMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewUserTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "search_user", - }) - if err == nil { - t.Fatal("expected error for missing emails/mobiles") - } -} - -func TestUserToolGetUserFallbackToContext(t *testing.T) { - // This test verifies the context fallback logic for open_id. - // It will fail at the API call (fake client), but we verify - // it gets past the open_id validation. - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewUserTool(client) - - ctx := WithOpenID(context.Background(), "ou_context_id") - _, err := tool.Execute(ctx, map[string]any{ - "action": "get_user", - }) - // Should fail at the API call, not at validation. - if err == nil { - t.Fatal("expected API error, not nil") - } - // Should not be the "open_id is required" error. - if err.Error() == "feishu_user get_user: open_id is required" { - t.Fatal("should have used context open_id") - } -} - -func TestFormatUser(t *testing.T) { - name := "Alice" - openID := "ou_abc" - email := "alice@example.com" - city := "Shanghai" - - u := &larkcontact.User{ - Name: &name, - OpenId: &openID, - Email: &email, - City: &city, - } - - m := formatUser(u) - if m["name"] != "Alice" { - t.Fatalf("expected Alice, got %v", m["name"]) - } - if m["open_id"] != "ou_abc" { - t.Fatalf("expected ou_abc, got %v", m["open_id"]) - } - if m["email"] != "alice@example.com" { - t.Fatalf("expected alice@example.com, got %v", m["email"]) - } - if m["city"] != "Shanghai" { - t.Fatalf("expected Shanghai, got %v", m["city"]) - } -} - -func TestFormatUserEmpty(t *testing.T) { - u := &larkcontact.User{} - m := formatUser(u) - if len(m) != 0 { - t.Fatalf("expected empty map for empty user, got %v", m) - } -} - -func TestToStringSlice(t *testing.T) { - tests := []struct { - name string - args map[string]any - key string - want int - }{ - {"from []any", map[string]any{"emails": []any{"a@b.com", "c@d.com"}}, "emails", 2}, - {"from []string", map[string]any{"emails": []string{"a@b.com"}}, "emails", 1}, - {"missing key", map[string]any{}, "emails", 0}, - {"wrong type", map[string]any{"emails": 42}, "emails", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := toStringSlice(tt.args, tt.key) - if len(got) != tt.want { - t.Fatalf("expected %d items, got %d", tt.want, len(got)) - } - }) - } -} diff --git a/internal/feishutool/wiki.go b/internal/feishutool/wiki.go deleted file mode 100644 index a3f528c7..00000000 --- a/internal/feishutool/wiki.go +++ /dev/null @@ -1,360 +0,0 @@ -package feishutool - -import ( - "context" - "fmt" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - larkwiki "github.com/larksuite/oapi-sdk-go/v3/service/wiki/v2" - "github.com/vaayne/anna/internal/toolspec" -) - -var wikiInputSchema = mustParseSchema(`{ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["list_spaces", "get_space", "create_space_node", "list_space_nodes", "get_node", "move_node", "copy_node"], - "description": "The action to perform" - }, - "space_id": { - "type": "string", - "description": "Wiki space ID (required for get_space, create_space_node, list_space_nodes, move_node, copy_node)" - }, - "node_token": { - "type": "string", - "description": "Node token (required for get_node, move_node, copy_node)" - }, - "parent_node_token": { - "type": "string", - "description": "Parent node token (optional for list_space_nodes to list children, for create_space_node)" - }, - "obj_type": { - "type": "string", - "enum": ["doc", "sheet", "mindnote", "bitable", "file", "docx", "slides"], - "description": "Object type for create_space_node (required) or get_node (optional, default 'wiki')" - }, - "node_type": { - "type": "string", - "enum": ["origin", "shortcut"], - "description": "Node type for create_space_node: origin (new doc) or shortcut (link to existing)" - }, - "title": { - "type": "string", - "description": "Title for create_space_node or copy_node" - }, - "target_parent_token": { - "type": "string", - "description": "Target parent node token for move_node or copy_node" - }, - "target_space_id": { - "type": "string", - "description": "Target space ID for copy_node (optional, defaults to same space)" - }, - "page_size": { - "type": "number", - "description": "Page size (default 50)" - }, - "page_token": { - "type": "string", - "description": "Pagination token" - } - }, - "required": ["action"] -}`) - -// WikiTool provides Feishu wiki space and node management. -type WikiTool struct { - client *Client -} - -// NewWikiTool creates a feishu_wiki tool. -func NewWikiTool(client *Client) *WikiTool { - return &WikiTool{client: client} -} - -func (t *WikiTool) Definition() toolspec.Definition { - return toolspec.Definition{ - Name: "feishu_wiki", - Description: `Manage Feishu/Lark wiki spaces and nodes. Uses user token when available. - -A wiki space contains hierarchical document nodes. Nodes can be docs, sheets, bitables, etc. - -Actions: -- list_spaces: List all wiki spaces accessible to the user. -- get_space: Get wiki space details. Requires space_id. -- create_space_node: Create a node in a wiki space. Requires space_id, obj_type (docx/sheet/bitable/etc), node_type (origin/shortcut). Optional: parent_node_token, title. -- list_space_nodes: List nodes in a wiki space. Requires space_id. Optional: parent_node_token (for children of a specific node). -- get_node: Get node details (resolves wiki token to actual document token). Requires node_token. Optional: obj_type. -- move_node: Move a node within a wiki space. Requires space_id, node_token. Optional: target_parent_token. -- copy_node: Copy a node. Requires space_id, node_token. Optional: target_space_id, target_parent_token, title. - -node_token is the wiki node identifier. Use get_node to resolve it to the actual document obj_token for use with other tools (feishu_doc, feishu_sheets, etc).`, - InputSchema: wikiInputSchema, - } -} - -func (t *WikiTool) Execute(ctx context.Context, args map[string]any) (string, error) { - action := stringArg(args, "action") - switch action { - case "list_spaces": - return t.listSpaces(ctx, args) - case "get_space": - return t.getSpace(ctx, args) - case "create_space_node": - return t.createSpaceNode(ctx, args) - case "list_space_nodes": - return t.listSpaceNodes(ctx, args) - case "get_node": - return t.getNode(ctx, args) - case "move_node": - return t.moveNode(ctx, args) - case "copy_node": - return t.copyNode(ctx, args) - default: - return "", fmt.Errorf("feishu_wiki: unknown action %q", action) - } -} - -func (t *WikiTool) listSpaces(ctx context.Context, args map[string]any) (string, error) { - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkwiki.NewListSpaceReqBuilder() - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Wiki.Space.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list spaces: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list spaces: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("spaces", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki list_spaces: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *WikiTool) getSpace(ctx context.Context, args map[string]any) (string, error) { - spaceID := stringArg(args, "space_id") - if spaceID == "" { - return "", fmt.Errorf("feishu_wiki get_space: space_id is required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Wiki.Space.Get(ctx, - larkwiki.NewGetSpaceReqBuilder(). - SpaceId(spaceID). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get space: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get space: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"space": resp.Data.Space} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki get_space: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *WikiTool) createSpaceNode(ctx context.Context, args map[string]any) (string, error) { - spaceID := stringArg(args, "space_id") - objType := stringArg(args, "obj_type") - nodeType := stringArg(args, "node_type") - if spaceID == "" || objType == "" || nodeType == "" { - return "", fmt.Errorf("feishu_wiki create_space_node: space_id, obj_type, and node_type are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - nodeBuilder := larkwiki.NewNodeBuilder(). - ObjType(objType). - NodeType(nodeType) - if parent := stringArg(args, "parent_node_token"); parent != "" { - nodeBuilder.ParentNodeToken(parent) - } - if title := stringArg(args, "title"); title != "" { - nodeBuilder.Title(title) - } - - resp, err := t.client.Lark().Wiki.SpaceNode.Create(ctx, - larkwiki.NewCreateSpaceNodeReqBuilder(). - SpaceId(spaceID). - Node(nodeBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("create space node: %w", err) - } - if !resp.Success() { - return fmt.Errorf("create space node: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"node": resp.Data.Node} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki create_space_node: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *WikiTool) listSpaceNodes(ctx context.Context, args map[string]any) (string, error) { - spaceID := stringArg(args, "space_id") - if spaceID == "" { - return "", fmt.Errorf("feishu_wiki list_space_nodes: space_id is required") - } - - var result map[string]any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkwiki.NewListSpaceNodeReqBuilder(). - SpaceId(spaceID) - if parent := stringArg(args, "parent_node_token"); parent != "" { - builder.ParentNodeToken(parent) - } - if ps := intArg(args, "page_size"); ps > 0 { - builder.PageSize(ps) - } - if pt := stringArg(args, "page_token"); pt != "" { - builder.PageToken(pt) - } - - resp, err := t.client.Lark().Wiki.SpaceNode.List(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("list space nodes: %w", err) - } - if !resp.Success() { - return fmt.Errorf("list space nodes: %s", FormatLarkError(resp.Code, resp.Msg)) - } - - result = paginatedResultMap("nodes", resp.Data.Items, resp.Data.HasMore, resp.Data.PageToken) - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki list_space_nodes: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *WikiTool) getNode(ctx context.Context, args map[string]any) (string, error) { - token := stringArg(args, "node_token") - if token == "" { - return "", fmt.Errorf("feishu_wiki get_node: node_token is required") - } - objType := stringArg(args, "obj_type") - if objType == "" { - objType = "wiki" - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - resp, err := t.client.Lark().Wiki.Space.GetNode(ctx, - larkwiki.NewGetNodeSpaceReqBuilder(). - Token(token). - ObjType(objType). - Build(), - opts...) - if err != nil { - return fmt.Errorf("get node: %w", err) - } - if !resp.Success() { - return fmt.Errorf("get node: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"node": resp.Data.Node} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki get_node: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *WikiTool) moveNode(ctx context.Context, args map[string]any) (string, error) { - spaceID := stringArg(args, "space_id") - nodeToken := stringArg(args, "node_token") - if spaceID == "" || nodeToken == "" { - return "", fmt.Errorf("feishu_wiki move_node: space_id and node_token are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - builder := larkwiki.NewMoveSpaceNodeReqBuilder(). - SpaceId(spaceID). - NodeToken(nodeToken) - if target := stringArg(args, "target_parent_token"); target != "" { - builder.Body(larkwiki.NewMoveSpaceNodeReqBodyBuilder(). - TargetParentToken(target). - Build()) - } - - resp, err := t.client.Lark().Wiki.SpaceNode.Move(ctx, builder.Build(), opts...) - if err != nil { - return fmt.Errorf("move node: %w", err) - } - if !resp.Success() { - return fmt.Errorf("move node: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"node": resp.Data.Node} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki move_node: %w", invokeErr) - } - return JSONResultFromAny(result) -} - -func (t *WikiTool) copyNode(ctx context.Context, args map[string]any) (string, error) { - spaceID := stringArg(args, "space_id") - nodeToken := stringArg(args, "node_token") - if spaceID == "" || nodeToken == "" { - return "", fmt.Errorf("feishu_wiki copy_node: space_id and node_token are required") - } - - var result any - invokeErr := t.client.InvokeWithUserToken(ctx, false, func(ctx context.Context, opts ...larkcore.RequestOptionFunc) error { - bodyBuilder := larkwiki.NewCopySpaceNodeReqBodyBuilder() - if target := stringArg(args, "target_space_id"); target != "" { - bodyBuilder.TargetSpaceId(target) - } - if target := stringArg(args, "target_parent_token"); target != "" { - bodyBuilder.TargetParentToken(target) - } - if title := stringArg(args, "title"); title != "" { - bodyBuilder.Title(title) - } - - resp, err := t.client.Lark().Wiki.SpaceNode.Copy(ctx, - larkwiki.NewCopySpaceNodeReqBuilder(). - SpaceId(spaceID). - NodeToken(nodeToken). - Body(bodyBuilder.Build()). - Build(), - opts...) - if err != nil { - return fmt.Errorf("copy node: %w", err) - } - if !resp.Success() { - return fmt.Errorf("copy node: %s", FormatLarkError(resp.Code, resp.Msg)) - } - result = map[string]any{"node": resp.Data.Node} - return nil - }) - if invokeErr != nil { - return "", fmt.Errorf("feishu_wiki copy_node: %w", invokeErr) - } - return JSONResultFromAny(result) -} diff --git a/internal/feishutool/wiki_test.go b/internal/feishutool/wiki_test.go deleted file mode 100644 index fc59f960..00000000 --- a/internal/feishutool/wiki_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package feishutool - -import ( - "context" - "testing" - - lark "github.com/larksuite/oapi-sdk-go/v3" -) - -func TestWikiToolDefinition(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - def := tool.Definition() - if def.Name != "feishu_wiki" { - t.Fatalf("expected name feishu_wiki, got %q", def.Name) - } - if def.Description == "" { - t.Fatal("expected non-empty description") - } - if def.InputSchema == nil { - t.Fatal("expected non-nil input schema") - } - - props, ok := def.InputSchema["properties"].(map[string]any) - if !ok { - t.Fatal("expected properties in input schema") - } - actionProp, _ := props["action"].(map[string]any) - enumVals, _ := actionProp["enum"].([]any) - expected := map[string]bool{ - "list_spaces": true, - "get_space": true, - "create_space_node": true, - "list_space_nodes": true, - "get_node": true, - "move_node": true, - "copy_node": true, - } - for _, v := range enumVals { - delete(expected, v.(string)) - } - if len(expected) > 0 { - t.Fatalf("missing actions in enum: %v", expected) - } -} - -func TestWikiToolUnknownAction(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "unknown", - }) - if err == nil { - t.Fatal("expected error for unknown action") - } -} - -func TestWikiToolGetSpaceMissingID(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_space", - }) - if err == nil { - t.Fatal("expected error for missing space_id") - } -} - -func TestWikiToolCreateNodeMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "create_space_node", - "space_id": "space123", - }) - if err == nil { - t.Fatal("expected error for missing obj_type and node_type") - } -} - -func TestWikiToolListNodesMissingSpace(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "list_space_nodes", - }) - if err == nil { - t.Fatal("expected error for missing space_id") - } -} - -func TestWikiToolGetNodeMissingToken(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "get_node", - }) - if err == nil { - t.Fatal("expected error for missing node_token") - } -} - -func TestWikiToolMoveNodeMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "move_node", - }) - if err == nil { - t.Fatal("expected error for missing space_id and node_token") - } -} - -func TestWikiToolCopyNodeMissingParams(t *testing.T) { - larkClient := lark.NewClient("fake_id", "fake_secret") - client := NewClient(larkClient) - tool := NewWikiTool(client) - - _, err := tool.Execute(context.Background(), map[string]any{ - "action": "copy_node", - "space_id": "space123", - }) - if err == nil { - t.Fatal("expected error for missing node_token") - } -} From 84c78a0cc7b912f637776b39087af9b17dafd4fb Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 28 Mar 2026 17:30:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20address=20feishu=20re?= =?UTF-8?q?view=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/channel/feishu/feishu_test.go | 12 +++ internal/channel/feishu/handler.go | 3 + internal/db/database_test.go | 102 ++++++++++++++++++ .../20260328084600_drop_feishu_tokens.sql | 1 + internal/db/migrations/atlas.sum | 3 +- 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 internal/db/database_test.go create mode 100644 internal/db/migrations/20260328084600_drop_feishu_tokens.sql diff --git a/internal/channel/feishu/feishu_test.go b/internal/channel/feishu/feishu_test.go index 3f414d31..bbb3106a 100644 --- a/internal/channel/feishu/feishu_test.go +++ b/internal/channel/feishu/feishu_test.go @@ -451,6 +451,18 @@ func TestHandleCommandWhoami(t *testing.T) { } } +func TestHandleCommandAuthDeprecated(t *testing.T) { + bot := &Bot{} + var reply string + handled := bot.handleCommand(&channel.ResolvedChat{SessionKey: "ch"}, "/auth abc123", "ou_test123", func(s string) { reply = s }) + if !handled { + t.Fatal("expected /auth to be handled") + } + if !strings.Contains(reply, "removed") || !strings.Contains(reply, "lark-cli") { + t.Errorf("unexpected reply: %s", reply) + } +} + // --- handleModelCommand --- func TestHandleModelCommandListModels(t *testing.T) { diff --git a/internal/channel/feishu/handler.go b/internal/channel/feishu/handler.go index 6f4e27fb..c489f41a 100644 --- a/internal/channel/feishu/handler.go +++ b/internal/channel/feishu/handler.go @@ -460,6 +460,9 @@ func (b *Bot) handleCommand(rc *channel.ResolvedChat, text, senderID string, rep args := channel.ParseCommandArgs(text, fields[0]) switch cmd { + case "/auth": + reply("The /auth command was removed. Feishu workspace OAuth is no longer supported. If you need Lark workspace access, install a lark-cli skill and run `lark-cli auth login --recommend`.") + return true case "/model": b.handleModelCommand(rc, args, reply) return true diff --git a/internal/db/database_test.go b/internal/db/database_test.go new file mode 100644 index 00000000..565d967e --- /dev/null +++ b/internal/db/database_test.go @@ -0,0 +1,102 @@ +package db + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" +) + +func TestOpenDBFreshInstallDoesNotCreateFeishuTokensTable(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "fresh.db") + db, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if tableExists(t, db, "feishu_tokens") { + t.Fatal("feishu_tokens table should not exist after fresh install migrations") + } +} + +func TestOpenDBDropsLegacyFeishuTokensTableOnUpgrade(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "upgrade.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + if err := ConfigureDB(db); err != nil { + t.Fatalf("ConfigureDB: %v", err) + } + + _, err = db.Exec(`CREATE TABLE schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`) + if err != nil { + t.Fatalf("create schema_migrations: %v", err) + } + + files, err := migrationFiles() + if err != nil { + t.Fatalf("migrationFiles: %v", err) + } + + const dropVersion = "20260328084600_drop_feishu_tokens" + for _, file := range files { + version := strings.TrimSuffix(file, ".sql") + if version == dropVersion { + continue + } + if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil { + t.Fatalf("seed schema_migrations %s: %v", version, err) + } + } + + _, err = db.Exec(`CREATE TABLE feishu_tokens ( + open_id TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at TEXT NOT NULL, + refresh_expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`) + if err != nil { + t.Fatalf("create feishu_tokens: %v", err) + } + + if err := db.Close(); err != nil { + t.Fatalf("close seeded db: %v", err) + } + + upgraded, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB upgrade: %v", err) + } + t.Cleanup(func() { _ = upgraded.Close() }) + + if tableExists(t, upgraded, "feishu_tokens") { + t.Fatal("feishu_tokens table should be dropped during upgrade migrations") + } +} + +func tableExists(t *testing.T, db *sql.DB, name string) bool { + t.Helper() + + var count int + if err := db.QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", + name, + ).Scan(&count); err != nil { + t.Fatalf("query sqlite_master for %s: %v", name, err) + } + return count > 0 +} diff --git a/internal/db/migrations/20260328084600_drop_feishu_tokens.sql b/internal/db/migrations/20260328084600_drop_feishu_tokens.sql new file mode 100644 index 00000000..7bf30a2d --- /dev/null +++ b/internal/db/migrations/20260328084600_drop_feishu_tokens.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `feishu_tokens`; diff --git a/internal/db/migrations/atlas.sum b/internal/db/migrations/atlas.sum index e33f1855..2d097377 100644 --- a/internal/db/migrations/atlas.sum +++ b/internal/db/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:wGcEYUAqFeMyTlTSjkJ+7SKD7quE/3apiKxBQN1BREU= +h1:j2V1iIGLfzHgRcROFuyIQuqzAde+pIwQsd97Huk52C4= 20260317041843_rename_tables_with_prefixes.sql h1:Nfk81sAWddxDQvpo8pBhXJ5Sec5JcCF6tchqgt+777M= 20260317065837_drop_agent_provider_id.sql h1:gb4CoI8GlT4P3QbksGw07M8SruOcR9pQI0Sj7/Q2rao= 20260320104110_add-auth-tables.sql h1:6TjzFFeMMTJzy3/mGN4nrm4+SoYGhanOQY24nUbL3H4= @@ -8,3 +8,4 @@ h1:wGcEYUAqFeMyTlTSjkJ+7SKD7quE/3apiKxBQN1BREU= 20260321145321_add_agent_creator_id.sql h1:2HiuO2XWSU8Zk8Scirx5mof09VNYpvSxHK7ZMYkxgUM= 20260321163635_add_feishu_tokens.sql h1:hM/g/wZ9vpR/H3ZoJOyH8NUKRJpLy5Mu8Rmv90OUz5w= 20260322151434_add-notify-identity.sql h1:+QlRGAqKwce8YXkU/6gdMv73IHmgt/wqQ8NP/6r22SA= +20260328084600_drop_feishu_tokens.sql h1:2CHvjeF2egs2/CjCZJTw5BsOoyzPeqJrFskSmx9dWsM= From e874b777d09a8dca39e80b5cc549a79009705236 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 28 Mar 2026 17:36:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stabilize=20ci=20chec?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/anna/commands_test.go | 2 +- internal/channel/feishu/feishu.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/anna/commands_test.go b/cmd/anna/commands_test.go index 6619a3dc..192c2ed9 100644 --- a/cmd/anna/commands_test.go +++ b/cmd/anna/commands_test.go @@ -69,7 +69,7 @@ func TestRunGatewayNoServices(t *testing.T) { config.ResetAnnaHome() t.Cleanup(config.ResetAnnaHome) app := newApp() - err := app.Run([]string{"anna", "gateway"}) + err := app.Run([]string{"anna", "--admin-port", "0", "gateway"}) if err == nil { t.Fatal("expected error for no configured services") } diff --git a/internal/channel/feishu/feishu.go b/internal/channel/feishu/feishu.go index fd5550a8..f0e3d87f 100644 --- a/internal/channel/feishu/feishu.go +++ b/internal/channel/feishu/feishu.go @@ -48,8 +48,8 @@ type Config struct { AppSecret string `json:"app_secret"` EncryptKey string `json:"encrypt_key"` VerificationToken string `json:"verification_token"` - GroupMode string `json:"group_mode"` // "mention" | "always" | "disabled" - Groups map[string]GroupConfig `json:"groups"` // per-group overrides keyed by chat_id + GroupMode string `json:"group_mode"` // "mention" | "always" | "disabled" + Groups map[string]GroupConfig `json:"groups"` // per-group overrides keyed by chat_id } // Bot wraps a Feishu bot with agent pool integration.